From 9ad040b8150df11c6c6c1b6d140c744f605b3a88 Mon Sep 17 00:00:00 2001
From: dnnsjsk
Date: Tue, 13 Oct 2026 10:42:21 +0200
Subject: [PATCH 01/18] Add the JSX Pack PRD
Plans JSX support and native React 19.3 server rendering in pure PHP,
plus a general JSX renderer for server-only HTML. Records the sign-off
decisions and the verified facts behind them: React's server output
compared byte for byte, hydration tested in jsdom, and rough local
timings for real React, Preact and the components alone.
Co-Authored-By: Claude Opus 5.5
---
prds/JSX-PACK.md | 561 +++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 561 insertions(+)
create mode 100644 prds/JSX-PACK.md
diff --git a/prds/JSX-PACK.md b/prds/JSX-PACK.md
new file mode 100644
index 00000000..6c106620
--- /dev/null
+++ b/prds/JSX-PACK.md
@@ -0,0 +1,561 @@
+# PRD: JSX Pack
+
+**Status**: Approved, not started
+
+**Sign-off decisions (2026-10-13)**:
+1. The client framework is **React**, and the server targets **React 19.3.0** exactly.
+2. **Speed first**: React's server rendering is built into Phasis natively, in PHP. No React bundle is loaded or interpreted.
+3. A **general JSX renderer** ships alongside it for server-only HTML.
+4. All six open questions are resolved as recommended (see "Resolved questions").
+5. **Phase order** confirmed: React first (Phases 1 to 5), with the HTML profile (Phase 6) built alongside Phase 5.
+6. **Toolchain pin and vendored React** confirmed. `tests/Jsx/oracle/` pins the reference tools and holds the ~580 KB real React 19.3 bundle, used as the exact fallback and the three-way oracle.
+
+**Author**: this session
+**Last updated**: 2026-10-13
+**Builds on**: the ES module loader (`src/Module/ModuleLoader.php`), the event loop, and FETCH-PACK.md (components that call `fetch`)
+
+## Summary
+
+Render JSX components to HTML on the server, in pure PHP, fast enough for real pages, with output that React 19.3 on the client hydrates without errors. Four pieces:
+
+1. **Syntax**: JSX in `.jsx` modules, lowered at parse time to plain function calls. The interpreter, bytecode compiler, VM and JsToPhp emitter never see a JSX node.
+2. **Native React 19.3 SSR**: `react`, `react/jsx-runtime`, `react-dom`, `react-dom/server` and `react-dom/static` are built-in modules implemented in PHP. Their HTML is byte-identical to React 19.3's own server renderer. Only the application's components run as JavaScript.
+3. **General JSX renderer**: `phasis` and `phasis/jsx-runtime` for server-only templates (emails, feeds, static pages), byte-identical to `preact-render-to-string` 6.8, plus `raw()`.
+4. **Host plumbing**: built-in modules, import maps for bare specifiers, `.jsx` routing, and `Engine::render()` so PHP renders a component with PHP props in one call.
+
+Both renderers share one element model (React 19's) and one render core. They differ only in how they serialize HTML.
+
+## Goals
+
+### React page, hydrated by React 19.3 on the client
+
+The component is the same file the client bundle imports:
+
+```jsx
+// components/PostPage.jsx
+import { useId, useState } from 'react';
+
+export default function PostPage({ post }) {
+ const [open, setOpen] = useState(false);
+ const panelId = useId();
+ return (
+
+ {post.title}
+ setOpen(!open)}>
+ {post.commentCount} comments
+
+
+
+ );
+}
+```
+
+PHP renders it and embeds the props, so React on the client renders the same tree:
+
+```php
+use Phasis\Engine;
+
+$engine = new Engine();
+$props = ['post' => ['title' => $post->post_title, 'commentCount' => 3, 'excerpt' => $excerpt]];
+
+echo '' . $engine->render(__DIR__ . '/components/PostPage.jsx', $props) . '
';
+echo '';
+```
+
+The client side is standard React 19.3 tooling, not part of Phasis:
+
+```jsx
+hydrateRoot(document.getElementById('root'), );
+```
+
+Two details make this hydrate cleanly. The text markers React needs (``) are present, and `useId()` produces the same `_R_..._` ids React 19.3 generates on the client.
+
+### Data loading
+
+`Engine::render()` follows React 19.3's `prerender` semantics: it waits for async components and for `use(promise)` inside ``.
+
+```jsx
+export default async function Comments({ postId }) {
+ const res = await fetch(`https://api.example.com/posts/${postId}/comments`);
+ const comments = await res.json();
+ return {comments.map((c) => {c.body} )} ;
+}
+```
+
+React 19.3's client hydrates async components in production. Its development build logs "is an async Client Component", because React officially supports async components only as Server Components. Keep data-fetching components in parts of the page React does not hydrate, or pass the data down as props.
+
+### Server-only templates with the general renderer
+
+```jsx
+/** @jsxImportSource phasis */
+import { raw } from 'phasis';
+
+export default function Newsletter({ name, articleHtml }) {
+ return (
+
+ Hello {name}
+ {raw(articleHtml)}
+
+ );
+}
+```
+
+### Exact fallback
+
+Some libraries depend on React internals that the native implementation doesn't cover. For those, a vendored ESM build of real React 19.3 can be mapped over the built-ins with an import map. It is byte-identical but roughly 15x slower (see "Why a native React renderer").
+
+### Tooling
+
+`Parser::parseSource($src, 'module', new JsxOptions(mode: JsxMode::Preserve))` returns the JSX AST, which can be exported as standard ESTree JSX nodes.
+
+## Non-goals
+
+- **TypeScript and `.tsx`**. Needs type stripping first; separate PRD.
+- **JSX outside modules in the Engine**. `eval()`, `new Function()`, `ShadowRealm` and classic scripts stay plain JavaScript. JSX is a compile-time, module-goal feature, as in Babel, esbuild, Bun and Deno. This keeps every test262 code path untouched. The standalone parser API still accepts JSX in the script goal for tooling.
+- **Client-side React**: the DOM, events, effects, state updates. The browser runs the real React.
+- **Streaming SSR**: `renderToReadableStream`, `renderToPipeableStream`, `resume*` and `prerenderToNodeStream` (shell-first output with inline swap scripts). v1 renders complete documents.
+- **React Server Components**: `"use client"` / `"use server"` directives, the Flight protocol, server actions.
+- **Other React versions.** The profile design allows adding 19.x versions later; v1 ships 19.3.0 only.
+- **A props-embedding or islands helper**. App-level in v1, as in the Goals example.
+- **`node_modules` resolution, `package.json` `exports`, CommonJS `require`**. Import maps only.
+- **Dev runtime and dev-only warnings** (`jsxDEV`, `__source`, `__self`).
+- **JSX output in the Formatter**. Follow-up; the Formatter keeps rejecting JSX until then.
+
+## Current state (verified 2026-10-13)
+
+- **JSX dies in the lexer today.** `Parser::ensureTokenized()` (`src/Parser/Parser.php:160`) tokenizes the whole file before parsing starts. `Don't
` fails with "Unterminated string literal", and `` lexes as the start of a regex (`RegExp(/div>;/)`). The regex fix-up approach (`rescanSlashAsRegExp()`) cannot recover from a lexer that has already thrown, so the lexer has to recognize JSX itself.
+- **The lexer already knows where JSX can start.** `canStartRegExp()` (`src/Lexer/Lexer.php:1820`) answers "may an expression start here?", including the paren walk-back that tells `if (...)` apart from a call. Plain JavaScript never starts an expression with `<`, so a `<` in that position followed by a name or `>` is unambiguously JSX.
+- **Only three parse sites matter.** `Engine::eval()` (`src/Engine.php:1659`), `Engine::execFile()` (`:1718`) and `ModuleLoader::linkModule()` (`src/Module/ModuleLoader.php:376`). Nothing re-parses source later; the AST is retained.
+- **Bare specifiers are rejected.** `ModuleLoader::resolve()` (`:1284`) throws for anything that isn't a relative or absolute path.
+- **`execFile()` never loads modules.** It always parses as a script, so an `.mjs` file with an `import` throws `'import' is only valid in module code`. `docs/api.md` claims otherwise. The CLI calls `eval(file_get_contents(...))` and loses the path.
+- **AST tooling.** `Walker` and `Serializer` are reflection-based and pick up new node classes automatically. `EstreeSerializer` (`src/Ast/EstreeSerializer.php:104`) and `JsPrinter` (`src/Formatter/JsPrinter.php:1069`) dispatch explicitly.
+- **Real React 19.3 already runs inside Phasis.**
+ - `react` plus `react-dom/server.edge`, bundled into one ESM file with esbuild (npm's React is CommonJS), renders byte-identical HTML in Phasis and in Node. That includes context, `useState`, `useMemo` and the text markers.
+ - The default browser server build creates a `MessageChannel` at load time, which Phasis doesn't have. The edge build doesn't.
+- **Real Preact 11.0.1 and preact-render-to-string 6.8.0** also render byte-identically in Phasis and Node.
+- **A parse oracle is already vendored.** `tests/Popular/babel-parser/babel-parser.js` parses JSX with `plugins: ['jsx', 'estree']` and emits every ESTree JSX node type.
+
+## Why a native React renderer
+
+The same page was rendered four ways inside Phasis: header, nav, 100 post cards with interpolated text, a context consumer, 1,316 elements in all. These are rough local measurements with Xdebug off and the tracing JIT on. They are directional only; the CI bench is the authoritative number.
+
+| Variant | Load per fresh engine | Render |
+|---|---:|---:|
+| Real React 19.3 (bundled edge build) | ~565 ms | ~1,850 ms |
+| Real Preact 11 + preact-render-to-string | ~165 ms | ~870 ms |
+| Minimal hand-written JS renderer | none | ~270 ms |
+| The components alone, producing no HTML | none | ~110 ms |
+
+Node renders the same page in about 3 ms.
+
+- **The cost is React, not the components.** About 94% of real React's render time is React's own code. The components themselves account for the rest.
+- **What the native path keeps.** Element creation, hooks and serialization move into PHP, so the cost approaches the components-only floor. That is an estimated 15x faster render than real React on this page.
+- **No library load.** The ~565 ms load disappears, because the library is PHP code that OPcache keeps compiled. It can also be loaded at server start with `opcache.preload`.
+
+## Why byte-identical output, pinned to 19.3
+
+**Hydration depends on React's exact text markers.** Both server formats were hydrated in jsdom, against the React 19.3 client and the Preact 11 client:
+
+| Server HTML | Preact client | React client |
+|---|---|---|
+| Preact format: `Hello Dennis
` | hydrates | **fails** |
+| React format: `Hello Dennis
` | hydrates | hydrates |
+
+When text pieces sit next to each other (`Hello {name}`, `{n} comments`), React's server output separates them with ``. Without the markers, React reports "Hydration failed because the server rendered text didn't match the client", discards the server DOM and renders the tree again in the browser. This happens in production and in development.
+
+Other format differences produce the same DOM and hydrate fine, including `disabled` vs `disabled=""` and `'` vs `'`. The one exception: a trailing `;` in `style` triggers a development-only attribute warning.
+
+**React's server output changes between minor versions:**
+
+| React | `useId()` | ` ` |
+|---|---|---|
+| 18.3.1 | `:R1:` | no preload link |
+| 19.0.0 | `:R1:` | adds ` ` |
+| 19.1.0 | `«R1»` | preload link |
+| 19.3.0 | `_R_1_` | preload link |
+
+The ids the server generates must equal the ids the client generates, or `htmlFor` and `aria-*` links break after hydration. The native renderer therefore targets one exact version, React 19.3.0, behind a version profile. Moving the client to a later 19.x means a profile update and regenerated oracles.
+
+## Architecture
+
+### Layer 1: Lexer (JSX mode)
+
+`Lexer::setJsx(bool)` is set by the parser when JSX is enabled. With it off, tokenization is unchanged. JSX scanning lives in a trait so `Lexer.php` (already ~2,000 lines) doesn't grow further.
+
+The lexer keeps a context stack:
+
+| Frame | Entered by | Lexes | Left by |
+|---|---|---|---|
+| Tag | `<` where `canStartRegExp()` is true and the next non-whitespace character is an identifier start or `>`; or `<` inside Children | JSX names (identifier characters plus `-`; keywords such as `class` and `for` are plain names), attribute strings (no escapes, may span lines), `= : . / { >`; whitespace and both comment styles skipped | `>` |
+| Children | `>` ending an opening tag | `JsxText`: raw text up to `{` or `<`, with no whitespace or comment skipping | the closing tag's `>` |
+| Expression | `{` inside Tag or Children | normal JavaScript with brace counting; nested JSX allowed | `}` at depth 0 |
+
+JSX and template literals nest in both directions (`` `${ }` `` and `` {`${x}`} ``). The existing template handling (`Lexer.php:174`) runs before `readToken()` and assumes it owns every `}` at depth 0 inside a substitution, so template frames and JSX frames must share one ordering.
+
+New token types: `JsxTagStart`, `JsxTagEnd`, `JsxName`, `JsxText`, `JsxString`. The `JsxTagEnd` that closes a root element counts as value-producing in `canStartRegExp()`.
+
+Accepted as esbuild and Babel accept them: whitespace after `<` (`< div />`), comments inside tags, and literal `>` and `}` in text.
+
+Known gap: after `}` the lexer treats `<` as less-than, the same rule it applies to `/` today. A JSX element used as an expression statement directly after a block (`if (a) {}
`) therefore doesn't lex as JSX. Its value would be discarded anyway; documented.
+
+### Layer 2: Parser and AST
+
+- **`Parser\Parts\JsxParser` trait**, following the existing `Parts/*` pattern, entered from `parsePrimaryExpression()` (`src/Parser/Parts/ExpressionParser.php:878`) on `JsxTagStart`.
+- **AST nodes in `src/Ast/Jsx/`**, named per the codebase convention, with `EstreeSerializer` mapping them to ESTree names (`JsxElement` to `JSXElement` and so on): `JsxElement`, `JsxFragment`, `JsxOpeningElement`, `JsxClosingElement`, `JsxOpeningFragment`, `JsxClosingFragment`, `JsxAttribute`, `JsxSpreadAttribute`, `JsxExpressionContainer`, `JsxEmptyExpression`, `JsxSpreadChild`, `JsxText`, `JsxIdentifier`, `JsxMemberExpression`, `JsxNamespacedName`. `docs/parser.md` gains rows in its deviation table.
+- **Errors use Babel's wording**, since Babel is the parse oracle:
+ - `Expected corresponding JSX closing tag for .`
+ - `Adjacent JSX elements must be wrapped in an enclosing tag. Did you want a JSX fragment <>...>?`
+ - `JSX attributes must only be assigned a non-empty expression.`
+- **Parsing stays side-effect free.** `parseParenthesizedOrArrow()` backtracks (`ExpressionParser.php:2539`), so a JSX element inside parentheses may be parsed twice.
+
+Parser API:
+
+```php
+public static function parseSource(string $source, string $sourceType = 'script', ?JsxOptions $jsx = null): Program
+public function setJsxOptions(?JsxOptions $options): void
+```
+
+`Preserve` and `Classic` work in either goal. `Automatic` and `Precompile` inject imports and require the module goal.
+
+### Layer 3: Lowering at parse time
+
+`Parser\Jsx\JsxLowering` lowers each element as soon as it is parsed (bottom-up). Unless the mode is `Preserve`, the parser only ever returns ECMAScript nodes, each carrying the JSX node's `SourceLocation` so stack traces point at the JSX.
+
+```php
+enum JsxMode { case Automatic; case Classic; case Preserve; case Precompile; }
+
+final readonly class JsxOptions
+{
+ public function __construct(
+ public JsxMode $mode = JsxMode::Automatic,
+ public string $importSource = 'react',
+ public string $factory = 'React.createElement',
+ public string $fragment = 'React.Fragment',
+ /** @var list Module path suffixes the Engine parses with JSX. */
+ public array $extensions = ['.jsx'],
+ ) {
+ }
+}
+```
+
+The default import source is `react`, the same setting the client bundle compiles with. `react/jsx-runtime` resolves to the native built-in, so server and client compile the same files the same way.
+
+The output must match esbuild exactly. Verified against esbuild 0.28.2 (`/* @__PURE__ */` annotations omitted; shown with `importSource: 'react'`):
+
+| JSX | Automatic | Classic (`h`, `Fragment`) |
+|---|---|---|
+| `
` | `jsx("div", {})` | `h("div", null)` |
+| `{name}
` | `jsx("p", { children: name })` | `h("p", null, name)` |
+| `Hello {name}! ` | `jsxs(Card, { title: "Hi", children: ["Hello ", name, "!"] }, id)` | `h(Card, { title: "Hi", key: id }, "Hello ", name, "!")` |
+| `<> >` | `jsx(Fragment, { children: jsx("br", {}) })` | `h(Fragment, null, h("br", null))` |
+| ` ` | `jsx("input", { disabled: true })` | `h("input", { disabled: true })` |
+| `
` | `jsx("div", { ...props }, "x")` | `h("div", { key: "x", ...props })` |
+| `
` | `createElement("div", { ...props, key: "x" })`, imported from `"react"` | `h("div", { ...props, key: "x" })` |
+| `{...xs}
` | `jsxs("div", { children: [...xs] })` | `h("div", null, ...xs)` |
+| ` ` | `jsx("use", { "xlink:href": "#a" })` | `h("use", { "xlink:href": "#a" })` |
+
+Rules behind the table:
+
+- **Tag names.** A lowercase first letter, a `-`, or a namespace (`svg:rect`) makes a string. Everything else is a reference: `Foo`, `_x`, `$y`, `Foo.Bar.Baz`, `this.Comp`.
+- **Key after a spread** falls back to `createElement` from the **package root** (`"react"`, or `"phasis"` for the general runtime), not from the `jsx-runtime` module.
+- **`jsxs`** is used when `children` is an array: two or more children, or any spread child.
+- **Attribute strings**: HTML entities decoded, no backslash escapes, newlines kept as written.
+- **Text children**: entities decoded (XHTML named entities plus numeric references; unknown ones such as `&unknown;` stay literal). Whitespace follows the shared JSX rule: split on line terminators, drop whitespace-only lines, trim leading whitespace on every line but the first and trailing whitespace on every line but the last, join what's left with single spaces.
+
+ ```jsx
+
+ Hello,
+ world {name}
+ bold tail
+
+ // jsxs("div", { children: ["Hello, world ", name, jsx("b", { children: "bold" }), " tail"] })
+ ```
+- **Empty containers** `{}` and `{/* comment */}` produce no child.
+- **Injected imports.** Automatic mode prepends `import { jsx, jsxs, Fragment } from "react/jsx-runtime"`, importing only the names actually used. Local names are chosen so they don't appear as any identifier token in the file.
+- **Pragmas** in any comment override the options per file, as in esbuild: `@jsx`, `@jsxFrag`, `@jsxRuntime`, `@jsxImportSource`. The lexer already scans comments for `sourceURL`; pragma capture piggybacks on that scan. The module records its effective import source, which `Engine::render()` uses to pick a profile.
+
+### Layer 4: Host plumbing
+
+1. **Built-in module registry.** `ModuleLoader::registerBuiltinModule(string $specifier, \Closure $factory)`. `resolve()` returns a reserved key for registered specifiers, and `linkModule()` builds a synthetic namespace from the factory, mirroring the `.json` branch at `ModuleLoader.php:352`. Registered: `react`, `react/jsx-runtime`, `react-dom`, `react-dom/server`, `react-dom/static`, `phasis`, `phasis/jsx-runtime`, and the internal `phasis/jsx-precompile`.
+2. **Import maps.** `Engine::setImportMap(array $imports)`, following the WHATWG import map `imports` table: exact keys, plus keys ending in `/` as prefixes, with absolute paths as values. Resolution order: import map, built-in registry, relative or absolute path, then the existing error. The import map wins, which is how the exact-React fallback replaces the built-ins.
+3. **Which sources get JSX.** Module paths ending in one of `JsxOptions::$extensions` (default `['.jsx']`). `linkModule()` passes the options to the parser for those paths, and `evalAsModule($source, $path)` follows the same rule. `.js` files opt in by adding `.js` to the list.
+4. **`execFile()`** routes `.mjs` and JSX extensions through the module loader and returns `null` for them, like `evalAsModule()`. `.js` keeps today's script behavior, and `docs/api.md` is corrected.
+5. **PHP entry points:**
+
+ ```php
+ enum RenderProfile { case React; case Html; }
+
+ public function setJsxOptions(JsxOptions $options): void
+ public function setImportMap(array $imports): void
+ public function render(string $path, array $props = [], string $export = 'default', ?RenderProfile $profile = null): string
+ public function renderSource(string $source, array $props = [], string $export = 'default', ?string $path = null, ?RenderProfile $profile = null): string
+ public function getExport(string $path, string $name = 'default'): mixed
+ ```
+
+ - **Profile choice.** `render()` uses the given profile, else the entry module's effective JSX import source (`react` gives `React`, `phasis` gives `Html`).
+ - **Steps.** It loads the module, converts `$props` with `PhpToJs`, renders the export, and runs the event loop until the render settles.
+ - **Waiting semantics.** The React profile follows React 19.3's `prerender` and waits for every async component and `use(promise)`. The HTML profile awaits async components.
+ - **Failures.** A rejected render rethrows as `JsThrowable`. A render still pending after the loop drains throws `RuntimeError` ("render did not settle") instead of hanging.
+ - **`renderSource()`** does the same for an in-memory module; `$path` anchors relative imports.
+ - **`getExport()`** returns one export converted to PHP. This is how the exact-React fallback hands HTML back to PHP, and it avoids `evalAsModule()`'s self-referential namespace problem.
+6. **CLI.**
+ - `bin/phasis file.jsx` goes through `execFile()`.
+ - `--ast` uses `Preserve` for `.jsx` files.
+ - Optional: `bin/phasis --render file.jsx --props '{"id":1}'`.
+
+### Layer 5: Render core (shared)
+
+`src/BuiltIn/Jsx/` holds one PHP render core, used by both profiles.
+
+**Element model.** One element model, React 19's:
+
+- Both runtimes create `Phasis\Value\JsElement` instances through native `jsx`, `jsxs` and `createElement`.
+- JS sees React 19.3's shape: own properties `$$typeof` (`Symbol.for('react.transitional.element')`), `type`, `key`, `ref` and `props`.
+- Special types use React's own `Symbol.for` keys: `react.fragment`, `react.suspense`, `react.memo`, `react.forward_ref`, `react.lazy`, `react.context`, `react.consumer` and the others. Libraries that check `$$typeof` (react-is style) keep working.
+- The renderer accepts the PHP class (fast path) and objects carrying the element symbol, as React does. Objects from `JSON.parse` can't carry symbols, so they never render as elements.
+
+**Components.**
+- Function components.
+- Class components (`Component`, `PureComponent`): constructor, `getDerivedStateFromProps`, `UNSAFE_componentWillMount`, `render`, `static contextType`, and `defaultProps` (class components only, as in React 19).
+- `memo`, `forwardRef` and `lazy`.
+
+**Hooks dispatcher.** Server semantics per React 19.3, ported from React's Fizz hooks:
+- `useState` and `useReducer` return the initial state. Render-phase updates re-render the component, as React does.
+- `useMemo` and `useCallback` compute once. `useRef` returns `{ current }`.
+- `useEffect`, `useLayoutEffect`, `useInsertionEffect` and `useImperativeHandle` never run.
+- `useSyncExternalStore` reads `getServerSnapshot`.
+- `useTransition`, `useDeferredValue`, `useOptimistic`, `useActionState` and `useEffectEvent` behave as React's server renderer does.
+- `useContext` and `use()` cover both context and promises.
+
+**`useId`.** Ids come from React's tree-position algorithm (fork indexes encoded per tree level) and use React 19.3's `_R_..._` format.
+
+**Context.** Three forms are supported, each read with `useContext` or `use`: `` (React 19), `` and ``.
+
+**Thenables.** Async components, `use(promise)` and `lazy` suspend the render through a PHP Fiber, the mechanism generators already use (`src/Value/JsGenerator.php:14`). The PHP host drives the event loop until the promise settles. Each component's context and tree position are captured, so suspension can't leak state between components.
+
+**Profiles.** Profiles only decide serialization: text, attributes, markers and hoisting.
+
+### Layer 6: React 19.3 profile
+
+Modules match React 19.3.0's export lists.
+
+The streaming and resume APIs are out of scope (see Non-goals): `renderToReadableStream`, `renderToPipeableStream`, `resume`, `resumeToPipeableStream`, `prerenderToNodeStream`, `resumeAndPrerender` and `resumeAndPrerenderToNodeStream`. They are still exported, but calling one throws a `TypeError` naming the limitation. That way a module that merely imports them still links.
+
+| Module | Exports |
+|---|---|
+| `react` | `Activity`, `Children`, `Component`, `Fragment`, `Profiler`, `PureComponent`, `StrictMode`, `Suspense`, `ViewTransition`, `addTransitionType`, `cache`, `cacheSignal`, `cloneElement`, `createContext`, `createElement`, `createRef`, `forwardRef`, `isValidElement`, `lazy`, `memo`, `startTransition`, `unstable_useCacheRefresh`, `use`, `useActionState`, `useCallback`, `useContext`, `useDebugValue`, `useDeferredValue`, `useEffect`, `useEffectEvent`, `useId`, `useImperativeHandle`, `useInsertionEffect`, `useLayoutEffect`, `useMemo`, `useOptimistic`, `useReducer`, `useRef`, `useState`, `useSyncExternalStore`, `useTransition`, `version` (`"19.3.0"`) |
+| `react/jsx-runtime` | `jsx`, `jsxs`, `Fragment` |
+| `react-dom` | `browser`, `createPortal`, `flushSync`, `preconnect`, `prefetchDNS`, `preinit`, `preinitModule`, `preload`, `preloadModule`, `requestFormReset`, `unstable_batchedUpdates`, `useFormState`, `useFormStatus`, `version` |
+| `react-dom/server` | `renderToString`, `renderToStaticMarkup`, `version` (plus the throwing streaming stubs) |
+| `react-dom/static` | `prerender` (returns `{ prelude }` as a `ReadableStream`, like React), `version` (plus the throwing stubs) |
+
+Each export behaves as React 19.3's server renderer does, as pinned by the oracle. Examples: `createPortal` throws the way React's server renderer does, and the `preload` family emits resource hints.
+
+Output rules. The oracle is the source of truth; the following are already verified against React 19.3.0:
+
+- **Text markers.** `` goes between adjacent text pieces: `Hello Dennis
`.
+- **Suspense markers.**
+ - A resolved boundary is `...`.
+ - A boundary that falls back to client rendering (sync `renderToString`, or an error) is ` fallback`.
+ - Sync `renderToString` throws "A component suspended while responding to synchronous input" when nothing catches the suspension. `prerender` and `Engine::render()` wait instead.
+- **Escaping.** `&`, `<`, `>`, `"` and `'` are escaped, with `'` as `'`.
+- **Attributes.**
+ - Booleans render as `disabled=""`.
+ - React's attribute naming table applies (`readOnly=""`, `tabindex`).
+ - Function values are dropped.
+ - `aria-*` and `data-*` booleans are stringified.
+- **Style.** `style` objects serialize without a trailing `;` (`color:red;font-size:12px`). String `style` values throw, as in React.
+- **URL safety.** `javascript:` URLs in `href`, `src`, `action` and `formAction` become React's blocking URL, `javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')`.
+- **Resource hints and hoisting.** Images emit ` ` ahead of the content. Rendering ``, `` and `` hoists ``, ` `, ` `, stylesheet precedence and async scripts the way React 19.3 does.
+- **Forms.** `value`, `defaultValue`, `checked` and `defaultChecked` follow React's handling, and `` marks the matching `` as `selected=""`.
+- **`useId`** renders `_R_1_`-style ids.
+
+**Implementation approach.** Port the synchronous and prerender paths from React's MIT-licensed Fizz sources (`ReactFizzServer`, `ReactFizzHooks`, `ReactFizzConfigDOM`) rather than reverse-engineering the output. Ported files keep React's copyright notice.
+
+### Layer 7: General HTML profile
+
+`phasis/jsx-runtime` exports `jsx`, `jsxs` and `Fragment`. `phasis` exports:
+
+- `createElement` and `Fragment`
+- `createContext`, `useContext` and the shared server hooks
+- `renderToString` and `renderToStringAsync`
+- `raw`
+
+Output is byte-identical to preact-render-to-string 6.8.0. HTML spellings (`class`, `for`, string `style`) work as written. Behaviors verified against it:
+
+- **Text**
+ - Escapes `&`, `<` and `"` only.
+ - Numbers use JS `ToString` (`-0` becomes `0`, `1e21` becomes `1e+21`).
+ - `null`, `undefined` and booleans render nothing.
+ - Arrays and fragments flatten; adjacent text joins with no markers.
+- **Elements.** Void elements render as ` `.
+- **Attribute names**
+ - `className` becomes `class` and `htmlFor` becomes `for`.
+ - HTML camelCase props are lowercased (`readonly`, `maxlength`).
+ - Inside SVG, presentation attributes are kebab-cased (`stroke-width`), and `xlinkHref` becomes `xlink:href`.
+- **Attribute values**
+ - `true` or `""` render as a bare attribute (`disabled`, `alt`).
+ - `false`, `null`, `undefined` and functions are omitted.
+ - `aria-*` and `data-*` booleans are stringified.
+ - `style` objects become `prop:value;` pairs with a trailing `;`, and `px` is added to numbers except unitless and custom properties.
+- **Special cases**
+ - Unsafe attribute names are dropped.
+ - `dangerouslySetInnerHTML.__html` is inserted unescaped.
+ - ` ` or ` `.
+ */
+class JsxElement extends Node
+{
+ /**
+ * @param Node[] $children
+ */
+ public function __construct(
+ SourceLocation $location,
+ public readonly JsxOpeningElement $openingElement,
+ public readonly ?JsxClosingElement $closingElement,
+ public readonly array $children,
+ ) {
+ parent::__construct($location);
+ }
+
+ public function type(): string
+ {
+ return 'JsxElement';
+ }
+}
diff --git a/src/Ast/Jsx/JsxEmptyExpression.php b/src/Ast/Jsx/JsxEmptyExpression.php
new file mode 100644
index 00000000..92d1d1d8
--- /dev/null
+++ b/src/Ast/Jsx/JsxEmptyExpression.php
@@ -0,0 +1,25 @@
+children>`.
+ */
+class JsxFragment extends Node
+{
+ /**
+ * @param Node[] $children
+ */
+ public function __construct(
+ SourceLocation $location,
+ public readonly JsxOpeningFragment $openingFragment,
+ public readonly JsxClosingFragment $closingFragment,
+ public readonly array $children,
+ ) {
+ parent::__construct($location);
+ }
+
+ public function type(): string
+ {
+ return 'JsxFragment';
+ }
+}
diff --git a/src/Ast/Jsx/JsxIdentifier.php b/src/Ast/Jsx/JsxIdentifier.php
new file mode 100644
index 00000000..06e08d65
--- /dev/null
+++ b/src/Ast/Jsx/JsxIdentifier.php
@@ -0,0 +1,26 @@
+` that opens a fragment.
+ */
+class JsxOpeningFragment extends Node
+{
+ public function __construct(
+ SourceLocation $location,
+ ) {
+ parent::__construct($location);
+ }
+
+ public function type(): string
+ {
+ return 'JsxOpeningFragment';
+ }
+}
diff --git a/src/Ast/Jsx/JsxSpreadAttribute.php b/src/Ast/Jsx/JsxSpreadAttribute.php
new file mode 100644
index 00000000..5d4f7586
--- /dev/null
+++ b/src/Ast/Jsx/JsxSpreadAttribute.php
@@ -0,0 +1,26 @@
+` would otherwise be read as an unterminated string or a regex.
+ *
+ * A stack of frames tracks where the scanner is:
+ * - tag: between `<` and `>` (names, attribute strings, punctuation)
+ * - children: between an opening tag and its closing tag (raw text)
+ * - expr: inside `{...}`, lexed as ordinary JavaScript with brace counting
+ *
+ * Expression frames record how many template substitutions were open when
+ * they started, so a `}` is attributed to whichever of the two (a template
+ * substitution or a JSX expression container) opened last.
+ */
+trait JsxScanning
+{
+ private bool $jsx = false;
+
+ /**
+ * @var list
+ */
+ private array $jsxStack = [];
+
+ /** @var array Pragmas found in comments: jsx, jsxFrag, jsxRuntime, jsxImportSource. */
+ private array $jsxPragmas = [];
+
+ /** Enable JSX scanning. Off by default, which leaves tokenization unchanged. */
+ public function setJsx(bool $jsx): void
+ {
+ $this->jsx = $jsx;
+ }
+
+ /** @return array */
+ public function jsxPragmas(): array
+ {
+ return $this->jsxPragmas;
+ }
+
+ /**
+ * @param 'tag'|'children'|'expr' $kind
+ * @return array{kind: 'tag'|'children'|'expr', tokens: int, closing: bool, selfClosing: bool, templates: int, depth: int}
+ */
+ private function newJsxFrame(string $kind): array
+ {
+ return [
+ 'kind' => $kind,
+ 'tokens' => 0,
+ 'closing' => false,
+ 'selfClosing' => false,
+ 'templates' => count($this->templateBraceDepth),
+ 'depth' => 0,
+ ];
+ }
+
+ /**
+ * True when `<` at the current position starts a JSX element: an
+ * expression may start here and the `<` is followed (after optional
+ * whitespace) by a name or by `>` for a fragment.
+ */
+ private function jsxCanStartElement(): bool
+ {
+ // After `}` the regex rule says "operand ended" because the brace may
+ // close an object literal. For `<` the other reading wins: a JSX
+ // statement after a block or declaration (`function A() {}\n `)
+ // is real code, while comparing an object or function literal with
+ // `<` is not. Babel and esbuild both read it as JSX.
+ $last = $this->tokens === [] ? null : $this->tokens[count($this->tokens) - 1];
+ if (($last === null || $last->type !== TokenType::RightBrace) && !$this->canStartRegExp()) {
+ return false;
+ }
+ $i = $this->pos + 1;
+ while ($i < $this->length) {
+ $c = $this->source[$i];
+ if ($c === ' ' || $c === "\t" || $c === "\n" || $c === "\r") {
+ $i++;
+ continue;
+ }
+ if ($c === '>') {
+ return true;
+ }
+ $o = ord($c);
+ return $o >= 0x80 || ctype_alpha($c) || $c === '_' || $c === '$';
+ }
+ return false;
+ }
+
+ private function startJsxElement(SourceLocation $start): Token
+ {
+ $this->advance();
+ $this->jsxStack[] = $this->newJsxFrame('tag');
+ return new Token(TokenType::JsxTagStart, '<', $start);
+ }
+
+ /** Next token while the innermost frame is a tag or children frame. */
+ private function readJsxToken(): Token
+ {
+ $top = count($this->jsxStack) - 1;
+ if ($this->jsxStack[$top]['kind'] === 'children') {
+ return $this->readJsxChildToken();
+ }
+ return $this->readJsxTagToken($top);
+ }
+
+ private function readJsxTagToken(int $top): Token
+ {
+ $this->lineTerminatorBefore = false;
+ $this->skipWhitespaceAndComments();
+ if ($this->pos >= $this->length) {
+ throw new SyntaxError('Unterminated JSX contents.', $this->location());
+ }
+ $start = $this->location();
+ $ch = $this->source[$this->pos];
+ $frame = $this->jsxStack[$top];
+ $this->jsxStack[$top]['tokens']++;
+
+ if ($ch === '>') {
+ $this->advance();
+ $token = new Token(TokenType::JsxTagEnd, '>', $start, $this->lineTerminatorBefore);
+ if ($frame['closing']) {
+ array_pop($this->jsxStack);
+ $children = array_pop($this->jsxStack);
+ if ($children === null || $children['kind'] !== 'children') {
+ throw new SyntaxError('Unexpected closing JSX tag', $start);
+ }
+ $this->afterJsxElement();
+ } elseif ($frame['selfClosing']) {
+ array_pop($this->jsxStack);
+ $this->afterJsxElement();
+ } else {
+ $this->jsxStack[$top] = $this->newJsxFrame('children');
+ }
+ return $token;
+ }
+ if ($ch === '/') {
+ $this->advance();
+ if ($frame['tokens'] === 0) {
+ $this->jsxStack[$top]['closing'] = true;
+ } else {
+ $this->jsxStack[$top]['selfClosing'] = true;
+ }
+ return new Token(TokenType::Slash, '/', $start, $this->lineTerminatorBefore);
+ }
+ if ($ch === '{') {
+ $this->advance();
+ $this->jsxStack[] = $this->newJsxFrame('expr');
+ return new Token(TokenType::LeftBrace, '{', $start, $this->lineTerminatorBefore);
+ }
+ if ($ch === '"' || $ch === "'") {
+ return $this->readJsxString($start, $ch);
+ }
+ if ($ch === '<') {
+ // An element used as an attribute value: ` />`.
+ return $this->startJsxElement($start);
+ }
+ $punctuator = match ($ch) {
+ '=' => TokenType::Equal,
+ ':' => TokenType::Colon,
+ '.' => TokenType::Dot,
+ default => null,
+ };
+ if ($punctuator !== null) {
+ $this->advance();
+ return new Token($punctuator, $ch, $start, $this->lineTerminatorBefore);
+ }
+ if ($this->isIdentifierStart($ch)) {
+ return $this->readJsxName($start);
+ }
+ throw new SyntaxError("Unexpected character in JSX tag: {$ch}", $start);
+ }
+
+ private function readJsxChildToken(): Token
+ {
+ $start = $this->location();
+ $ch = $this->source[$this->pos];
+ if ($ch === '{') {
+ $this->advance();
+ $this->jsxStack[] = $this->newJsxFrame('expr');
+ return new Token(TokenType::LeftBrace, '{', $start);
+ }
+ if ($ch === '<') {
+ return $this->startJsxElement($start);
+ }
+ $from = $this->pos;
+ while ($this->pos < $this->length) {
+ $c = $this->source[$this->pos];
+ if ($c === '{' || $c === '<') {
+ break;
+ }
+ $this->advanceTrackingLines($c);
+ }
+ if ($this->pos >= $this->length) {
+ throw new SyntaxError('Unterminated JSX contents.', $start);
+ }
+ return new Token(TokenType::JsxText, substr($this->source, $from, $this->pos - $from), $start);
+ }
+
+ private function readJsxString(SourceLocation $start, string $quote): Token
+ {
+ $this->advance();
+ $from = $this->pos;
+ while ($this->pos < $this->length && $this->source[$this->pos] !== $quote) {
+ $this->advanceTrackingLines($this->source[$this->pos]);
+ }
+ if ($this->pos >= $this->length) {
+ throw new SyntaxError('Unterminated string constant.', $start);
+ }
+ $value = substr($this->source, $from, $this->pos - $from);
+ $this->advance();
+ return new Token(TokenType::JsxString, $value, $start, $this->lineTerminatorBefore, $quote);
+ }
+
+ private function readJsxName(SourceLocation $start): Token
+ {
+ $from = $this->pos;
+ while ($this->pos < $this->length) {
+ $c = $this->source[$this->pos];
+ if ($c === '-' || $this->isIdentifierPart($c)) {
+ $this->advance();
+ continue;
+ }
+ break;
+ }
+ return new Token(TokenType::JsxName, substr($this->source, $from, $this->pos - $from), $start, $this->lineTerminatorBefore);
+ }
+
+ /** Advance one byte, keeping line and column right across line terminators. */
+ private function advanceTrackingLines(string $c): void
+ {
+ if ($c === "\n") {
+ $this->pos++;
+ $this->line++;
+ $this->column = 0;
+ return;
+ }
+ if ($c === "\r") {
+ $this->pos++;
+ if ($this->pos < $this->length && $this->source[$this->pos] === "\n") {
+ $this->pos++;
+ }
+ $this->line++;
+ $this->column = 0;
+ return;
+ }
+ $this->advance();
+ }
+
+ /**
+ * Called when an element finishes. Back in JavaScript, a following `<`
+ * that would start another element is the classic mistake of returning
+ * two roots; report it the way Babel does.
+ */
+ private function afterJsxElement(): void
+ {
+ if ($this->jsxStack !== [] && $this->jsxStack[count($this->jsxStack) - 1]['kind'] !== 'expr') {
+ return;
+ }
+ $i = $this->pos;
+ $line = $this->line;
+ $column = $this->column;
+ while ($i < $this->length && in_array($this->source[$i], [' ', "\t", "\n", "\r"], true)) {
+ if ($this->source[$i] === "\n" || ($this->source[$i] === "\r" && ($this->source[$i + 1] ?? '') !== "\n")) {
+ $line++;
+ $column = 0;
+ } else {
+ $column++;
+ }
+ $i++;
+ }
+ if ($i + 1 < $this->length && $this->source[$i] === '<') {
+ $next = $this->source[$i + 1];
+ if ($next === '>' || $next === '/' || ctype_alpha($next) || $next === '_' || $next === '$') {
+ throw new SyntaxError(
+ 'Adjacent JSX elements must be wrapped in an enclosing tag. Did you want a JSX fragment <>...>?',
+ new SourceLocation($line, $column, $i),
+ );
+ }
+ }
+ }
+
+ /**
+ * Handle `}` when the innermost open construct is a JSX expression
+ * container at brace depth 0. Returns the RightBrace token, or null when
+ * the brace belongs to JavaScript (or a template substitution).
+ */
+ private function closeJsxExpression(): ?Token
+ {
+ $top = count($this->jsxStack) - 1;
+ $frame = $this->jsxStack[$top];
+ if (
+ $frame['kind'] !== 'expr'
+ || $frame['depth'] !== 0
+ || $frame['templates'] !== count($this->templateBraceDepth)
+ ) {
+ return null;
+ }
+ $start = $this->location();
+ $this->advance();
+ array_pop($this->jsxStack);
+ return new Token(TokenType::RightBrace, '}', $start, $this->lineTerminatorBefore);
+ }
+
+ /** Count braces read as JavaScript inside the innermost JSX expression container. */
+ private function trackJsxExpressionBrace(Token $token): void
+ {
+ $top = count($this->jsxStack) - 1;
+ if (
+ $this->jsxStack[$top]['kind'] !== 'expr'
+ || $this->jsxStack[$top]['templates'] !== count($this->templateBraceDepth)
+ ) {
+ return;
+ }
+ if ($token->type === TokenType::LeftBrace) {
+ $this->jsxStack[$top]['depth']++;
+ } elseif ($token->type === TokenType::RightBrace) {
+ $this->jsxStack[$top]['depth']--;
+ }
+ }
+
+ /** Record `@jsx`, `@jsxFrag`, `@jsxRuntime` and `@jsxImportSource` pragmas. */
+ private function captureJsxPragmas(string $comment): void
+ {
+ if (!str_contains($comment, '@jsx')) {
+ return;
+ }
+ if (preg_match_all('/@(jsx|jsxFrag|jsxRuntime|jsxImportSource)\s+(\S+)/', $comment, $m, PREG_SET_ORDER) > 0) {
+ foreach ($m as $match) {
+ $this->jsxPragmas[$match[1]] = rtrim($match[2], '*/');
+ }
+ }
+ }
+}
diff --git a/src/Lexer/Lexer.php b/src/Lexer/Lexer.php
index 8874abbf..d91cd8dc 100644
--- a/src/Lexer/Lexer.php
+++ b/src/Lexer/Lexer.php
@@ -8,6 +8,8 @@
class Lexer
{
+ use JsxScanning;
+
private string $source;
private int $length;
private int $pos = 0;
@@ -80,6 +82,8 @@ public function tokenize(): array
{
$this->tokens = [];
$this->comments = [];
+ $this->jsxStack = [];
+ $this->jsxPragmas = [];
$this->pos = 0;
$this->line = 1;
$this->column = 0;
@@ -115,12 +119,26 @@ public function tokenize(): array
$tokenCountChecked = 0;
while ($this->pos < $this->length) {
+ // Inside a JSX tag or between JSX children, the JSX scanner owns
+ // the source: whitespace and `//` are significant there.
+ if ($this->jsxStack !== [] && $this->jsxStack[count($this->jsxStack) - 1]['kind'] !== 'expr') {
+ $this->tokens[] = $this->readJsxToken();
+ continue;
+ }
+
$this->lineTerminatorBefore = false;
$this->skipWhitespaceAndComments();
if ($this->pos >= $this->length) {
break;
}
+ if ($this->jsxStack !== [] && $this->source[$this->pos] === '}') {
+ $closing = $this->closeJsxExpression();
+ if ($closing !== null) {
+ $this->tokens[] = $closing;
+ continue;
+ }
+ }
// Only sample every 65k tokens. count() on a 4M-element
// array is cheap, but doing this guard on the inner loop
// would still measurably slow normal sources.
@@ -148,6 +166,7 @@ public function tokenize(): array
if (
$this->source[$this->pos] === '{'
&& $this->templateDepth === 0
+ && $this->jsxStack === []
&& $this->canCoalesceEmptyBlock()
) {
$savedPos = $this->pos;
@@ -191,6 +210,9 @@ public function tokenize(): array
$this->templateDepth++;
$this->templateBraceDepth[] = 0;
}
+ if ($this->jsxStack !== []) {
+ $this->trackJsxExpressionBrace($token);
+ }
// Track brace depth inside template expressions.
if ($this->templateDepth > 0) {
if ($token->type === TokenType::LeftBrace) {
@@ -265,6 +287,13 @@ private function readToken(): Token
return $this->readRegExp($start);
}
+ // JSX element: `<` where an expression may start, followed by a name
+ // or by `>` for a fragment. Plain JavaScript never starts an
+ // expression with `<`, so this cannot change the meaning of valid JS.
+ if ($ch === '<' && $this->jsx && $this->jsxCanStartElement()) {
+ return $this->startJsxElement($start);
+ }
+
// Private identifier (#name) for class private fields/methods.
// Accept an IdentifierStart character, or a '\u' unicode escape that
// must eventually decode to one (readIdentifier will validate).
@@ -1363,6 +1392,9 @@ private function skipWhitespaceAndComments(): void
$commentStart = $this->pos;
$this->skipToEndOfLine();
$this->captureSourceUrlPragma($commentStart, $this->pos);
+ if ($this->jsx) {
+ $this->captureJsxPragmas(substr($this->source, $commentStart, $this->pos - $commentStart));
+ }
if ($this->collectComments) {
$this->comments[] = new Comment(
'line',
@@ -1463,6 +1495,9 @@ private function skipWhitespaceAndComments(): void
if (!$closed) {
throw new SyntaxError('Unterminated multi-line comment', $commentStart);
}
+ if ($this->jsx) {
+ $this->captureJsxPragmas(substr($this->source, $commentStart->offset, $this->pos - $commentStart->offset));
+ }
if ($this->collectComments) {
$this->comments[] = new Comment(
'block',
@@ -1877,6 +1912,7 @@ private function canStartRegExp(): bool
|| $prev->type === TokenType::Super
// Contextual keywords that can be used as identifiers in expression context.
|| $prev->type === TokenType::Of
+ || $prev->type === TokenType::JsxTagEnd
) {
return false;
}
diff --git a/src/Lexer/TokenType.php b/src/Lexer/TokenType.php
index 150c3cd6..68dbd0ca 100644
--- a/src/Lexer/TokenType.php
+++ b/src/Lexer/TokenType.php
@@ -128,6 +128,14 @@ enum TokenType: string
// Private identifier (#name)
case PrivateIdentifier = 'PrivateIdentifier';
+ // JSX, emitted only when the lexer runs in JSX mode. Punctuation inside
+ // tags reuses the ordinary types (`/`, `=`, `:`, `.`, `{`, `}`).
+ case JsxTagStart = 'JsxTagStart';
+ case JsxTagEnd = 'JsxTagEnd';
+ case JsxName = 'JsxName';
+ case JsxText = 'JsxText';
+ case JsxString = 'JsxString';
+
// Special
case EOF = 'EOF';
diff --git a/src/Parser/Jsx/JsxLowering.php b/src/Parser/Jsx/JsxLowering.php
new file mode 100644
index 00000000..827f06f3
--- /dev/null
+++ b/src/Parser/Jsx/JsxLowering.php
@@ -0,0 +1,283 @@
+> import source => imported name => local name */
+ private array $imports = [];
+
+ /**
+ * @param \Closure(string): string $uniqueName Returns a local binding name not used in the module.
+ */
+ public function __construct(
+ private readonly JsxMode $mode,
+ private readonly string $importSource,
+ private readonly string $factory,
+ private readonly string $fragment,
+ private readonly \Closure $uniqueName,
+ ) {
+ }
+
+ public function lower(JsxElement|JsxFragment $node): Node
+ {
+ return $this->mode === JsxMode::Classic ? $this->lowerClassic($node) : $this->lowerAutomatic($node);
+ }
+
+ /** True when lowering referenced runtime functions that have to be imported. */
+ public function needsImports(): bool
+ {
+ return $this->imports !== [];
+ }
+
+ /** @return list */
+ public function importDeclarations(SourceLocation $location): array
+ {
+ $declarations = [];
+ foreach ($this->imports as $source => $names) {
+ $specifiers = [];
+ foreach ($names as $imported => $local) {
+ $specifiers[] = new ImportSpecifier($location, 'named', $local, $imported);
+ }
+ $declarations[] = new ImportDeclaration($location, $specifiers, $source);
+ }
+ return $declarations;
+ }
+
+ private function lowerAutomatic(JsxElement|JsxFragment $node): Node
+ {
+ $location = $node->location;
+ $runtime = $this->importSource . '/jsx-runtime';
+ $children = $this->lowerChildren($node->children);
+
+ if ($node instanceof JsxFragment) {
+ $type = $this->runtimeRef($runtime, 'Fragment', $location);
+ $props = [];
+ $key = null;
+ } else {
+ $type = $this->elementType($node->openingElement->name);
+ $props = [];
+ $all = [];
+ $key = null;
+ $sawSpread = false;
+ $keyAfterSpread = false;
+ foreach ($node->openingElement->attributes as $attribute) {
+ if (!($attribute instanceof JsxAttribute)) {
+ $spread = new SpreadElement($attribute->location, self::spreadArgument($attribute));
+ $props[] = $spread;
+ $all[] = $spread;
+ $sawSpread = true;
+ continue;
+ }
+ $property = $this->attributeProperty($attribute);
+ $all[] = $property;
+ if ($attribute->name instanceof JsxIdentifier && $attribute->name->name === 'key') {
+ if ($attribute->value === null) {
+ throw new SyntaxError('Please provide an explicit value for "key"', $attribute->location);
+ }
+ if ($sawSpread) {
+ $keyAfterSpread = true;
+ } else {
+ $key = $property->value;
+ }
+ continue;
+ }
+ $props[] = $property;
+ }
+ if ($keyAfterSpread) {
+ // A key after a spread can't be extracted without changing which
+ // value wins, so esbuild falls back to createElement from the
+ // package root, keeping every prop in order.
+ $callee = $this->runtimeRef($this->importSource, 'createElement', $location);
+ $arguments = [$type, new ObjectExpression($location, $all)];
+ foreach ($children as $child) {
+ $arguments[] = $child;
+ }
+ return new CallExpression($location, $callee, $arguments);
+ }
+ }
+
+ $static = false;
+ if (count($children) === 1 && !($children[0] instanceof SpreadElement)) {
+ $props[] = $this->property('children', $children[0], $location);
+ } elseif ($children !== []) {
+ $props[] = $this->property('children', new ArrayExpression($location, $children), $location);
+ $static = true;
+ }
+
+ $callee = $this->runtimeRef($runtime, $static ? 'jsxs' : 'jsx', $location);
+ $arguments = [$type, new ObjectExpression($location, $props)];
+ if ($key !== null) {
+ $arguments[] = $key;
+ }
+ return new CallExpression($location, $callee, $arguments);
+ }
+
+ private function lowerClassic(JsxElement|JsxFragment $node): Node
+ {
+ $location = $node->location;
+ if ($node instanceof JsxFragment) {
+ $type = $this->dottedName($this->fragment, $location);
+ $props = new Literal($location, null, 'null');
+ } else {
+ $type = $this->elementType($node->openingElement->name);
+ $properties = [];
+ foreach ($node->openingElement->attributes as $attribute) {
+ $properties[] = $attribute instanceof JsxAttribute
+ ? $this->attributeProperty($attribute)
+ : new SpreadElement($attribute->location, self::spreadArgument($attribute));
+ }
+ $props = $properties === [] ? new Literal($location, null, 'null') : new ObjectExpression($location, $properties);
+ }
+ $arguments = [$type, $props];
+ foreach ($this->lowerChildren($node->children) as $child) {
+ $arguments[] = $child;
+ }
+ return new CallExpression($location, $this->dottedName($this->factory, $location), $arguments);
+ }
+
+ /**
+ * @param Node[] $children
+ * @return list
+ */
+ private function lowerChildren(array $children): array
+ {
+ $lowered = [];
+ foreach ($children as $child) {
+ if ($child instanceof JsxTextNode) {
+ $text = JsxText::cleanChild($child->raw);
+ if ($text !== null) {
+ $lowered[] = new Literal($child->location, $text, $text);
+ }
+ continue;
+ }
+ if ($child instanceof JsxExpressionContainer) {
+ if (!($child->expression instanceof JsxEmptyExpression)) {
+ $lowered[] = $child->expression;
+ }
+ continue;
+ }
+ if ($child instanceof JsxSpreadChild) {
+ $lowered[] = new SpreadElement($child->location, $child->expression);
+ continue;
+ }
+ if ($child instanceof JsxElement || $child instanceof JsxFragment) {
+ $lowered[] = $this->lower($child);
+ }
+ }
+ return $lowered;
+ }
+
+ private function attributeProperty(JsxAttribute $attribute): Property
+ {
+ $name = $attribute->name instanceof JsxNamespacedName
+ ? $attribute->name->namespace->name . ':' . $attribute->name->name->name
+ : ($attribute->name instanceof JsxIdentifier ? $attribute->name->name : '');
+ $value = $attribute->value;
+ if ($value === null) {
+ $value = new Literal($attribute->location, true, 'true');
+ } elseif ($value instanceof JsxExpressionContainer) {
+ $value = $value->expression;
+ } elseif ($value instanceof JsxElement || $value instanceof JsxFragment) {
+ $value = $this->lower($value);
+ }
+ return $this->property($name, $value, $attribute->location);
+ }
+
+ private function property(string $name, Node $value, SourceLocation $location): Property
+ {
+ $key = preg_match('/^[A-Za-z_$][A-Za-z0-9_$]*$/', $name) === 1
+ ? new Identifier($location, $name)
+ : new Literal($location, $name, $name);
+ return new Property($location, $key, $value, 'init', false, false, false);
+ }
+
+ private function elementType(Node $name): Node
+ {
+ if ($name instanceof JsxNamespacedName) {
+ $text = $name->namespace->name . ':' . $name->name->name;
+ return new Literal($name->location, $text, $text);
+ }
+ if ($name instanceof JsxMemberExpression) {
+ return new MemberExpression(
+ $name->location,
+ $this->elementType($name->object),
+ new Identifier($name->property->location, $name->property->name),
+ false,
+ false,
+ );
+ }
+ if (!($name instanceof JsxIdentifier)) {
+ throw new \LogicException('Unexpected JSX element name: ' . $name->type());
+ }
+ if ($name->name === 'this') {
+ return new ThisExpression($name->location);
+ }
+ $first = $name->name[0];
+ if (($first >= 'a' && $first <= 'z') || str_contains($name->name, '-')) {
+ return new Literal($name->location, $name->name, $name->name);
+ }
+ return new Identifier($name->location, $name->name);
+ }
+
+ private static function spreadArgument(Node $attribute): Node
+ {
+ if (!($attribute instanceof JsxSpreadAttribute)) {
+ throw new \LogicException('Unexpected JSX attribute: ' . $attribute->type());
+ }
+ return $attribute->argument;
+ }
+
+ /** A reference to an automatic-runtime import, registering the import on first use. */
+ private function runtimeRef(string $source, string $imported, SourceLocation $location): Identifier
+ {
+ if (!isset($this->imports[$source][$imported])) {
+ $this->imports[$source][$imported] = ($this->uniqueName)('_' . $imported);
+ }
+ return new Identifier($location, $this->imports[$source][$imported]);
+ }
+
+ /** Build `React.createElement`-style member chains for the classic factory. */
+ private function dottedName(string $name, SourceLocation $location): Node
+ {
+ $parts = explode('.', $name);
+ $node = $parts[0] === 'this' ? new ThisExpression($location) : new Identifier($location, $parts[0]);
+ foreach (array_slice($parts, 1) as $part) {
+ $node = new MemberExpression($location, $node, new Identifier($location, $part), false, false);
+ }
+ return $node;
+ }
+}
diff --git a/src/Parser/Jsx/JsxMode.php b/src/Parser/Jsx/JsxMode.php
new file mode 100644
index 00000000..bcfebec4
--- /dev/null
+++ b/src/Parser/Jsx/JsxMode.php
@@ -0,0 +1,20 @@
+ $extensions Module path suffixes the Engine parses with JSX.
+ */
+ public function __construct(
+ public JsxMode $mode = JsxMode::Automatic,
+ public string $importSource = 'react',
+ public string $factory = 'React.createElement',
+ public string $fragment = 'React.Fragment',
+ public array $extensions = ['.jsx'],
+ ) {
+ }
+
+ /** True when $path ends in one of the JSX extensions. */
+ public function appliesTo(string $path): bool
+ {
+ $lower = strtolower($path);
+ foreach ($this->extensions as $extension) {
+ if (str_ends_with($lower, strtolower($extension))) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/Parser/Jsx/JsxText.php b/src/Parser/Jsx/JsxText.php
new file mode 100644
index 00000000..54781c75
--- /dev/null
+++ b/src/Parser/Jsx/JsxText.php
@@ -0,0 +1,208 @@
+|null */
+ private static ?array $entities = null;
+
+ /**
+ * Clean text between tags the way esbuild does. Whitespace is trimmed on
+ * the raw text, line by line, before entities are decoded: lines that
+ * are only whitespace disappear, leading whitespace goes from every line
+ * but the first, trailing whitespace from every line but the last, and
+ * the remaining lines are joined with one space. Returns null when
+ * nothing is left, meaning the text produces no child.
+ */
+ public static function cleanChild(string $raw): ?string
+ {
+ $out = '';
+ $hasOutput = false;
+ $first = 0;
+ $afterLast = -1;
+ $len = strlen($raw);
+ $i = 0;
+ while ($i < $len) {
+ $c = $raw[$i];
+ $width = 1;
+ $isNewline = false;
+ $isSpace = false;
+ if ($c === "\n" || $c === "\r") {
+ $isNewline = true;
+ } elseif ($c === ' ' || $c === "\t") {
+ $isSpace = true;
+ } elseif (ord($c) >= 0x80) {
+ $width = self::utf8Width(ord($c));
+ $char = substr($raw, $i, $width);
+ if ($char === "\u{2028}" || $char === "\u{2029}") {
+ $isNewline = true;
+ } elseif (self::isUnusualWhitespace($char)) {
+ $isSpace = true;
+ }
+ } elseif ($c === "\v" || $c === "\f") {
+ $isSpace = true;
+ }
+ if ($isNewline) {
+ if ($first !== -1 && $afterLast !== -1 && $afterLast > $first) {
+ if ($hasOutput) {
+ $out .= ' ';
+ }
+ $out .= self::decodeEntities(substr($raw, $first, $afterLast - $first));
+ $hasOutput = true;
+ }
+ $first = -1;
+ } elseif (!$isSpace) {
+ $afterLast = $i + $width;
+ if ($first === -1) {
+ $first = $i;
+ }
+ }
+ $i += $width;
+ }
+ if ($first !== -1) {
+ if ($hasOutput) {
+ $out .= ' ';
+ }
+ $out .= self::decodeEntities(substr($raw, $first));
+ $hasOutput = true;
+ }
+ return $hasOutput ? $out : null;
+ }
+
+ /**
+ * Decode `&name;`, `{` and `😀` references. Unknown names and
+ * malformed references stay literal. Numeric values are turned into
+ * UTF-16 code units the way esbuild does, so out-of-range values wrap
+ * and surrogates survive as lone code units.
+ */
+ public static function decodeEntities(string $text): string
+ {
+ if (!str_contains($text, '&')) {
+ return $text;
+ }
+ $entities = self::$entities ??= self::buildEntityTable();
+ $out = '';
+ $len = strlen($text);
+ $i = 0;
+ while ($i < $len) {
+ $amp = strpos($text, '&', $i);
+ if ($amp === false) {
+ $out = JsString::concatNormalize($out, substr($text, $i));
+ break;
+ }
+ $out = JsString::concatNormalize($out, substr($text, $i, $amp - $i));
+ $semi = strpos($text, ';', $amp + 1);
+ if ($semi === false || $semi === $amp + 1) {
+ $out .= '&';
+ $i = $amp + 1;
+ continue;
+ }
+ $entity = substr($text, $amp + 1, $semi - $amp - 1);
+ $decoded = null;
+ if ($entity[0] === '#') {
+ $number = substr($entity, 1);
+ $base = 10;
+ if (strlen($number) > 1 && $number[0] === 'x') {
+ $number = substr($number, 1);
+ $base = 16;
+ }
+ $value = self::parseGoInt32($number, $base);
+ if ($value !== null) {
+ $decoded = self::codePointToInternal($value);
+ }
+ } elseif (isset($entities[$entity])) {
+ $decoded = $entities[$entity];
+ }
+ if ($decoded === null) {
+ $out .= '&';
+ $i = $amp + 1;
+ continue;
+ }
+ $out = JsString::concatNormalize($out, $decoded);
+ $i = $semi + 1;
+ }
+ return $out;
+ }
+
+ /** Go's strconv.ParseInt(s, base, 32): optional sign, digits only, must fit in int32. */
+ private static function parseGoInt32(string $s, int $base): ?int
+ {
+ $pattern = $base === 16 ? '/^[+-]?[0-9a-fA-F]+$/' : '/^[+-]?[0-9]+$/';
+ if (preg_match($pattern, $s) !== 1) {
+ return null;
+ }
+ $negative = $s[0] === '-';
+ $digits = ltrim($s, '+-');
+ $digits = ltrim($digits, '0');
+ if ($digits === '') {
+ return 0;
+ }
+ if (strlen($digits) > 10) {
+ return null;
+ }
+ $value = intval($digits, $base);
+ $value = $negative ? -$value : $value;
+ if ($value > 2147483647 || $value < -2147483648) {
+ return null;
+ }
+ return $value;
+ }
+
+ /** Encode a decoded numeric reference as esbuild's UTF-16 units, in the engine's internal form. */
+ private static function codePointToInternal(int $c): string
+ {
+ if ($c <= 0xFFFF) {
+ return JsString::utf16CodeUnitToUtf8($c & 0xFFFF);
+ }
+ $c -= 0x10000;
+ $high = 0xD800 + (($c >> 10) & 0x3FF);
+ $low = 0xDC00 + ($c & 0x3FF);
+ return JsString::concatNormalize(
+ JsString::utf16CodeUnitToUtf8($high),
+ JsString::utf16CodeUnitToUtf8($low),
+ );
+ }
+
+ /** @return array */
+ private static function buildEntityTable(): array
+ {
+ // PHP's XHTML table is exactly the 253 names esbuild decodes.
+ $table = [];
+ foreach (get_html_translation_table(HTML_ENTITIES, ENT_QUOTES | ENT_XHTML, 'UTF-8') as $char => $entity) {
+ $name = substr($entity, 1, -1);
+ // PHP spells the apostrophe numerically; JSX knows it as `apos`.
+ $table[$name === '#039' ? 'apos' : $name] = $char;
+ }
+ return $table;
+ }
+
+ private static function utf8Width(int $byte): int
+ {
+ return match (true) {
+ $byte >= 0xF0 => 4,
+ $byte >= 0xE0 => 3,
+ $byte >= 0xC0 => 2,
+ default => 1,
+ };
+ }
+
+ /** Non-ASCII whitespace esbuild trims at line edges (its js_lexer.IsWhitespace). */
+ private static function isUnusualWhitespace(string $char): bool
+ {
+ return in_array($char, [
+ "\u{00A0}", "\u{1680}", "\u{2000}", "\u{2001}", "\u{2002}", "\u{2003}", "\u{2004}",
+ "\u{2005}", "\u{2006}", "\u{2007}", "\u{2008}", "\u{2009}", "\u{200A}", "\u{202F}",
+ "\u{205F}", "\u{3000}", "\u{FEFF}",
+ ], true);
+ }
+}
diff --git a/src/Parser/Parser.php b/src/Parser/Parser.php
index 266ae3a4..898f19f7 100644
--- a/src/Parser/Parser.php
+++ b/src/Parser/Parser.php
@@ -80,6 +80,7 @@ class Parser
use Parts\StatementParser;
use Parts\ExpressionParser;
use Parts\TokenNavigation;
+ use Parts\JsxParser;
private int $pos = 0;
private bool $noIn = false;
@@ -137,6 +138,92 @@ public function __construct(string $source)
/** @var Lexer */
private Lexer $lexer;
+ /** @var array Dotted global expressions replaced by string literals, as bundlers' `define`. */
+ private array $defines = [];
+
+ /**
+ * Replace dotted global expressions with string literals at parse
+ * time, the way bundlers' `define` option works. Used for
+ * `process.env.NODE_ENV` in modules. Must be called before parse().
+ *
+ * @param array $defines e.g. ['process.env.NODE_ENV' => 'production']
+ */
+ public function setDefines(array $defines): void
+ {
+ $this->defines = $defines;
+ }
+
+ /** JSX settings; null means JSX is not recognized at all. */
+ private ?Jsx\JsxOptions $jsxOptions = null;
+
+ private ?Jsx\JsxLowering $jsxLoweringInstance = null;
+
+ /**
+ * Recognize JSX in this source. Must be called before parse(). Classic
+ * and Preserve work in either goal; Automatic and Precompile inject
+ * imports and therefore need the module goal.
+ */
+ public function setJsxOptions(?Jsx\JsxOptions $options): void
+ {
+ $this->jsxOptions = $options;
+ }
+
+ /**
+ * The JSX import source in effect after `@jsxImportSource` pragmas, or
+ * null when JSX is off. Call after parse().
+ */
+ public function jsxImportSource(): ?string
+ {
+ if ($this->jsxOptions === null) {
+ return null;
+ }
+ return $this->lexer->jsxPragmas()['jsxImportSource'] ?? $this->jsxOptions->importSource;
+ }
+
+ private function jsxMode(): Jsx\JsxMode
+ {
+ $mode = $this->jsxOptions !== null ? $this->jsxOptions->mode : Jsx\JsxMode::Preserve;
+ if ($mode === Jsx\JsxMode::Preserve) {
+ return $mode;
+ }
+ return match ($this->lexer->jsxPragmas()['jsxRuntime'] ?? null) {
+ 'classic' => Jsx\JsxMode::Classic,
+ 'automatic' => $mode === Jsx\JsxMode::Precompile ? $mode : Jsx\JsxMode::Automatic,
+ default => $mode,
+ };
+ }
+
+ private function jsxLowering(): Jsx\JsxLowering
+ {
+ if ($this->jsxLoweringInstance !== null) {
+ return $this->jsxLoweringInstance;
+ }
+ $options = $this->jsxOptions ?? new Jsx\JsxOptions();
+ $pragmas = $this->lexer->jsxPragmas();
+ $used = [];
+ foreach ($this->tokens ?? [] as $token) {
+ if ($token->type === TokenType::Identifier) {
+ $used[$token->value] = true;
+ }
+ }
+ $uniqueName = static function (string $base) use (&$used): string {
+ $name = $base;
+ $n = 2;
+ while (isset($used[$name])) {
+ $name = $base . $n++;
+ }
+ $used[$name] = true;
+ return $name;
+ };
+ return $this->jsxLoweringInstance = new Jsx\JsxLowering(
+ $this->jsxMode(),
+ $pragmas['jsxImportSource'] ?? $options->importSource,
+ $pragmas['jsx'] ?? $options->factory,
+ $pragmas['jsxFrag'] ?? $options->fragment,
+ $uniqueName,
+ );
+ }
+
/** @var Token[]|null */
private ?array $tokens = null;
@@ -162,10 +249,80 @@ private function ensureTokenized(): void
if ($this->tokens === null) {
$this->lexer->setModuleMode($this->moduleMode);
$this->lexer->setCollectComments($this->collectComments);
+ $this->lexer->setJsx($this->jsxOptions !== null);
$this->tokens = $this->lexer->tokenize();
+ if ($this->defines !== []) {
+ $this->applyDefines();
+ }
}
}
+ /**
+ * Substitute each define whose dotted path appears as a global read:
+ * not a property of something else, not assigned to, and not shadowed
+ * by a declaration of the root name anywhere in the file.
+ */
+ private function applyDefines(): void
+ {
+ $tokens = $this->tokens ?? [];
+ foreach ($this->defines as $path => $value) {
+ $parts = explode('.', $path);
+ $root = $parts[0];
+ $declared = false;
+ foreach ($tokens as $i => $token) {
+ if ($token->type === TokenType::Identifier && $token->value === $root && $i > 0) {
+ $prev = $tokens[$i - 1]->type;
+ if (in_array($prev, [TokenType::Var, TokenType::Let, TokenType::Const_, TokenType::Function_, TokenType::Class_], true)) {
+ $declared = true;
+ break;
+ }
+ }
+ }
+ if ($declared) {
+ continue;
+ }
+ $width = count($parts) * 2 - 1;
+ $out = [];
+ $count = count($tokens);
+ for ($i = 0; $i < $count; $i++) {
+ $token = $tokens[$i];
+ if (
+ $token->type === TokenType::Identifier
+ && $token->value === $root
+ && $this->matchesDefine($tokens, $i, $parts)
+ ) {
+ $out[] = new Token(TokenType::String, $value, $token->location, $token->lineTerminatorBefore, 'verbatim');
+ $i += $width - 1;
+ continue;
+ }
+ $out[] = $token;
+ }
+ $tokens = $out;
+ }
+ $this->tokens = $tokens;
+ }
+
+ /**
+ * @param Token[] $tokens
+ * @param list $parts
+ */
+ private function matchesDefine(array $tokens, int $i, array $parts): bool
+ {
+ if ($i > 0 && in_array($tokens[$i - 1]->type, [TokenType::Dot, TokenType::OptionalChaining], true)) {
+ return false;
+ }
+ foreach (array_slice($parts, 1) as $k => $part) {
+ $dot = $tokens[$i + 1 + $k * 2] ?? null;
+ $name = $tokens[$i + 2 + $k * 2] ?? null;
+ if ($dot === null || $dot->type !== TokenType::Dot || $name === null || $name->value !== $part) {
+ return false;
+ }
+ }
+ $after = $tokens[$i + count($parts) * 2 - 1] ?? null;
+ return $after === null
+ || (!$after->type->isAssignmentOperator() && $after->type !== TokenType::PlusPlus && $after->type !== TokenType::MinusMinus);
+ }
+
/**
* Returns the value of the last `//# sourceURL=URL` (or legacy
* `//@ sourceURL=URL`) directive comment seen while tokenizing this
@@ -227,7 +384,7 @@ private function extractSource(int $startOffset): string
return substr($this->source, $startOffset, $endOffset - $startOffset);
}
- public static function parseSource(string $source, string $sourceType = 'script'): Program
+ public static function parseSource(string $source, string $sourceType = 'script', ?Jsx\JsxOptions $jsx = null): Program
{
if ($sourceType !== 'script' && $sourceType !== 'module') {
throw new \InvalidArgumentException(
@@ -236,6 +393,7 @@ public static function parseSource(string $source, string $sourceType = 'script'
}
$parser = new self($source);
$parser->setModuleMode($sourceType === 'module');
+ $parser->setJsxOptions($jsx);
return $parser->parse();
}
@@ -258,6 +416,16 @@ public function parse(): Program
$body[] = $this->parseStatementOrDeclaration();
}
+ if ($this->jsxLoweringInstance !== null && $this->jsxLoweringInstance->needsImports()) {
+ if (!$this->moduleMode) {
+ throw new \Phasis\Exceptions\SyntaxError(
+ 'The automatic JSX runtime needs module code; use the classic runtime in scripts',
+ $location,
+ );
+ }
+ $body = [...$this->jsxLoweringInstance->importDeclarations($location), ...$body];
+ }
+
$program = new Program($location, $body);
// Validate break/continue scopes at the script/module level. The
// validator descends into nested function and arrow bodies and
diff --git a/src/Parser/Parts/ExpressionParser.php b/src/Parser/Parts/ExpressionParser.php
index 8feb54f1..6799ea57 100644
--- a/src/Parser/Parts/ExpressionParser.php
+++ b/src/Parser/Parts/ExpressionParser.php
@@ -897,6 +897,7 @@ private function parsePrimaryExpression(): Node
TokenType::Async => $this->parseAsyncExpression(),
TokenType::Super => $this->parseSuperExpression(),
TokenType::Import => $this->parseImportExpression(),
+ TokenType::JsxTagStart => $this->parseJsxRoot(),
default => throw new ParseError('Unexpected token', $token),
};
}
diff --git a/src/Parser/Parts/JsxParser.php b/src/Parser/Parts/JsxParser.php
new file mode 100644
index 00000000..ac2a2af3
--- /dev/null
+++ b/src/Parser/Parts/JsxParser.php
@@ -0,0 +1,236 @@
+noIn;
+ $this->noIn = false;
+ try {
+ $element = $this->parseJsxElementAt();
+ } finally {
+ $this->noIn = $savedNoIn;
+ }
+ if ($this->jsxMode() === JsxMode::Preserve) {
+ return $element;
+ }
+ return $this->jsxLowering()->lower($element);
+ }
+
+ private function parseJsxElementAt(): JsxElement|JsxFragment
+ {
+ $start = $this->expect(TokenType::JsxTagStart);
+
+ if ($this->check(TokenType::JsxTagEnd)) {
+ $this->advance();
+ $opening = new JsxOpeningFragment($start->location);
+ $children = $this->parseJsxChildren();
+ $closeStart = $this->expect(TokenType::JsxTagStart);
+ $this->expect(TokenType::Slash);
+ if (!$this->check(TokenType::JsxTagEnd)) {
+ throw new SyntaxError('Expected corresponding JSX closing tag for <>.', $this->current()->location);
+ }
+ $this->advance();
+ return new JsxFragment($start->location, $opening, new JsxClosingFragment($closeStart->location), $children);
+ }
+
+ $name = $this->parseJsxElementName();
+ $attributes = [];
+ while (!$this->check(TokenType::Slash) && !$this->check(TokenType::JsxTagEnd)) {
+ $attributes[] = $this->parseJsxAttribute();
+ }
+ if ($this->eat(TokenType::Slash)) {
+ $this->expect(TokenType::JsxTagEnd);
+ $opening = new JsxOpeningElement($start->location, $name, $attributes, true);
+ return new JsxElement($start->location, $opening, null, []);
+ }
+ $this->expect(TokenType::JsxTagEnd);
+ $opening = new JsxOpeningElement($start->location, $name, $attributes, false);
+ $children = $this->parseJsxChildren();
+
+ $closeStart = $this->expect(TokenType::JsxTagStart);
+ $this->expect(TokenType::Slash);
+ if ($this->check(TokenType::JsxTagEnd)) {
+ throw new SyntaxError(
+ 'Expected corresponding JSX closing tag for <' . self::jsxNameText($name) . '>.',
+ $closeStart->location,
+ );
+ }
+ $closingName = $this->parseJsxElementName();
+ if (self::jsxNameText($closingName) !== self::jsxNameText($name)) {
+ throw new SyntaxError(
+ 'Expected corresponding JSX closing tag for <' . self::jsxNameText($name) . '>.',
+ $closeStart->location,
+ );
+ }
+ $this->expect(TokenType::JsxTagEnd);
+ $closing = new JsxClosingElement($closeStart->location, $closingName);
+ return new JsxElement($start->location, $opening, $closing, $children);
+ }
+
+ /** @return Node[] */
+ private function parseJsxChildren(): array
+ {
+ $children = [];
+ while (true) {
+ $token = $this->current();
+ if ($token->type === TokenType::JsxText) {
+ $this->advance();
+ $children[] = new JsxText(
+ $token->location,
+ JsxTextRules::decodeEntities($token->value),
+ $token->value,
+ );
+ continue;
+ }
+ if ($token->type === TokenType::LeftBrace) {
+ $children[] = $this->parseJsxChildExpression();
+ continue;
+ }
+ if ($token->type === TokenType::JsxTagStart) {
+ if ($this->peek()->type === TokenType::Slash) {
+ return $children;
+ }
+ $children[] = $this->parseJsxElementAt();
+ continue;
+ }
+ throw new ParseError('Unexpected token in JSX children', $token);
+ }
+ }
+
+ private function parseJsxChildExpression(): Node
+ {
+ $open = $this->advance();
+ if ($this->check(TokenType::RightBrace)) {
+ $close = $this->advance();
+ return new JsxExpressionContainer($open->location, new JsxEmptyExpression($close->location));
+ }
+ if ($this->check(TokenType::Ellipsis)) {
+ $this->advance();
+ $expression = $this->parseExpression();
+ $this->expect(TokenType::RightBrace);
+ return new JsxSpreadChild($open->location, $expression);
+ }
+ $expression = $this->parseExpression();
+ $this->expect(TokenType::RightBrace);
+ return new JsxExpressionContainer($open->location, $expression);
+ }
+
+ private function parseJsxAttribute(): Node
+ {
+ $token = $this->current();
+ if ($token->type === TokenType::LeftBrace) {
+ $this->advance();
+ $this->expect(TokenType::Ellipsis);
+ $argument = $this->parseAssignmentExpression();
+ $this->expect(TokenType::RightBrace);
+ return new JsxSpreadAttribute($token->location, $argument);
+ }
+ $name = $this->parseJsxIdentifier();
+ if ($this->eat(TokenType::Colon)) {
+ $name = new JsxNamespacedName($name->location, $name, $this->parseJsxIdentifier());
+ }
+ if (!$this->eat(TokenType::Equal)) {
+ return new JsxAttribute($token->location, $name, null);
+ }
+ $valueToken = $this->current();
+ if ($valueToken->type === TokenType::JsxString) {
+ $this->advance();
+ $quote = $valueToken->rawValue ?? '"';
+ $value = new Literal(
+ $valueToken->location,
+ JsxTextRules::decodeEntities($valueToken->value),
+ $quote . $valueToken->value . $quote,
+ );
+ return new JsxAttribute($token->location, $name, $value);
+ }
+ if ($valueToken->type === TokenType::LeftBrace) {
+ $this->advance();
+ if ($this->check(TokenType::RightBrace)) {
+ throw new SyntaxError(
+ 'JSX attributes must only be assigned a non-empty expression.',
+ $valueToken->location,
+ );
+ }
+ $expression = $this->parseAssignmentExpression();
+ $this->expect(TokenType::RightBrace);
+ return new JsxAttribute($token->location, $name, new JsxExpressionContainer($valueToken->location, $expression));
+ }
+ if ($valueToken->type === TokenType::JsxTagStart) {
+ return new JsxAttribute($token->location, $name, $this->parseJsxElementAt());
+ }
+ throw new SyntaxError(
+ 'JSX value should be either an expression or a quoted JSX text.',
+ $valueToken->location,
+ );
+ }
+
+ private function parseJsxElementName(): Node
+ {
+ $name = $this->parseJsxIdentifier();
+ if ($this->eat(TokenType::Colon)) {
+ return new JsxNamespacedName($name->location, $name, $this->parseJsxIdentifier());
+ }
+ $object = $name;
+ while ($this->eat(TokenType::Dot)) {
+ $object = new JsxMemberExpression($name->location, $object, $this->parseJsxIdentifier());
+ }
+ return $object;
+ }
+
+ private function parseJsxIdentifier(): JsxIdentifier
+ {
+ $token = $this->current();
+ if ($token->type !== TokenType::JsxName) {
+ throw new ParseError('Expected a JSX name', $token);
+ }
+ $this->advance();
+ return new JsxIdentifier($token->location, $token->value);
+ }
+
+ /** The source spelling of an element name, used to match opening and closing tags. */
+ private static function jsxNameText(Node $name): string
+ {
+ return match (true) {
+ $name instanceof JsxIdentifier => $name->name,
+ $name instanceof JsxNamespacedName => $name->namespace->name . ':' . $name->name->name,
+ $name instanceof JsxMemberExpression => self::jsxNameText($name->object) . '.' . $name->property->name,
+ default => '',
+ };
+ }
+}
diff --git a/tests/Jsx/JsxLowerOracleTest.php b/tests/Jsx/JsxLowerOracleTest.php
new file mode 100644
index 00000000..801d57da
--- /dev/null
+++ b/tests/Jsx/JsxLowerOracleTest.php
@@ -0,0 +1,52 @@
+ */
+ public static function fixtureProvider(): iterable
+ {
+ foreach (glob(__DIR__ . '/lower/*.jsx') ?: [] as $file) {
+ yield basename($file, '.jsx') => [$file];
+ }
+ }
+
+ #[DataProvider('fixtureProvider')]
+ public function testMatchesEsbuild(string $file): void
+ {
+ $expected = (string) file_get_contents(substr($file, 0, -4) . '.expected.txt');
+ $this->assertSame(rtrim($expected, "\n"), rtrim(self::trace($file), "\n"));
+ }
+
+ public static function trace(string $file): string
+ {
+ $recorder = __DIR__ . '/oracle/recorder';
+ $engine = new Engine();
+ $engine->setImportMap([
+ 'recorder' => $recorder . '/index.js',
+ 'recorder/jsx-runtime' => $recorder . '/jsx-runtime.js',
+ ]);
+ $engine->setJsxOptions(new JsxOptions(importSource: 'recorder'));
+ try {
+ $engine->execFile($file);
+ } catch (SyntaxError $e) {
+ return 'SyntaxError: ' . preg_replace('/ at line \d+, column \d+$/', '', $e->getMessage());
+ }
+ return $engine->getConsoleOutput();
+ }
+}
diff --git a/tests/Jsx/JsxParseOracleTest.php b/tests/Jsx/JsxParseOracleTest.php
new file mode 100644
index 00000000..2f5b60cb
--- /dev/null
+++ b/tests/Jsx/JsxParseOracleTest.php
@@ -0,0 +1,46 @@
+ */
+ public static function fixtureProvider(): iterable
+ {
+ foreach (glob(__DIR__ . '/parse/*.jsx') ?: [] as $file) {
+ yield basename($file, '.jsx') => [$file];
+ }
+ }
+
+ #[DataProvider('fixtureProvider')]
+ public function testMatchesBabel(string $file): void
+ {
+ $expected = (string) file_get_contents(substr($file, 0, -4) . '.expected.txt');
+ $this->assertSame($expected, self::summarize((string) file_get_contents($file)) . "\n");
+ }
+
+ public static function summarize(string $source): string
+ {
+ try {
+ $program = Parser::parseSource($source, 'module', new JsxOptions(mode: JsxMode::Preserve));
+ return EstreeSerializer::summarize($program);
+ } catch (SyntaxError $e) {
+ return 'SyntaxError: ' . preg_replace('/ at line \d+, column \d+$/', '', $e->getMessage());
+ }
+ }
+}
diff --git a/tests/Jsx/lower/attributes.expected.txt b/tests/Jsx/lower/attributes.expected.txt
new file mode 100644
index 00000000..5f0e6034
--- /dev/null
+++ b/tests/Jsx/lower/attributes.expected.txt
@@ -0,0 +1,8 @@
+#1 jsx("a", {"href": "/a", "onClick": fn:onClick, "title": "t", "data-x": "1", "aria-label": "l"})
+#2 jsx("use", {"xlink:href": "#i", "xml:lang": "en"})
+#3 jsx("c", {})
+#4 jsx(fn:Fragment, {"children": "f"})
+#5 jsx("a", {"b": #3, "d": #4})
+#6 jsx("a", {"str": "a & b \"q\" 's' AB"})
+#7 jsx("a", {"multi": "one\n two", "back": "c:\\new"})
+#8 jsx("a", {"e": "t/a", "n": 3, "o": {"x": 1}, "arr": [1, 2]})
diff --git a/tests/Jsx/lower/attributes.jsx b/tests/Jsx/lower/attributes.jsx
new file mode 100644
index 00000000..48746765
--- /dev/null
+++ b/tests/Jsx/lower/attributes.jsx
@@ -0,0 +1,8 @@
+const url = "/a", cb = function onClick() {};
+ ;
+ ;
+ d=<>f> />;
+ ;
+ ;
+ ;
diff --git a/tests/Jsx/lower/basic.expected.txt b/tests/Jsx/lower/basic.expected.txt
new file mode 100644
index 00000000..e15624ef
--- /dev/null
+++ b/tests/Jsx/lower/basic.expected.txt
@@ -0,0 +1,4 @@
+#1 jsx("div", {})
+#2 jsx("p", {"children": "N"})
+#3 jsxs(fn:Card, {"title": "Hi", "children": ["Hello ", "N", "!"]}, 7)
+#4 jsx("input", {"disabled": true})
diff --git a/tests/Jsx/lower/basic.jsx b/tests/Jsx/lower/basic.jsx
new file mode 100644
index 00000000..441c9a51
--- /dev/null
+++ b/tests/Jsx/lower/basic.jsx
@@ -0,0 +1,6 @@
+function Card() {}
+const name = "N", id = 7;
+
;
+{name}
;
+Hello {name}! ;
+ ;
diff --git a/tests/Jsx/lower/classic-member-factory.expected.txt b/tests/Jsx/lower/classic-member-factory.expected.txt
new file mode 100644
index 00000000..a338bb59
--- /dev/null
+++ b/tests/Jsx/lower/classic-member-factory.expected.txt
@@ -0,0 +1,2 @@
+#1 h(fn:Frag, null, "d")
+#2 h("a", {"b": "c"}, #1)
diff --git a/tests/Jsx/lower/classic-member-factory.jsx b/tests/Jsx/lower/classic-member-factory.jsx
new file mode 100644
index 00000000..b99aa386
--- /dev/null
+++ b/tests/Jsx/lower/classic-member-factory.jsx
@@ -0,0 +1,6 @@
+/** @jsxRuntime classic */
+/** @jsx React.createElement */
+/** @jsxFrag React.Fragment */
+import { h, Frag } from "recorder";
+const React = { createElement: h, Fragment: Frag };
+<>d> ;
diff --git a/tests/Jsx/lower/classic.expected.txt b/tests/Jsx/lower/classic.expected.txt
new file mode 100644
index 00000000..b299518d
--- /dev/null
+++ b/tests/Jsx/lower/classic.expected.txt
@@ -0,0 +1,9 @@
+#1 h("div", null)
+#2 h("p", null, "n")
+#3 h(fn:Card, {"title": "Hi", "key": 1}, "Hello ", "x", "!")
+#4 h("br", null)
+#5 h(fn:Frag, null, #4)
+#6 h("div", {"a": 1, "key": "x"})
+#7 h("div", {"key": "x", "a": 1})
+#8 h("div", null, 1, 2)
+#9 h("input", {"disabled": true})
diff --git a/tests/Jsx/lower/classic.jsx b/tests/Jsx/lower/classic.jsx
new file mode 100644
index 00000000..c72fc3be
--- /dev/null
+++ b/tests/Jsx/lower/classic.jsx
@@ -0,0 +1,14 @@
+/** @jsxRuntime classic */
+/** @jsx h */
+/** @jsxFrag Frag */
+import { h, Frag } from "recorder";
+function Card() {}
+const props = { a: 1 }, xs = [1, 2];
+
;
+{"n"}
;
+Hello {"x"}! ;
+<> >;
+
;
+
;
+{...xs}
;
+ ;
diff --git a/tests/Jsx/lower/components.expected.txt b/tests/Jsx/lower/components.expected.txt
new file mode 100644
index 00000000..9fe72ca4
--- /dev/null
+++ b/tests/Jsx/lower/components.expected.txt
@@ -0,0 +1,6 @@
+#1 jsx("h1", {"className": "title", "children": "Posts & news"})
+#2 jsx(fn:Item, {"id": 1, "t": "a"}, 1)
+#3 jsx(fn:Item, {"id": 2, "t": "b"}, 2)
+#4 jsx("ul", {"children": [#2, #3]})
+#5 jsx("span", {"children": "guest"})
+#6 jsxs(fn:Layout, {"title": "Blog", "children": [#1, #4, null, #5]})
diff --git a/tests/Jsx/lower/components.jsx b/tests/Jsx/lower/components.jsx
new file mode 100644
index 00000000..b6cdd220
--- /dev/null
+++ b/tests/Jsx/lower/components.jsx
@@ -0,0 +1,12 @@
+function Layout() {}
+function Item() {}
+const items = [{ id: 1, t: "a" }, { id: 2, t: "b" }];
+const user = null;
+
+ Posts & news
+ {items.length === 0 ? None
: (
+
+ )}
+ {user && {user} }
+ {user ?? guest }
+ ;
diff --git a/tests/Jsx/lower/empty-containers.expected.txt b/tests/Jsx/lower/empty-containers.expected.txt
new file mode 100644
index 00000000..9382c287
--- /dev/null
+++ b/tests/Jsx/lower/empty-containers.expected.txt
@@ -0,0 +1,3 @@
+#1 jsx("div", {})
+#2 jsx("div", {})
+#3 jsxs("div", {"children": ["a", "b"]})
diff --git a/tests/Jsx/lower/empty-containers.jsx b/tests/Jsx/lower/empty-containers.jsx
new file mode 100644
index 00000000..8e7ee4bd
--- /dev/null
+++ b/tests/Jsx/lower/empty-containers.jsx
@@ -0,0 +1,5 @@
+{}{/* c */}{/* a */ /* b */}
;
+
+ {/* only a comment */}
+
;
+a{}b
;
diff --git a/tests/Jsx/lower/entities.expected.txt b/tests/Jsx/lower/entities.expected.txt
new file mode 100644
index 00000000..08d50199
--- /dev/null
+++ b/tests/Jsx/lower/entities.expected.txt
@@ -0,0 +1,6 @@
+#1 jsx("p", {"children": "© x'y'z &unknown; "})
+#2 jsx("p", {"children": "& &x; <"})
+#3 jsx("p", {"children": "\u0000|𐀀|\ud800|A|😀|&|'"})
+#4 jsx("p", {"children": "AAAA"})
+#5 jsx("p", {"children": " a "})
+#6 jsx("p", {"children": " b "})
diff --git a/tests/Jsx/lower/entities.jsx b/tests/Jsx/lower/entities.jsx
new file mode 100644
index 00000000..1a3505ac
--- /dev/null
+++ b/tests/Jsx/lower/entities.jsx
@@ -0,0 +1,12 @@
+© x'y'z &unknown;
;
+& &x; <
;
+|||A|😀|&|'
;
+AA+65;-1;A
;
+
+
+ a
+
+
;
+
+ b
+
;
diff --git a/tests/Jsx/lower/error-bare-key.expected.txt b/tests/Jsx/lower/error-bare-key.expected.txt
new file mode 100644
index 00000000..827609a6
--- /dev/null
+++ b/tests/Jsx/lower/error-bare-key.expected.txt
@@ -0,0 +1 @@
+SyntaxError: Please provide an explicit value for "key"
diff --git a/tests/Jsx/lower/error-bare-key.jsx b/tests/Jsx/lower/error-bare-key.jsx
new file mode 100644
index 00000000..669a50ad
--- /dev/null
+++ b/tests/Jsx/lower/error-bare-key.jsx
@@ -0,0 +1,2 @@
+const k = 1;
+
;
diff --git a/tests/Jsx/lower/evaluation-order.expected.txt b/tests/Jsx/lower/evaluation-order.expected.txt
new file mode 100644
index 00000000..cd3eb4f4
--- /dev/null
+++ b/tests/Jsx/lower/evaluation-order.expected.txt
@@ -0,0 +1,8 @@
+eval a
+eval spread
+eval b
+eval c1
+eval inner
+#1 jsx("b", {"x": "inner"})
+eval c2
+#2 jsxs(fn:A, {"a": "a", "s": "spread", "b": "b", "children": ["c1", #1, "c2"]})
diff --git a/tests/Jsx/lower/evaluation-order.jsx b/tests/Jsx/lower/evaluation-order.jsx
new file mode 100644
index 00000000..270752e9
--- /dev/null
+++ b/tests/Jsx/lower/evaluation-order.jsx
@@ -0,0 +1,7 @@
+import { log } from "recorder";
+function A() {}
+
+ {log("c1")}
+
+ {log("c2")}
+ ;
diff --git a/tests/Jsx/lower/fragments.expected.txt b/tests/Jsx/lower/fragments.expected.txt
new file mode 100644
index 00000000..be523029
--- /dev/null
+++ b/tests/Jsx/lower/fragments.expected.txt
@@ -0,0 +1,7 @@
+#1 jsx("br", {})
+#2 jsx(fn:Fragment, {"children": #1})
+#3 jsx(fn:Fragment, {})
+#4 jsx(fn:A, {})
+#5 jsxs(fn:Fragment, {"children": [#4, "text", 1]})
+#6 jsx(fn:Fragment, {"children": "x"})
+#7 jsx("div", {"children": #6})
diff --git a/tests/Jsx/lower/fragments.jsx b/tests/Jsx/lower/fragments.jsx
new file mode 100644
index 00000000..73681ccb
--- /dev/null
+++ b/tests/Jsx/lower/fragments.jsx
@@ -0,0 +1,5 @@
+function A() {}
+<> >;
+<>>;
+<> text{1}>;
+{<>{"x"}>}
;
diff --git a/tests/Jsx/lower/keys-and-spread.expected.txt b/tests/Jsx/lower/keys-and-spread.expected.txt
new file mode 100644
index 00000000..d31fd29a
--- /dev/null
+++ b/tests/Jsx/lower/keys-and-spread.expected.txt
@@ -0,0 +1,6 @@
+#1 jsx("div", {"a": 1, "key": "p"}, "x")
+#2 createElement("div", {"a": 1, "key": "x"})
+#3 createElement("div", {"a": 1, "key": "x"}, "child", "k")
+#4 jsx("div", {"a": 1, "b": 2, "key": "p", "c": true}, "k")
+#5 jsx("div", {"a": 1, "key": "p"})
+#6 jsx("div", {"a": 1, "key": "p", "b": 2})
diff --git a/tests/Jsx/lower/keys-and-spread.jsx b/tests/Jsx/lower/keys-and-spread.jsx
new file mode 100644
index 00000000..36d796fa
--- /dev/null
+++ b/tests/Jsx/lower/keys-and-spread.jsx
@@ -0,0 +1,7 @@
+const props = { a: 1, key: "p" }, k = "k";
+
;
+
;
+child{k}
;
+
;
+
;
+
;
diff --git a/tests/Jsx/lower/names.expected.txt b/tests/Jsx/lower/names.expected.txt
new file mode 100644
index 00000000..8a59c00c
--- /dev/null
+++ b/tests/Jsx/lower/names.expected.txt
@@ -0,0 +1,7 @@
+#1 jsx("my-el", {})
+#2 jsx(fn:Baz, {})
+#3 jsx(fn:_x, {})
+#4 jsx(fn:$y, {})
+#5 jsx("svg:rect", {})
+#6 jsx(fn:Ünï, {})
+#7 jsx(fn:Comp, {})
diff --git a/tests/Jsx/lower/names.jsx b/tests/Jsx/lower/names.jsx
new file mode 100644
index 00000000..c0272350
--- /dev/null
+++ b/tests/Jsx/lower/names.jsx
@@ -0,0 +1,12 @@
+const Foo = { Bar: { Baz: function Baz() {} } };
+function _x() {}
+function $y() {}
+ ;
+ ;
+<_x />;
+<$y />;
+ ;
+<Ünï />;
+function Ünï() {}
+const o = { Comp: function Comp() {}, render() { return ; } };
+o.render();
diff --git a/tests/Jsx/lower/nested-in-expressions.expected.txt b/tests/Jsx/lower/nested-in-expressions.expected.txt
new file mode 100644
index 00000000..3c33d514
--- /dev/null
+++ b/tests/Jsx/lower/nested-in-expressions.expected.txt
@@ -0,0 +1,12 @@
+#1 jsx("i", {"children": "odd"})
+#2 jsx(fn:Row, {"n": 1, "children": #1}, 1)
+#3 jsx("b", {"children": "even"})
+#4 jsx(fn:Row, {"n": 2, "children": #3}, 2)
+#5 jsx("i", {"children": "odd"})
+#6 jsx(fn:Row, {"n": 3, "children": #5}, 3)
+#7 jsx("tbody", {"children": [#2, #4, #6]})
+#8 jsx("table", {"children": #7})
+#9 jsx("span", {"children": "v1"})
+#10 jsx("a", {}, "1")
+#11 jsx("b", {}, "2")
+#12 jsx("em", {})
diff --git a/tests/Jsx/lower/nested-in-expressions.jsx b/tests/Jsx/lower/nested-in-expressions.jsx
new file mode 100644
index 00000000..b1c64ddf
--- /dev/null
+++ b/tests/Jsx/lower/nested-in-expressions.jsx
@@ -0,0 +1,7 @@
+function Row() {}
+const rows = [1, 2, 3];
+const table = {rows.map((r) => {r % 2 ? odd : even }
)}
;
+const fn = () => {`v${rows[0]}`} ;
+fn();
+[ , ];
+({ k: });
diff --git a/tests/Jsx/lower/proto-attr.expected.txt b/tests/Jsx/lower/proto-attr.expected.txt
new file mode 100644
index 00000000..e6f6b8a3
--- /dev/null
+++ b/tests/Jsx/lower/proto-attr.expected.txt
@@ -0,0 +1,2 @@
+#1 jsx("div", (custom proto){})
+#2 jsx("div", {})
diff --git a/tests/Jsx/lower/proto-attr.jsx b/tests/Jsx/lower/proto-attr.jsx
new file mode 100644
index 00000000..8e5f715f
--- /dev/null
+++ b/tests/Jsx/lower/proto-attr.jsx
@@ -0,0 +1,3 @@
+const p = { custom: true };
+
;
+
;
diff --git a/tests/Jsx/lower/spread-children.expected.txt b/tests/Jsx/lower/spread-children.expected.txt
new file mode 100644
index 00000000..fe530621
--- /dev/null
+++ b/tests/Jsx/lower/spread-children.expected.txt
@@ -0,0 +1,2 @@
+#1 jsxs("div", {"children": ["a", "b"]})
+#2 jsxs("div", {"children": ["first", "a", "b"]})
diff --git a/tests/Jsx/lower/spread-children.jsx b/tests/Jsx/lower/spread-children.jsx
new file mode 100644
index 00000000..d300bc3d
--- /dev/null
+++ b/tests/Jsx/lower/spread-children.jsx
@@ -0,0 +1,3 @@
+const xs = ["a", "b"];
+{...xs}
;
+first{...xs}
;
diff --git a/tests/Jsx/lower/text-special.expected.txt b/tests/Jsx/lower/text-special.expected.txt
new file mode 100644
index 00000000..00ce225e
--- /dev/null
+++ b/tests/Jsx/lower/text-special.expected.txt
@@ -0,0 +1,2 @@
+#1 jsx("p", {"children": "Don't // not a comment /* nor this */ a > b \"quoted\" back\\slash"})
+#2 jsxs("p", {"children": ["}", " ", "{"]})
diff --git a/tests/Jsx/lower/text-special.jsx b/tests/Jsx/lower/text-special.jsx
new file mode 100644
index 00000000..c3ddab0a
--- /dev/null
+++ b/tests/Jsx/lower/text-special.jsx
@@ -0,0 +1,2 @@
+Don't // not a comment /* nor this */ a > b "quoted" back\slash
;
+{'}'} {'{'}
;
diff --git a/tests/Jsx/lower/unicode-whitespace.expected.txt b/tests/Jsx/lower/unicode-whitespace.expected.txt
new file mode 100644
index 00000000..028bf67d
--- /dev/null
+++ b/tests/Jsx/lower/unicode-whitespace.expected.txt
@@ -0,0 +1,2 @@
+#1 jsx("p", {"children": "a"})
+#2 jsx("p", {"children": "a b"})
diff --git a/tests/Jsx/lower/unicode-whitespace.jsx b/tests/Jsx/lower/unicode-whitespace.jsx
new file mode 100644
index 00000000..e661cd2a
--- /dev/null
+++ b/tests/Jsx/lower/unicode-whitespace.jsx
@@ -0,0 +1,4 @@
+
+ a
+
;
+a
b
;
diff --git a/tests/Jsx/lower/whitespace-tabs-crlf.expected.txt b/tests/Jsx/lower/whitespace-tabs-crlf.expected.txt
new file mode 100644
index 00000000..a6698604
--- /dev/null
+++ b/tests/Jsx/lower/whitespace-tabs-crlf.expected.txt
@@ -0,0 +1,3 @@
+#1 jsx("p", {"children": "a\tb c"})
+#2 jsx("p", {"children": "x y"})
+#3 jsx("p", {})
diff --git a/tests/Jsx/lower/whitespace-tabs-crlf.jsx b/tests/Jsx/lower/whitespace-tabs-crlf.jsx
new file mode 100644
index 00000000..dc8d2486
--- /dev/null
+++ b/tests/Jsx/lower/whitespace-tabs-crlf.jsx
@@ -0,0 +1,6 @@
+a b
+ c
;
+x
+ y
;
+
+
;
diff --git a/tests/Jsx/lower/whitespace.expected.txt b/tests/Jsx/lower/whitespace.expected.txt
new file mode 100644
index 00000000..59b0787e
--- /dev/null
+++ b/tests/Jsx/lower/whitespace.expected.txt
@@ -0,0 +1,8 @@
+#1 jsx("b", {"children": "bold"})
+#2 jsxs("div", {"children": ["Hello, world ", "x", #1, " tail"]})
+#3 jsx("p", {"children": " leading and trailing "})
+#4 jsx("p", {"children": " "})
+#5 jsx("p", {})
+#6 jsx("p", {"children": "a b c"})
+#7 jsxs("p", {"children": ["a", " ", "b"]})
+#8 jsxs("p", {"children": ["x", " ", "y"]})
diff --git a/tests/Jsx/lower/whitespace.jsx b/tests/Jsx/lower/whitespace.jsx
new file mode 100644
index 00000000..765060b9
--- /dev/null
+++ b/tests/Jsx/lower/whitespace.jsx
@@ -0,0 +1,20 @@
+
+ Hello,
+ world {"x"}
+ bold tail
+
;
+ leading and trailing
;
+
;
+
+
+
;
+
+ a
+ b
+
+ c
+
;
+a{" "}b
;
+
+ {"x"} {"y"}
+
;
diff --git a/tests/Jsx/oracle/jsx-oracle.mjs b/tests/Jsx/oracle/jsx-oracle.mjs
new file mode 100644
index 00000000..2c76ea34
--- /dev/null
+++ b/tests/Jsx/oracle/jsx-oracle.mjs
@@ -0,0 +1,194 @@
+// Regenerates the committed JSX oracle outputs from the pinned reference tools.
+//
+// Usage: node tests/Jsx/oracle/jsx-oracle.mjs [fixture ...]
+//
+// Each suite reads tests/Jsx//*.jsx (or the given fixtures) and writes
+// the reference output next to each fixture as .expected.txt. The PHP
+// tests in tests/Jsx compare Phasis against those files, so CI and local
+// runs never need Node; only refreshing the oracle does.
+
+import fs from 'node:fs';
+import path from 'node:path';
+import vm from 'node:vm';
+import { fileURLToPath } from 'node:url';
+import { execFileSync } from 'node:child_process';
+import os from 'node:os';
+import esbuild from 'esbuild';
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const root = path.resolve(here, '../../..');
+const jsxDir = path.resolve(here, '..');
+
+const suites = {
+ parse: parseSuite,
+ lower: lowerSuite,
+ resolution: resolutionSuite,
+ smoke: smokeSuite,
+};
+
+/** Where each suite's fixtures live, relative to tests/Jsx. */
+const suiteDirs = { smoke: 'oracle/smoke' };
+
+/** File extension of each suite's fixtures. */
+const suiteExtensions = { smoke: '.mjs' };
+
+/** Suites whose fixtures are directories holding a main.mjs entry. */
+const directorySuites = new Set(['resolution']);
+
+/**
+ * esbuild compiles the fixture (automatic runtime, import source `recorder`;
+ * pragmas may switch it to classic), bundles it with the recording runtime,
+ * and Node runs it. The output is the recorder's trace of every call.
+ */
+async function lowerSuite(fixture) {
+ let result;
+ try {
+ result = await esbuild.build({
+ entryPoints: [fixture],
+ bundle: true,
+ write: false,
+ format: 'esm',
+ platform: 'neutral',
+ jsx: 'automatic',
+ jsxImportSource: 'recorder',
+ alias: { recorder: path.join(here, 'recorder') },
+ // Keep every call: compiled JSX is marked pure and would otherwise be dropped.
+ treeShaking: false,
+ ignoreAnnotations: true,
+ logLevel: 'silent',
+ });
+ } catch (error) {
+ return 'SyntaxError: ' + error.errors[0].text.replace(/:$/, '');
+ }
+ return runModule(result.outputFiles[0].text);
+}
+
+function runModule(code) {
+ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'jsx-oracle-')), 'fixture.mjs');
+ fs.writeFileSync(file, code);
+ try {
+ return execFileSync(process.execPath, [file], { encoding: 'utf8', env: { ...process.env, TZ: 'UTC' } });
+ } catch (error) {
+ return (error.stdout ?? '') + 'Error: ' + String(error.stderr).split('\n').find((l) => /Error/.test(l));
+ } finally {
+ fs.rmSync(path.dirname(file), { recursive: true, force: true });
+ }
+}
+
+/**
+ * Bundle a fixture app with esbuild, configured like Phasis's defaults
+ * (main fields, conditions, extensions, NODE_ENV, CSS as empty, assets as
+ * deterministic URLs), and run it with Node.
+ */
+async function resolutionSuite(dir) {
+ const assets = {
+ name: 'assets',
+ setup(build) {
+ build.onLoad({ filter: /\.(svg|png|jpg|woff2)$/ }, (args) => ({
+ contents: `export default ${JSON.stringify('/assets/' + path.basename(args.path))};`,
+ loader: 'js',
+ }));
+ },
+ };
+ const result = await esbuild.build({
+ entryPoints: [path.join(dir, 'main.mjs')],
+ absWorkingDir: dir,
+ bundle: true,
+ write: false,
+ format: 'esm',
+ platform: 'neutral',
+ mainFields: ['module', 'main'],
+ conditions: ['phasis', 'module', 'production'],
+ resolveExtensions: ['.jsx', '.js', '.mjs', '.cjs', '.json'],
+ define: { 'process.env.NODE_ENV': '"production"' },
+ jsx: 'automatic',
+ loader: { '.css': 'empty' },
+ plugins: [assets],
+ logLevel: 'silent',
+ });
+ return runModule(result.outputFiles[0].text);
+}
+
+/** Package smoke tests: Node runs the module directly against the pinned packages. */
+function smokeSuite(fixture) {
+ // Phasis defaults NODE_ENV to production, as bundlers do for server builds.
+ const env = { ...process.env, TZ: 'UTC', NODE_ENV: 'production' };
+ return execFileSync(process.execPath, [fixture], { encoding: 'utf8', env });
+}
+
+/** Babel parser (the copy vendored for tests/Popular) with the jsx and estree plugins. */
+function parseSuite(fixture) {
+ const parser = loadBabelParser();
+ const source = fs.readFileSync(fixture, 'utf8');
+ try {
+ const ast = parser.parse(source, { sourceType: 'module', plugins: ['jsx', 'estree'] });
+ return summarize(ast.program, 0).join('\n');
+ } catch (error) {
+ return 'SyntaxError: ' + String(error.message).replace(/ \(\d+:\d+\)$/, '');
+ }
+}
+
+let babelParser = null;
+function loadBabelParser() {
+ if (babelParser === null) {
+ const context = {};
+ vm.createContext(context);
+ vm.runInContext(fs.readFileSync(path.join(root, 'tests/Popular/babel-parser/babel-parser.js'), 'utf8'), context);
+ babelParser = context.BabelParser;
+ }
+ return babelParser;
+}
+
+/** Same format as Phasis\Ast\EstreeSerializer::summarize(). */
+function summarize(node, depth) {
+ const extra = [];
+ if (typeof node.name === 'string' && node.name !== '') extra.push(`name=${node.name}`);
+ if (node.value !== undefined && node.value !== null && typeof node.value !== 'object') {
+ extra.push(`value=${JSON.stringify(node.value)}`);
+ }
+ if (typeof node.operator === 'string' && node.operator !== '') extra.push(`op=${node.operator}`);
+ if (typeof node.kind === 'string' && node.kind !== '') extra.push(`kind=${node.kind}`);
+ const lines = [' '.repeat(depth) + node.type + (extra.length ? ' ' + extra.join(' ') : '')];
+ for (const key of Object.keys(node)) {
+ if (key === 'loc' || key === 'range' || key === 'extra' || key.endsWith('Comments')) continue;
+ const child = node[key];
+ if (child && typeof child === 'object' && typeof child.type === 'string') {
+ lines.push(...summarize(child, depth + 1));
+ } else if (Array.isArray(child)) {
+ for (const item of child) {
+ if (item && typeof item === 'object' && typeof item.type === 'string') {
+ lines.push(...summarize(item, depth + 1));
+ }
+ }
+ }
+ }
+ return lines;
+}
+
+async function main() {
+ const [suite, ...files] = process.argv.slice(2);
+ if (!suites[suite]) {
+ console.error(`usage: jsx-oracle.mjs <${Object.keys(suites).join('|')}> [fixture ...]`);
+ process.exit(1);
+ }
+ const isDirectory = directorySuites.has(suite);
+ const extension = suiteExtensions[suite] ?? '.jsx';
+ const dir = path.join(jsxDir, suiteDirs[suite] ?? suite);
+ const fixtures = files.length > 0
+ ? files.map((f) => path.resolve(f))
+ : fs.readdirSync(dir)
+ .filter((f) => isDirectory
+ ? fs.statSync(path.join(dir, f)).isDirectory()
+ : f.endsWith(extension))
+ .sort()
+ .map((f) => path.join(dir, f));
+
+ for (const fixture of fixtures) {
+ const output = await suites[suite](fixture);
+ const target = isDirectory ? fixture + '.expected.txt' : fixture.slice(0, -extension.length) + '.expected.txt';
+ fs.writeFileSync(target, output.endsWith('\n') ? output : output + '\n');
+ }
+ console.log(`${suite}: wrote ${fixtures.length} expected outputs`);
+}
+
+await main();
diff --git a/tests/Jsx/oracle/package-lock.json b/tests/Jsx/oracle/package-lock.json
new file mode 100644
index 00000000..1f332abb
--- /dev/null
+++ b/tests/Jsx/oracle/package-lock.json
@@ -0,0 +1,2412 @@
+{
+ "name": "phasis-jsx-oracle",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "phasis-jsx-oracle",
+ "dependencies": {
+ "@heroicons/react": "2.2.0",
+ "class-variance-authority": "0.7.1",
+ "classnames": "2.5.1",
+ "clsx": "2.1.1",
+ "date-fns": "4.4.0",
+ "dayjs": "1.11.23",
+ "esbuild": "0.28.2",
+ "jsdom": "26.1.0",
+ "lodash": "4.18.1",
+ "lodash-es": "4.18.1",
+ "lucide-react": "1.56.0",
+ "preact": "11.0.1",
+ "preact-render-to-string": "6.8.0",
+ "prop-types": "15.8.1",
+ "react": "19.3.0",
+ "react-dom": "19.3.0",
+ "react-icons": "5.7.0",
+ "react-is": "19.3.0",
+ "react-markdown": "10.1.0",
+ "tailwind-merge": "3.7.0",
+ "use-sync-external-store": "1.7.0"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.3",
+ "@csstools/css-color-parser": "^3.0.9",
+ "@csstools/css-parser-algorithms": "^3.0.4",
+ "@csstools/css-tokenizer": "^3.0.3",
+ "lru-cache": "^10.4.3"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@heroicons/react": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
+ "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">= 16 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/@types/debug": {
+ "version": "4.1.13",
+ "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
+ "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*"
+ }
+ },
+ "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==",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree-jsx": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
+ "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/@types/hast": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
+ "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/mdast": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
+ "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+ "license": "MIT"
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
+ "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==",
+ "license": "ISC"
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/bail": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
+ "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/ccount": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
+ "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
+ "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-html4": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
+ "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
+ "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-reference-invalid": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
+ "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/class-variance-authority": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
+ "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "clsx": "^2.1.1"
+ },
+ "funding": {
+ "url": "https://polar.sh/cva"
+ }
+ },
+ "node_modules/classnames": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
+ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
+ "license": "MIT"
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/comma-separated-tokens": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
+ "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/cssstyle": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^3.2.0",
+ "rrweb-cssom": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/data-urls": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/date-fns": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
+ "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
+ "node_modules/dayjs": {
+ "version": "1.11.23",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
+ "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "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==",
+ "license": "MIT"
+ },
+ "node_modules/decode-named-character-reference": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
+ "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
+ "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
+ }
+ },
+ "node_modules/estree-util-is-identifier-name": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
+ "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
+ "node_modules/hast-util-to-jsx-runtime": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
+ "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "devlop": "^1.0.0",
+ "estree-util-is-identifier-name": "^3.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "mdast-util-mdx-expression": "^2.0.0",
+ "mdast-util-mdx-jsx": "^3.0.0",
+ "mdast-util-mdxjs-esm": "^2.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "style-to-js": "^1.0.0",
+ "unist-util-position": "^5.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-whitespace": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
+ "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/html-url-attributes": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
+ "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inline-style-parser": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
+ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
+ "license": "MIT"
+ },
+ "node_modules/is-alphabetical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
+ "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-alphanumerical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
+ "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-alphabetical": "^2.0.0",
+ "is-decimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-decimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
+ "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-hexadecimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
+ "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "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==",
+ "license": "MIT"
+ },
+ "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==",
+ "license": "MIT"
+ },
+ "node_modules/jsdom": {
+ "version": "26.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
+ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
+ "license": "MIT",
+ "dependencies": {
+ "cssstyle": "^4.2.1",
+ "data-urls": "^5.0.0",
+ "decimal.js": "^10.5.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.16",
+ "parse5": "^7.2.1",
+ "rrweb-cssom": "^0.8.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^5.1.1",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.1.1",
+ "ws": "^8.18.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
+ "node_modules/lodash-es": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+ "license": "MIT"
+ },
+ "node_modules/longest-streak": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
+ "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "license": "ISC"
+ },
+ "node_modules/lucide-react": {
+ "version": "1.56.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.56.0.tgz",
+ "integrity": "sha512-b+hqj30/trHBma5r1zlKb6yLewQp6tUtnz9loc3xdrYerPmvLYhlEbyPVCJ8DOg2/blBf6lwPLznftFVACka6A==",
+ "license": "ISC",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/mdast-util-from-markdown": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.1.0.tgz",
+ "integrity": "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark": "^4.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-expression": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
+ "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
+ "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "parse-entities": "^4.0.0",
+ "stringify-entities": "^4.0.0",
+ "unist-util-stringify-position": "^4.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdxjs-esm": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
+ "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-phrasing": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
+ "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-hast": {
+ "version": "13.2.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
+ "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "trim-lines": "^3.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-markdown": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.2.0.tgz",
+ "integrity": "sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "longest-streak": "^3.0.0",
+ "mdast-util-phrasing": "^4.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "micromark-util-html-tag-name": "^2.0.0",
+ "unist-util-visit": "^5.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
+ "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz",
+ "integrity": "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@types/debug": "^4.0.0",
+ "debug": "^4.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-edit-map": "^1.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-core-commonmark": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.4.tgz",
+ "integrity": "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-factory-destination": "^2.0.0",
+ "micromark-factory-label": "^2.0.0",
+ "micromark-factory-space": "^2.1.0",
+ "micromark-factory-title": "^2.0.0",
+ "micromark-factory-whitespace": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-edit-map": "^1.0.0",
+ "micromark-util-html-tag-name": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.3"
+ }
+ },
+ "node_modules/micromark-factory-destination": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
+ "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-label": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
+ "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-space": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz",
+ "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-title": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
+ "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-whitespace": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
+ "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-character": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
+ "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-chunked": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
+ "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-classify-character": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
+ "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-combine-extensions": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
+ "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-numeric-character-reference": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
+ "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-string": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
+ "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-edit-map": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-edit-map/-/micromark-util-edit-map-1.0.0.tgz",
+ "integrity": "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-encode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
+ "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-html-tag-name": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
+ "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-normalize-identifier": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
+ "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-resolve-all": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
+ "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-sanitize-uri": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
+ "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-subtokenize": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
+ "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-symbol": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
+ "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-types": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz",
+ "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "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==",
+ "license": "MIT"
+ },
+ "node_modules/nwsapi": {
+ "version": "2.2.28",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz",
+ "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==",
+ "license": "MIT"
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/parse-entities": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
+ "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "character-entities-legacy": "^3.0.0",
+ "character-reference-invalid": "^2.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "is-alphanumerical": "^2.0.0",
+ "is-decimal": "^2.0.0",
+ "is-hexadecimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/parse-entities/node_modules/@types/unist": {
+ "version": "2.0.11",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
+ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
+ "license": "MIT"
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/preact": {
+ "version": "11.0.1",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-11.0.1.tgz",
+ "integrity": "sha512-TNNwEq++jYNHy747LziCjPRVgEwnLSOIOAw/oYcjI3Tdnubx2EXtt76I0an8PATvUWtx/DX1kFIg1eFN+O/fkw==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ },
+ "peerDependencies": {
+ "preact-render-to-string": ">=6.7.0"
+ },
+ "peerDependenciesMeta": {
+ "preact-render-to-string": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/preact-render-to-string": {
+ "version": "6.8.0",
+ "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.8.0.tgz",
+ "integrity": "sha512-2qiMrReQz+MZ5+/bT27kbVnFE7QNCC2fku/Tnc2H5bFUS3I3ASbRy0q72XLZ+2p2KyDE5xMfvSEtkmVo6TRNyA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "preact": ">=10 || >= 11.0.0-0"
+ }
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/prop-types/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
+ "node_modules/property-information": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
+ "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": "^19.3.0"
+ }
+ },
+ "node_modules/react-icons": {
+ "version": "5.7.0",
+ "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz",
+ "integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "*"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz",
+ "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==",
+ "license": "MIT"
+ },
+ "node_modules/react-markdown": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
+ "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "hast-util-to-jsx-runtime": "^2.0.0",
+ "html-url-attributes": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.0.0",
+ "unified": "^11.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18",
+ "react": ">=18"
+ }
+ },
+ "node_modules/remark-parse": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
+ "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-rehype": {
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
+ "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "unified": "^11.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/rrweb-cssom": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
+ "license": "MIT"
+ },
+ "node_modules/space-separated-tokens": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
+ "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/stringify-entities": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
+ "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities-html4": "^2.0.0",
+ "character-entities-legacy": "^3.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/style-to-js": {
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
+ "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "style-to-object": "1.0.14"
+ }
+ },
+ "node_modules/style-to-object": {
+ "version": "1.0.14",
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
+ "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
+ "license": "MIT",
+ "dependencies": {
+ "inline-style-parser": "0.2.7"
+ }
+ },
+ "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==",
+ "license": "MIT"
+ },
+ "node_modules/tailwind-merge": {
+ "version": "3.7.0",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz",
+ "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^6.1.32"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/trim-lines": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
+ "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/trough": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
+ "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/unified": {
+ "version": "11.0.5",
+ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
+ "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "bail": "^2.0.0",
+ "devlop": "^1.0.0",
+ "extend": "^3.0.0",
+ "is-plain-obj": "^4.0.0",
+ "trough": "^2.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-is": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
+ "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
+ "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-stringify-position": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
+ "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
+ "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
+ "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
+ "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/vfile": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+ "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
+ "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
+ "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "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==",
+ "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==",
+ "license": "MIT"
+ },
+ "node_modules/zwitch": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+ "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ }
+ }
+}
diff --git a/tests/Jsx/oracle/package.json b/tests/Jsx/oracle/package.json
new file mode 100644
index 00000000..60a04e32
--- /dev/null
+++ b/tests/Jsx/oracle/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "phasis-jsx-oracle",
+ "private": true,
+ "description": "Pinned reference tools for the JSX Pack oracles. Installed only when refreshing oracles; outputs are committed.",
+ "type": "module",
+ "dependencies": {
+ "@heroicons/react": "2.2.0",
+ "class-variance-authority": "0.7.1",
+ "classnames": "2.5.1",
+ "clsx": "2.1.1",
+ "date-fns": "4.4.0",
+ "dayjs": "1.11.23",
+ "esbuild": "0.28.2",
+ "jsdom": "26.1.0",
+ "lodash": "4.18.1",
+ "lodash-es": "4.18.1",
+ "lucide-react": "1.56.0",
+ "preact": "11.0.1",
+ "preact-render-to-string": "6.8.0",
+ "prop-types": "15.8.1",
+ "react": "19.3.0",
+ "react-dom": "19.3.0",
+ "react-icons": "5.7.0",
+ "react-is": "19.3.0",
+ "react-markdown": "10.1.0",
+ "tailwind-merge": "3.7.0",
+ "use-sync-external-store": "1.7.0"
+ }
+}
diff --git a/tests/Jsx/oracle/recorder/index.js b/tests/Jsx/oracle/recorder/index.js
new file mode 100644
index 00000000..9222371a
--- /dev/null
+++ b/tests/Jsx/oracle/recorder/index.js
@@ -0,0 +1,20 @@
+import { record } from './shared.js';
+
+export { Fragment } from './jsx-runtime.js';
+
+export function createElement(...args) {
+ return record('createElement', args);
+}
+
+export function h(...args) {
+ return record('h', args);
+}
+
+export function Frag(props) {
+ return props.children;
+}
+
+export function log(label) {
+ console.log('eval ' + label);
+ return label;
+}
diff --git a/tests/Jsx/oracle/recorder/jsx-runtime.js b/tests/Jsx/oracle/recorder/jsx-runtime.js
new file mode 100644
index 00000000..1f2db957
--- /dev/null
+++ b/tests/Jsx/oracle/recorder/jsx-runtime.js
@@ -0,0 +1,13 @@
+import { record } from './shared.js';
+
+export function Fragment(props) {
+ return props.children;
+}
+
+export function jsx(...args) {
+ return record('jsx', args);
+}
+
+export function jsxs(...args) {
+ return record('jsxs', args);
+}
diff --git a/tests/Jsx/oracle/recorder/shared.js b/tests/Jsx/oracle/recorder/shared.js
new file mode 100644
index 00000000..13618613
--- /dev/null
+++ b/tests/Jsx/oracle/recorder/shared.js
@@ -0,0 +1,28 @@
+// Recording JSX runtime for the lowering oracle: every call prints one line
+// describing exactly what the compiled code passed in, and returns a token
+// that later lines refer to as #id.
+let next = 0;
+
+export function show(value) {
+ if (value === undefined) return 'undefined';
+ if (value === null) return 'null';
+ switch (typeof value) {
+ case 'string': return JSON.stringify(value);
+ case 'number':
+ case 'boolean': return String(value);
+ case 'bigint': return value + 'n';
+ case 'symbol': return value.toString();
+ case 'function': return 'fn:' + (value.name || 'anonymous');
+ }
+ if (Array.isArray(value)) return '[' + value.map(show).join(', ') + ']';
+ if (Object.prototype.hasOwnProperty.call(value, '$el')) return '#' + value.$el;
+ const proto = Object.getPrototypeOf(value);
+ const prefix = proto === Object.prototype ? '' : proto === null ? '(null proto)' : '(custom proto)';
+ return prefix + '{' + Object.keys(value).map((k) => JSON.stringify(k) + ': ' + show(value[k])).join(', ') + '}';
+}
+
+export function record(fn, args) {
+ const id = ++next;
+ console.log(`#${id} ${fn}(${args.map(show).join(', ')})`);
+ return { $el: id };
+}
diff --git a/tests/Jsx/parse/arrow-params.expected.txt b/tests/Jsx/parse/arrow-params.expected.txt
new file mode 100644
index 00000000..9cfedd6b
--- /dev/null
+++ b/tests/Jsx/parse/arrow-params.expected.txt
@@ -0,0 +1,27 @@
+Program
+ VariableDeclaration kind=const
+ VariableDeclarator
+ Identifier name=f
+ ArrowFunctionExpression
+ AssignmentPattern
+ Identifier name=a
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=A
+ AssignmentPattern
+ ObjectPattern
+ Property kind=init
+ Identifier name=b
+ Identifier name=b
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=B
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=C
+ JSXClosingElement
+ JSXIdentifier name=C
+ JSXExpressionContainer
+ Identifier name=a
+ JSXExpressionContainer
+ Identifier name=b
diff --git a/tests/Jsx/parse/arrow-params.jsx b/tests/Jsx/parse/arrow-params.jsx
new file mode 100644
index 00000000..17d90a25
--- /dev/null
+++ b/tests/Jsx/parse/arrow-params.jsx
@@ -0,0 +1 @@
+const f = (a = , { b } = ) => ({a}{b} );
\ No newline at end of file
diff --git a/tests/Jsx/parse/attr-backslash.expected.txt b/tests/Jsx/parse/attr-backslash.expected.txt
new file mode 100644
index 00000000..5129421f
--- /dev/null
+++ b/tests/Jsx/parse/attr-backslash.expected.txt
@@ -0,0 +1,10 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=a
+ JSXAttribute
+ JSXIdentifier name=title
+ Literal value="c:\\new\\t"
diff --git a/tests/Jsx/parse/attr-backslash.jsx b/tests/Jsx/parse/attr-backslash.jsx
new file mode 100644
index 00000000..3287d3a9
--- /dev/null
+++ b/tests/Jsx/parse/attr-backslash.jsx
@@ -0,0 +1 @@
+x = ;
\ No newline at end of file
diff --git a/tests/Jsx/parse/attr-bare.expected.txt b/tests/Jsx/parse/attr-bare.expected.txt
new file mode 100644
index 00000000..8232aec2
--- /dev/null
+++ b/tests/Jsx/parse/attr-bare.expected.txt
@@ -0,0 +1,11 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=input
+ JSXAttribute
+ JSXIdentifier name=disabled
+ JSXAttribute
+ JSXIdentifier name=readOnly
diff --git a/tests/Jsx/parse/attr-bare.jsx b/tests/Jsx/parse/attr-bare.jsx
new file mode 100644
index 00000000..b47fda6b
--- /dev/null
+++ b/tests/Jsx/parse/attr-bare.jsx
@@ -0,0 +1 @@
+x = ;
\ No newline at end of file
diff --git a/tests/Jsx/parse/attr-comments.expected.txt b/tests/Jsx/parse/attr-comments.expected.txt
new file mode 100644
index 00000000..9e960b2d
--- /dev/null
+++ b/tests/Jsx/parse/attr-comments.expected.txt
@@ -0,0 +1,14 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=div
+ JSXAttribute
+ JSXIdentifier name=a
+ Literal value="1"
+ JSXAttribute
+ JSXIdentifier name=b
+ JSXExpressionContainer
+ Literal value=2
diff --git a/tests/Jsx/parse/attr-comments.jsx b/tests/Jsx/parse/attr-comments.jsx
new file mode 100644
index 00000000..d56d6fca
--- /dev/null
+++ b/tests/Jsx/parse/attr-comments.jsx
@@ -0,0 +1,2 @@
+x =
;
\ No newline at end of file
diff --git a/tests/Jsx/parse/attr-element.expected.txt b/tests/Jsx/parse/attr-element.expected.txt
new file mode 100644
index 00000000..e2db6281
--- /dev/null
+++ b/tests/Jsx/parse/attr-element.expected.txt
@@ -0,0 +1,23 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=Layout
+ JSXAttribute
+ JSXIdentifier name=header
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=Header
+ JSXAttribute
+ JSXIdentifier name=footer
+ JSXFragment
+ JSXOpeningFragment
+ JSXClosingFragment
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=b
+ JSXClosingElement
+ JSXIdentifier name=b
+ JSXText value="f"
diff --git a/tests/Jsx/parse/attr-element.jsx b/tests/Jsx/parse/attr-element.jsx
new file mode 100644
index 00000000..e34c3d2f
--- /dev/null
+++ b/tests/Jsx/parse/attr-element.jsx
@@ -0,0 +1 @@
+x = footer=<>f > />;
\ No newline at end of file
diff --git a/tests/Jsx/parse/attr-expression.expected.txt b/tests/Jsx/parse/attr-expression.expected.txt
new file mode 100644
index 00000000..91c4e0be
--- /dev/null
+++ b/tests/Jsx/parse/attr-expression.expected.txt
@@ -0,0 +1,31 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=a
+ JSXAttribute
+ JSXIdentifier name=onClick
+ JSXExpressionContainer
+ ArrowFunctionExpression
+ CallExpression
+ Identifier name=go
+ Literal value=1
+ JSXAttribute
+ JSXIdentifier name=style
+ JSXExpressionContainer
+ ObjectExpression
+ Property kind=init
+ Identifier name=color
+ Literal value="red"
+ Property kind=init
+ Identifier name=fontSize
+ Literal value=12
+ JSXAttribute
+ JSXIdentifier name=n
+ JSXExpressionContainer
+ ConditionalExpression
+ Identifier name=a
+ Identifier name=b
+ Identifier name=c
diff --git a/tests/Jsx/parse/attr-expression.jsx b/tests/Jsx/parse/attr-expression.jsx
new file mode 100644
index 00000000..a6992c55
--- /dev/null
+++ b/tests/Jsx/parse/attr-expression.jsx
@@ -0,0 +1 @@
+x = go(1)} style={{ color: "red", fontSize: 12 }} n={a ? b : c} />;
\ No newline at end of file
diff --git a/tests/Jsx/parse/attr-multiline.expected.txt b/tests/Jsx/parse/attr-multiline.expected.txt
new file mode 100644
index 00000000..60191a2c
--- /dev/null
+++ b/tests/Jsx/parse/attr-multiline.expected.txt
@@ -0,0 +1,10 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=a
+ JSXAttribute
+ JSXIdentifier name=title
+ Literal value="one\n two"
diff --git a/tests/Jsx/parse/attr-multiline.jsx b/tests/Jsx/parse/attr-multiline.jsx
new file mode 100644
index 00000000..44150a3d
--- /dev/null
+++ b/tests/Jsx/parse/attr-multiline.jsx
@@ -0,0 +1,2 @@
+x = ;
\ No newline at end of file
diff --git a/tests/Jsx/parse/attr-namespaced.expected.txt b/tests/Jsx/parse/attr-namespaced.expected.txt
new file mode 100644
index 00000000..22a659c7
--- /dev/null
+++ b/tests/Jsx/parse/attr-namespaced.expected.txt
@@ -0,0 +1,17 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=use
+ JSXAttribute
+ JSXNamespacedName
+ JSXIdentifier name=xlink
+ JSXIdentifier name=href
+ Literal value="#icon"
+ JSXAttribute
+ JSXNamespacedName
+ JSXIdentifier name=xml
+ JSXIdentifier name=lang
+ Literal value="en"
diff --git a/tests/Jsx/parse/attr-namespaced.jsx b/tests/Jsx/parse/attr-namespaced.jsx
new file mode 100644
index 00000000..0e45658c
--- /dev/null
+++ b/tests/Jsx/parse/attr-namespaced.jsx
@@ -0,0 +1 @@
+x = ;
\ No newline at end of file
diff --git a/tests/Jsx/parse/attr-spread.expected.txt b/tests/Jsx/parse/attr-spread.expected.txt
new file mode 100644
index 00000000..39ff407e
--- /dev/null
+++ b/tests/Jsx/parse/attr-spread.expected.txt
@@ -0,0 +1,20 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=Card
+ JSXSpreadAttribute
+ Identifier name=props
+ JSXAttribute
+ JSXIdentifier name=key
+ Literal value="k"
+ JSXSpreadAttribute
+ ObjectExpression
+ Property kind=init
+ Identifier name=a
+ Identifier name=a
+ Property kind=init
+ Identifier name=b
+ Identifier name=b
diff --git a/tests/Jsx/parse/attr-spread.jsx b/tests/Jsx/parse/attr-spread.jsx
new file mode 100644
index 00000000..e755cad5
--- /dev/null
+++ b/tests/Jsx/parse/attr-spread.jsx
@@ -0,0 +1 @@
+x = ;
\ No newline at end of file
diff --git a/tests/Jsx/parse/attr-strings.expected.txt b/tests/Jsx/parse/attr-strings.expected.txt
new file mode 100644
index 00000000..fff90cb1
--- /dev/null
+++ b/tests/Jsx/parse/attr-strings.expected.txt
@@ -0,0 +1,16 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=a
+ JSXAttribute
+ JSXIdentifier name=href
+ Literal value="/x?a=1&b=2"
+ JSXAttribute
+ JSXIdentifier name=title
+ Literal value="single \"q\""
+ JSXAttribute
+ JSXIdentifier name=data-n
+ Literal value="AB<&unknown;"
diff --git a/tests/Jsx/parse/attr-strings.jsx b/tests/Jsx/parse/attr-strings.jsx
new file mode 100644
index 00000000..3f548e71
--- /dev/null
+++ b/tests/Jsx/parse/attr-strings.jsx
@@ -0,0 +1 @@
+x = ;
\ No newline at end of file
diff --git a/tests/Jsx/parse/children-empty.expected.txt b/tests/Jsx/parse/children-empty.expected.txt
new file mode 100644
index 00000000..f70a9d82
--- /dev/null
+++ b/tests/Jsx/parse/children-empty.expected.txt
@@ -0,0 +1,15 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=div
+ JSXClosingElement
+ JSXIdentifier name=div
+ JSXExpressionContainer
+ JSXEmptyExpression
+ JSXExpressionContainer
+ JSXEmptyExpression
+ JSXExpressionContainer
+ JSXEmptyExpression
diff --git a/tests/Jsx/parse/children-empty.jsx b/tests/Jsx/parse/children-empty.jsx
new file mode 100644
index 00000000..bca311f4
--- /dev/null
+++ b/tests/Jsx/parse/children-empty.jsx
@@ -0,0 +1 @@
+x = {}{/* comment */}{/* a */ /* b */}
;
\ No newline at end of file
diff --git a/tests/Jsx/parse/children-entities.expected.txt b/tests/Jsx/parse/children-entities.expected.txt
new file mode 100644
index 00000000..e101cbeb
--- /dev/null
+++ b/tests/Jsx/parse/children-entities.expected.txt
@@ -0,0 +1,10 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=p
+ JSXClosingElement
+ JSXIdentifier name=p
+ JSXText value="© 2026 — Tom & Jerry € 😀 '"
diff --git a/tests/Jsx/parse/children-entities.jsx b/tests/Jsx/parse/children-entities.jsx
new file mode 100644
index 00000000..174fd65c
--- /dev/null
+++ b/tests/Jsx/parse/children-entities.jsx
@@ -0,0 +1 @@
+x = © 2026 — Tom & Jerry € 😀 '
;
\ No newline at end of file
diff --git a/tests/Jsx/parse/children-expressions.expected.txt b/tests/Jsx/parse/children-expressions.expected.txt
new file mode 100644
index 00000000..c8212be1
--- /dev/null
+++ b/tests/Jsx/parse/children-expressions.expected.txt
@@ -0,0 +1,31 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=ul
+ JSXClosingElement
+ JSXIdentifier name=ul
+ JSXExpressionContainer
+ CallExpression
+ MemberExpression
+ Identifier name=items
+ Identifier name=map
+ ArrowFunctionExpression
+ Identifier name=i
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=li
+ JSXAttribute
+ JSXIdentifier name=key
+ JSXExpressionContainer
+ MemberExpression
+ Identifier name=i
+ Identifier name=id
+ JSXClosingElement
+ JSXIdentifier name=li
+ JSXExpressionContainer
+ MemberExpression
+ Identifier name=i
+ Identifier name=name
diff --git a/tests/Jsx/parse/children-expressions.jsx b/tests/Jsx/parse/children-expressions.jsx
new file mode 100644
index 00000000..24851422
--- /dev/null
+++ b/tests/Jsx/parse/children-expressions.jsx
@@ -0,0 +1 @@
+x = {items.map((i) => {i.name} )} ;
\ No newline at end of file
diff --git a/tests/Jsx/parse/children-nested.expected.txt b/tests/Jsx/parse/children-nested.expected.txt
new file mode 100644
index 00000000..b4bae442
--- /dev/null
+++ b/tests/Jsx/parse/children-nested.expected.txt
@@ -0,0 +1,42 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=main
+ JSXClosingElement
+ JSXIdentifier name=main
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=header
+ JSXClosingElement
+ JSXIdentifier name=header
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=h1
+ JSXClosingElement
+ JSXIdentifier name=h1
+ JSXText value="T"
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=section
+ JSXClosingElement
+ JSXIdentifier name=section
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=article
+ JSXClosingElement
+ JSXIdentifier name=article
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=p
+ JSXClosingElement
+ JSXIdentifier name=p
+ JSXText value="a"
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=p
+ JSXClosingElement
+ JSXIdentifier name=p
+ JSXText value="b"
diff --git a/tests/Jsx/parse/children-nested.jsx b/tests/Jsx/parse/children-nested.jsx
new file mode 100644
index 00000000..df8bfe7f
--- /dev/null
+++ b/tests/Jsx/parse/children-nested.jsx
@@ -0,0 +1 @@
+x = ;
\ No newline at end of file
diff --git a/tests/Jsx/parse/children-special-text.expected.txt b/tests/Jsx/parse/children-special-text.expected.txt
new file mode 100644
index 00000000..673483ed
--- /dev/null
+++ b/tests/Jsx/parse/children-special-text.expected.txt
@@ -0,0 +1,10 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=p
+ JSXClosingElement
+ JSXIdentifier name=p
+ JSXText value="Don't // not a comment /* nor this */ a > b and c } d \"quoted\""
diff --git a/tests/Jsx/parse/children-special-text.jsx b/tests/Jsx/parse/children-special-text.jsx
new file mode 100644
index 00000000..66386333
--- /dev/null
+++ b/tests/Jsx/parse/children-special-text.jsx
@@ -0,0 +1 @@
+x = Don't // not a comment /* nor this */ a > b and c } d "quoted"
;
\ No newline at end of file
diff --git a/tests/Jsx/parse/children-spread.expected.txt b/tests/Jsx/parse/children-spread.expected.txt
new file mode 100644
index 00000000..401c0643
--- /dev/null
+++ b/tests/Jsx/parse/children-spread.expected.txt
@@ -0,0 +1,11 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=div
+ JSXClosingElement
+ JSXIdentifier name=div
+ JSXSpreadChild
+ Identifier name=list
diff --git a/tests/Jsx/parse/children-spread.jsx b/tests/Jsx/parse/children-spread.jsx
new file mode 100644
index 00000000..b2fc486b
--- /dev/null
+++ b/tests/Jsx/parse/children-spread.jsx
@@ -0,0 +1 @@
+x = {...list}
;
\ No newline at end of file
diff --git a/tests/Jsx/parse/children-text.expected.txt b/tests/Jsx/parse/children-text.expected.txt
new file mode 100644
index 00000000..c17dec74
--- /dev/null
+++ b/tests/Jsx/parse/children-text.expected.txt
@@ -0,0 +1,20 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=p
+ JSXClosingElement
+ JSXIdentifier name=p
+ JSXText value="\n Hello,\n world "
+ JSXExpressionContainer
+ Identifier name=name
+ JSXText value="\n "
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=b
+ JSXClosingElement
+ JSXIdentifier name=b
+ JSXText value="bold"
+ JSXText value=" tail\n"
diff --git a/tests/Jsx/parse/children-text.jsx b/tests/Jsx/parse/children-text.jsx
new file mode 100644
index 00000000..967b0a81
--- /dev/null
+++ b/tests/Jsx/parse/children-text.jsx
@@ -0,0 +1,5 @@
+x =
+ Hello,
+ world {name}
+ bold tail
+
;
\ No newline at end of file
diff --git a/tests/Jsx/parse/comparison-after.expected.txt b/tests/Jsx/parse/comparison-after.expected.txt
new file mode 100644
index 00000000..634c006e
--- /dev/null
+++ b/tests/Jsx/parse/comparison-after.expected.txt
@@ -0,0 +1,21 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ BinaryExpression op=<
+ Identifier name=a
+ Identifier name=b
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=y
+ BinaryExpression op=!==
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=a
+ Identifier name=z
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=w
+ BinaryExpression op=<
+ Identifier name=c
+ Identifier name=d
diff --git a/tests/Jsx/parse/comparison-after.jsx b/tests/Jsx/parse/comparison-after.jsx
new file mode 100644
index 00000000..39b873af
--- /dev/null
+++ b/tests/Jsx/parse/comparison-after.jsx
@@ -0,0 +1 @@
+x = a < b; y = !== z; w = (c) < d;
\ No newline at end of file
diff --git a/tests/Jsx/parse/division-after.expected.txt b/tests/Jsx/parse/division-after.expected.txt
new file mode 100644
index 00000000..f88acd21
--- /dev/null
+++ b/tests/Jsx/parse/division-after.expected.txt
@@ -0,0 +1,9 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ BinaryExpression op=/
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=a
+ Literal value=2
diff --git a/tests/Jsx/parse/division-after.jsx b/tests/Jsx/parse/division-after.jsx
new file mode 100644
index 00000000..f1e11dea
--- /dev/null
+++ b/tests/Jsx/parse/division-after.jsx
@@ -0,0 +1 @@
+x = / 2;
\ No newline at end of file
diff --git a/tests/Jsx/parse/error-adjacent.expected.txt b/tests/Jsx/parse/error-adjacent.expected.txt
new file mode 100644
index 00000000..c469d638
--- /dev/null
+++ b/tests/Jsx/parse/error-adjacent.expected.txt
@@ -0,0 +1 @@
+SyntaxError: Adjacent JSX elements must be wrapped in an enclosing tag. Did you want a JSX fragment <>...>?
diff --git a/tests/Jsx/parse/error-adjacent.jsx b/tests/Jsx/parse/error-adjacent.jsx
new file mode 100644
index 00000000..b9cfff9d
--- /dev/null
+++ b/tests/Jsx/parse/error-adjacent.jsx
@@ -0,0 +1 @@
+x = ;
\ No newline at end of file
diff --git a/tests/Jsx/parse/error-attr-value.expected.txt b/tests/Jsx/parse/error-attr-value.expected.txt
new file mode 100644
index 00000000..00a14b21
--- /dev/null
+++ b/tests/Jsx/parse/error-attr-value.expected.txt
@@ -0,0 +1 @@
+SyntaxError: JSX value should be either an expression or a quoted JSX text.
diff --git a/tests/Jsx/parse/error-attr-value.jsx b/tests/Jsx/parse/error-attr-value.jsx
new file mode 100644
index 00000000..dfae52b1
--- /dev/null
+++ b/tests/Jsx/parse/error-attr-value.jsx
@@ -0,0 +1 @@
+x = ;
\ No newline at end of file
diff --git a/tests/Jsx/parse/error-empty-attr.expected.txt b/tests/Jsx/parse/error-empty-attr.expected.txt
new file mode 100644
index 00000000..6cf9d8fc
--- /dev/null
+++ b/tests/Jsx/parse/error-empty-attr.expected.txt
@@ -0,0 +1 @@
+SyntaxError: JSX attributes must only be assigned a non-empty expression.
diff --git a/tests/Jsx/parse/error-empty-attr.jsx b/tests/Jsx/parse/error-empty-attr.jsx
new file mode 100644
index 00000000..573852bb
--- /dev/null
+++ b/tests/Jsx/parse/error-empty-attr.jsx
@@ -0,0 +1 @@
+x = ;
\ No newline at end of file
diff --git a/tests/Jsx/parse/error-mismatch.expected.txt b/tests/Jsx/parse/error-mismatch.expected.txt
new file mode 100644
index 00000000..740367d7
--- /dev/null
+++ b/tests/Jsx/parse/error-mismatch.expected.txt
@@ -0,0 +1 @@
+SyntaxError: Expected corresponding JSX closing tag for .
diff --git a/tests/Jsx/parse/error-mismatch.jsx b/tests/Jsx/parse/error-mismatch.jsx
new file mode 100644
index 00000000..0c3728e3
--- /dev/null
+++ b/tests/Jsx/parse/error-mismatch.jsx
@@ -0,0 +1 @@
+x = ;
\ No newline at end of file
diff --git a/tests/Jsx/parse/error-unterminated.expected.txt b/tests/Jsx/parse/error-unterminated.expected.txt
new file mode 100644
index 00000000..ed3b2867
--- /dev/null
+++ b/tests/Jsx/parse/error-unterminated.expected.txt
@@ -0,0 +1 @@
+SyntaxError: Unterminated JSX contents.
diff --git a/tests/Jsx/parse/error-unterminated.jsx b/tests/Jsx/parse/error-unterminated.jsx
new file mode 100644
index 00000000..9694480d
--- /dev/null
+++ b/tests/Jsx/parse/error-unterminated.jsx
@@ -0,0 +1 @@
+x = text
\ No newline at end of file
diff --git a/tests/Jsx/parse/fragments.expected.txt b/tests/Jsx/parse/fragments.expected.txt
new file mode 100644
index 00000000..c91540c8
--- /dev/null
+++ b/tests/Jsx/parse/fragments.expected.txt
@@ -0,0 +1,17 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXFragment
+ JSXOpeningFragment
+ JSXClosingFragment
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=A
+ JSXFragment
+ JSXOpeningFragment
+ JSXClosingFragment
+ JSXText value="nested"
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=B
diff --git a/tests/Jsx/parse/fragments.jsx b/tests/Jsx/parse/fragments.jsx
new file mode 100644
index 00000000..048bb80e
--- /dev/null
+++ b/tests/Jsx/parse/fragments.jsx
@@ -0,0 +1 @@
+x = <> <>nested> >;
\ No newline at end of file
diff --git a/tests/Jsx/parse/generators.expected.txt b/tests/Jsx/parse/generators.expected.txt
new file mode 100644
index 00000000..53428f1b
--- /dev/null
+++ b/tests/Jsx/parse/generators.expected.txt
@@ -0,0 +1,17 @@
+Program
+ FunctionDeclaration
+ Identifier name=g
+ BlockStatement
+ ExpressionStatement
+ YieldExpression
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=A
+ FunctionDeclaration
+ Identifier name=h
+ BlockStatement
+ ReturnStatement
+ AwaitExpression
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=B
diff --git a/tests/Jsx/parse/generators.jsx b/tests/Jsx/parse/generators.jsx
new file mode 100644
index 00000000..0794d2cb
--- /dev/null
+++ b/tests/Jsx/parse/generators.jsx
@@ -0,0 +1,2 @@
+function* g() { yield ; }
+async function h() { return await ; }
\ No newline at end of file
diff --git a/tests/Jsx/parse/jsx-in-template.expected.txt b/tests/Jsx/parse/jsx-in-template.expected.txt
new file mode 100644
index 00000000..18d54ad3
--- /dev/null
+++ b/tests/Jsx/parse/jsx-in-template.expected.txt
@@ -0,0 +1,23 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ TemplateLiteral
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=b
+ JSXClosingElement
+ JSXIdentifier name=b
+ JSXExpressionContainer
+ TemplateLiteral
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=i
+ JSXClosingElement
+ JSXIdentifier name=i
+ JSXExpressionContainer
+ Identifier name=y
+ TemplateElement
+ TemplateElement
+ TemplateElement
+ TemplateElement
diff --git a/tests/Jsx/parse/jsx-in-template.jsx b/tests/Jsx/parse/jsx-in-template.jsx
new file mode 100644
index 00000000..42f83f26
--- /dev/null
+++ b/tests/Jsx/parse/jsx-in-template.jsx
@@ -0,0 +1 @@
+x = `${{`${{y} }`} }`;
\ No newline at end of file
diff --git a/tests/Jsx/parse/long-component.expected.txt b/tests/Jsx/parse/long-component.expected.txt
new file mode 100644
index 00000000..d18454e7
--- /dev/null
+++ b/tests/Jsx/parse/long-component.expected.txt
@@ -0,0 +1,109 @@
+Program
+ ExportDefaultDeclaration
+ FunctionDeclaration
+ Identifier name=Page
+ ObjectPattern
+ Property kind=init
+ Identifier name=posts
+ Identifier name=posts
+ Property kind=init
+ Identifier name=user
+ Identifier name=user
+ BlockStatement
+ ReturnStatement
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=Layout
+ JSXAttribute
+ JSXIdentifier name=title
+ Literal value="Blog"
+ JSXAttribute
+ JSXIdentifier name=user
+ JSXExpressionContainer
+ Identifier name=user
+ JSXClosingElement
+ JSXIdentifier name=Layout
+ JSXText value="\n "
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=h1
+ JSXAttribute
+ JSXIdentifier name=className
+ Literal value="title"
+ JSXClosingElement
+ JSXIdentifier name=h1
+ JSXText value="Posts & news"
+ JSXText value="\n "
+ JSXExpressionContainer
+ ConditionalExpression
+ BinaryExpression op====
+ MemberExpression
+ Identifier name=posts
+ Identifier name=length
+ Literal value=0
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=p
+ JSXClosingElement
+ JSXIdentifier name=p
+ JSXText value="No posts yet."
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=ul
+ JSXAttribute
+ JSXIdentifier name=className
+ Literal value="posts"
+ JSXClosingElement
+ JSXIdentifier name=ul
+ JSXText value="\n "
+ JSXExpressionContainer
+ CallExpression
+ MemberExpression
+ Identifier name=posts
+ Identifier name=map
+ ArrowFunctionExpression
+ Identifier name=post
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=li
+ JSXAttribute
+ JSXIdentifier name=key
+ JSXExpressionContainer
+ MemberExpression
+ Identifier name=post
+ Identifier name=id
+ JSXClosingElement
+ JSXIdentifier name=li
+ JSXText value="\n "
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=a
+ JSXAttribute
+ JSXIdentifier name=href
+ JSXExpressionContainer
+ TemplateLiteral
+ MemberExpression
+ Identifier name=post
+ Identifier name=slug
+ TemplateElement
+ TemplateElement
+ JSXClosingElement
+ JSXIdentifier name=a
+ JSXExpressionContainer
+ MemberExpression
+ Identifier name=post
+ Identifier name=title
+ JSXText value="\n "
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=span
+ JSXClosingElement
+ JSXIdentifier name=span
+ JSXText value=" by "
+ JSXExpressionContainer
+ MemberExpression
+ Identifier name=post
+ Identifier name=author
+ JSXText value="\n "
+ JSXText value="\n "
+ JSXText value="\n "
diff --git a/tests/Jsx/parse/long-component.jsx b/tests/Jsx/parse/long-component.jsx
new file mode 100644
index 00000000..81fe3546
--- /dev/null
+++ b/tests/Jsx/parse/long-component.jsx
@@ -0,0 +1,19 @@
+export default function Page({ posts, user }) {
+ return (
+
+ Posts & news
+ {posts.length === 0 ? (
+ No posts yet.
+ ) : (
+
+ {posts.map((post) => (
+
+ {post.title}
+ by {post.author}
+
+ ))}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/tests/Jsx/parse/names-component.expected.txt b/tests/Jsx/parse/names-component.expected.txt
new file mode 100644
index 00000000..a9eff048
--- /dev/null
+++ b/tests/Jsx/parse/names-component.expected.txt
@@ -0,0 +1,7 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=Card
diff --git a/tests/Jsx/parse/names-component.jsx b/tests/Jsx/parse/names-component.jsx
new file mode 100644
index 00000000..e4a07cfc
--- /dev/null
+++ b/tests/Jsx/parse/names-component.jsx
@@ -0,0 +1 @@
+x = ;
\ No newline at end of file
diff --git a/tests/Jsx/parse/names-dashed.expected.txt b/tests/Jsx/parse/names-dashed.expected.txt
new file mode 100644
index 00000000..e2f719c0
--- /dev/null
+++ b/tests/Jsx/parse/names-dashed.expected.txt
@@ -0,0 +1,13 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=my-element
+ JSXAttribute
+ JSXIdentifier name=data-x
+ Literal value="1"
+ JSXAttribute
+ JSXIdentifier name=aria-label
+ Literal value="y"
diff --git a/tests/Jsx/parse/names-dashed.jsx b/tests/Jsx/parse/names-dashed.jsx
new file mode 100644
index 00000000..89565bb3
--- /dev/null
+++ b/tests/Jsx/parse/names-dashed.jsx
@@ -0,0 +1 @@
+x = ;
\ No newline at end of file
diff --git a/tests/Jsx/parse/names-keywords.expected.txt b/tests/Jsx/parse/names-keywords.expected.txt
new file mode 100644
index 00000000..75a904bd
--- /dev/null
+++ b/tests/Jsx/parse/names-keywords.expected.txt
@@ -0,0 +1,15 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=label
+ JSXAttribute
+ JSXIdentifier name=for
+ Literal value="a"
+ JSXAttribute
+ JSXIdentifier name=class
+ Literal value="b"
+ JSXAttribute
+ JSXIdentifier name=default
diff --git a/tests/Jsx/parse/names-keywords.jsx b/tests/Jsx/parse/names-keywords.jsx
new file mode 100644
index 00000000..1c09a4b4
--- /dev/null
+++ b/tests/Jsx/parse/names-keywords.jsx
@@ -0,0 +1 @@
+x = ;
\ No newline at end of file
diff --git a/tests/Jsx/parse/names-lowercase.expected.txt b/tests/Jsx/parse/names-lowercase.expected.txt
new file mode 100644
index 00000000..4a3da774
--- /dev/null
+++ b/tests/Jsx/parse/names-lowercase.expected.txt
@@ -0,0 +1,7 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=div
diff --git a/tests/Jsx/parse/names-lowercase.jsx b/tests/Jsx/parse/names-lowercase.jsx
new file mode 100644
index 00000000..c78e5543
--- /dev/null
+++ b/tests/Jsx/parse/names-lowercase.jsx
@@ -0,0 +1 @@
+x =
;
\ No newline at end of file
diff --git a/tests/Jsx/parse/names-member.expected.txt b/tests/Jsx/parse/names-member.expected.txt
new file mode 100644
index 00000000..6149c798
--- /dev/null
+++ b/tests/Jsx/parse/names-member.expected.txt
@@ -0,0 +1,11 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXMemberExpression
+ JSXMemberExpression
+ JSXIdentifier name=UI
+ JSXIdentifier name=Layout
+ JSXIdentifier name=Header
diff --git a/tests/Jsx/parse/names-member.jsx b/tests/Jsx/parse/names-member.jsx
new file mode 100644
index 00000000..ce233b80
--- /dev/null
+++ b/tests/Jsx/parse/names-member.jsx
@@ -0,0 +1 @@
+x = ;
\ No newline at end of file
diff --git a/tests/Jsx/parse/names-namespace.expected.txt b/tests/Jsx/parse/names-namespace.expected.txt
new file mode 100644
index 00000000..f7581133
--- /dev/null
+++ b/tests/Jsx/parse/names-namespace.expected.txt
@@ -0,0 +1,12 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXNamespacedName
+ JSXIdentifier name=svg
+ JSXIdentifier name=rect
+ JSXAttribute
+ JSXIdentifier name=width
+ Literal value="1"
diff --git a/tests/Jsx/parse/names-namespace.jsx b/tests/Jsx/parse/names-namespace.jsx
new file mode 100644
index 00000000..13db5fc3
--- /dev/null
+++ b/tests/Jsx/parse/names-namespace.jsx
@@ -0,0 +1 @@
+x = ;
\ No newline at end of file
diff --git a/tests/Jsx/parse/names-this.expected.txt b/tests/Jsx/parse/names-this.expected.txt
new file mode 100644
index 00000000..1580c1f5
--- /dev/null
+++ b/tests/Jsx/parse/names-this.expected.txt
@@ -0,0 +1,9 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXMemberExpression
+ JSXIdentifier name=this
+ JSXIdentifier name=Comp
diff --git a/tests/Jsx/parse/names-this.jsx b/tests/Jsx/parse/names-this.jsx
new file mode 100644
index 00000000..c882898c
--- /dev/null
+++ b/tests/Jsx/parse/names-this.jsx
@@ -0,0 +1 @@
+x = ;
\ No newline at end of file
diff --git a/tests/Jsx/parse/names-unicode.expected.txt b/tests/Jsx/parse/names-unicode.expected.txt
new file mode 100644
index 00000000..6f9bb255
--- /dev/null
+++ b/tests/Jsx/parse/names-unicode.expected.txt
@@ -0,0 +1,10 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=Größe
+ JSXAttribute
+ JSXIdentifier name=wert
+ Literal value="ä"
diff --git a/tests/Jsx/parse/names-unicode.jsx b/tests/Jsx/parse/names-unicode.jsx
new file mode 100644
index 00000000..b45330c4
--- /dev/null
+++ b/tests/Jsx/parse/names-unicode.jsx
@@ -0,0 +1 @@
+x = ;
\ No newline at end of file
diff --git a/tests/Jsx/parse/object-in-attr.expected.txt b/tests/Jsx/parse/object-in-attr.expected.txt
new file mode 100644
index 00000000..cfac3803
--- /dev/null
+++ b/tests/Jsx/parse/object-in-attr.expected.txt
@@ -0,0 +1,29 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=C
+ JSXAttribute
+ JSXIdentifier name=data
+ JSXExpressionContainer
+ ObjectExpression
+ Property kind=init
+ Identifier name=a
+ ObjectExpression
+ Property kind=init
+ Identifier name=b
+ ArrayExpression
+ Literal value=1
+ Literal value=2
+ JSXAttribute
+ JSXIdentifier name=f
+ JSXExpressionContainer
+ FunctionExpression
+ BlockStatement
+ ReturnStatement
+ ObjectExpression
+ Property kind=init
+ Identifier name=c
+ Literal value=1
diff --git a/tests/Jsx/parse/object-in-attr.jsx b/tests/Jsx/parse/object-in-attr.jsx
new file mode 100644
index 00000000..154637d1
--- /dev/null
+++ b/tests/Jsx/parse/object-in-attr.jsx
@@ -0,0 +1 @@
+x = ;
\ No newline at end of file
diff --git a/tests/Jsx/parse/positions.expected.txt b/tests/Jsx/parse/positions.expected.txt
new file mode 100644
index 00000000..e48867f0
--- /dev/null
+++ b/tests/Jsx/parse/positions.expected.txt
@@ -0,0 +1,71 @@
+Program
+ FunctionDeclaration
+ Identifier name=f
+ BlockStatement
+ IfStatement
+ Identifier name=a
+ ReturnStatement
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=A
+ VariableDeclaration kind=const
+ VariableDeclarator
+ Identifier name=b
+ ConditionalExpression
+ Identifier name=c
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=B
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=C
+ VariableDeclaration kind=const
+ VariableDeclarator
+ Identifier name=d
+ ArrayExpression
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=D
+ JSXAttribute
+ JSXIdentifier name=key
+ Literal value="1"
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=E
+ JSXAttribute
+ JSXIdentifier name=key
+ Literal value="2"
+ ReturnStatement
+ LogicalExpression op=&&
+ Identifier name=e
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=F
+ JSXClosingElement
+ JSXIdentifier name=F
+ JSXExpressionContainer
+ LogicalExpression op=||
+ Identifier name=g
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=G
+ VariableDeclaration kind=const
+ VariableDeclarator
+ Identifier name=h
+ ArrowFunctionExpression
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=H
+ VariableDeclaration kind=const
+ VariableDeclarator
+ Identifier name=i
+ ObjectExpression
+ Property kind=init
+ Identifier name=j
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=I
+ ExportDefaultDeclaration
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=J
diff --git a/tests/Jsx/parse/positions.jsx b/tests/Jsx/parse/positions.jsx
new file mode 100644
index 00000000..9c36b200
--- /dev/null
+++ b/tests/Jsx/parse/positions.jsx
@@ -0,0 +1,9 @@
+function f() {
+ if (a) return ;
+ const b = c ? : ;
+ const d = [ , ];
+ return e && {g || } ;
+}
+const h = () => ;
+const i = { j: };
+export default ;
\ No newline at end of file
diff --git a/tests/Jsx/parse/regex-in-expression.expected.txt b/tests/Jsx/parse/regex-in-expression.expected.txt
new file mode 100644
index 00000000..362fe7fd
--- /dev/null
+++ b/tests/Jsx/parse/regex-in-expression.expected.txt
@@ -0,0 +1,18 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=p
+ JSXClosingElement
+ JSXIdentifier name=p
+ JSXExpressionContainer
+ ConditionalExpression
+ CallExpression
+ MemberExpression
+ Literal
+ Identifier name=test
+ Identifier name=s
+ Literal value="y"
+ Literal value="n"
diff --git a/tests/Jsx/parse/regex-in-expression.jsx b/tests/Jsx/parse/regex-in-expression.jsx
new file mode 100644
index 00000000..42c7d0ce
--- /dev/null
+++ b/tests/Jsx/parse/regex-in-expression.jsx
@@ -0,0 +1 @@
+x = {/ab+c/.test(s) ? "y" : "n"}
;
\ No newline at end of file
diff --git a/tests/Jsx/parse/statement-after-block.expected.txt b/tests/Jsx/parse/statement-after-block.expected.txt
new file mode 100644
index 00000000..49efb372
--- /dev/null
+++ b/tests/Jsx/parse/statement-after-block.expected.txt
@@ -0,0 +1,25 @@
+Program
+ FunctionDeclaration
+ Identifier name=A
+ BlockStatement
+ ExpressionStatement
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=A
+ IfStatement
+ Identifier name=x
+ BlockStatement
+ ExpressionStatement
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=B
+ ClassDeclaration
+ Identifier name=C
+ ClassBody
+ ExpressionStatement
+ JSXFragment
+ JSXOpeningFragment
+ JSXClosingFragment
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=C
diff --git a/tests/Jsx/parse/statement-after-block.jsx b/tests/Jsx/parse/statement-after-block.jsx
new file mode 100644
index 00000000..07d3179f
--- /dev/null
+++ b/tests/Jsx/parse/statement-after-block.jsx
@@ -0,0 +1,5 @@
+function A() {}
+ ;
+if (x) {} ;
+class C {}
+<> >;
diff --git a/tests/Jsx/parse/template-in-jsx.expected.txt b/tests/Jsx/parse/template-in-jsx.expected.txt
new file mode 100644
index 00000000..3992a1a6
--- /dev/null
+++ b/tests/Jsx/parse/template-in-jsx.expected.txt
@@ -0,0 +1,27 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=p
+ JSXAttribute
+ JSXIdentifier name=className
+ JSXExpressionContainer
+ TemplateLiteral
+ ConditionalExpression
+ Identifier name=b
+ Literal value="c"
+ TemplateLiteral
+ Identifier name=e
+ TemplateElement
+ TemplateElement
+ TemplateElement
+ TemplateElement
+ JSXClosingElement
+ JSXIdentifier name=p
+ JSXExpressionContainer
+ TemplateLiteral
+ Identifier name=u
+ TemplateElement
+ TemplateElement
diff --git a/tests/Jsx/parse/template-in-jsx.jsx b/tests/Jsx/parse/template-in-jsx.jsx
new file mode 100644
index 00000000..615a0aac
--- /dev/null
+++ b/tests/Jsx/parse/template-in-jsx.jsx
@@ -0,0 +1 @@
+x = {`t${u}`}
;
\ No newline at end of file
diff --git a/tests/Jsx/parse/whitespace-tags.expected.txt b/tests/Jsx/parse/whitespace-tags.expected.txt
new file mode 100644
index 00000000..a1b8a24a
--- /dev/null
+++ b/tests/Jsx/parse/whitespace-tags.expected.txt
@@ -0,0 +1,13 @@
+Program
+ ExpressionStatement
+ AssignmentExpression op==
+ Identifier name=x
+ JSXElement
+ JSXOpeningElement
+ JSXIdentifier name=div
+ JSXAttribute
+ JSXIdentifier name=a
+ Literal value="1"
+ JSXClosingElement
+ JSXIdentifier name=div
+ JSXText value="x"
diff --git a/tests/Jsx/parse/whitespace-tags.jsx b/tests/Jsx/parse/whitespace-tags.jsx
new file mode 100644
index 00000000..b357c215
--- /dev/null
+++ b/tests/Jsx/parse/whitespace-tags.jsx
@@ -0,0 +1 @@
+x = < div a = "1" >x div >;
\ No newline at end of file
diff --git a/tests/Unit/Lexer/JsxLexerTest.php b/tests/Unit/Lexer/JsxLexerTest.php
new file mode 100644
index 00000000..51a5551c
--- /dev/null
+++ b/tests/Unit/Lexer/JsxLexerTest.php
@@ -0,0 +1,114 @@
+ */
+ private function tokens(string $source, bool $jsx = true): array
+ {
+ $lexer = new Lexer($source);
+ $lexer->setJsx($jsx);
+ $result = [];
+ foreach ($lexer->tokenize() as $token) {
+ if ($token->type === TokenType::EOF) {
+ break;
+ }
+ $result[] = $token->type->name . ':' . $token->value;
+ }
+ return $result;
+ }
+
+ public function testTextIsNotLexedAsJavaScript(): void
+ {
+ $this->assertSame(
+ ['JsxTagStart:<', 'JsxName:p', 'JsxTagEnd:>', "JsxText:Don't // x /* y */", 'JsxTagStart:<', 'Slash:/', 'JsxName:p', 'JsxTagEnd:>'],
+ $this->tokens("Don't // x /* y */
"),
+ );
+ }
+
+ public function testNamesMayContainDashesAndKeywords(): void
+ {
+ $this->assertSame(
+ ['JsxTagStart:<', 'JsxName:my-el', 'JsxName:class', 'Equal:=', 'JsxString:a b', 'JsxName:for', 'Slash:/', 'JsxTagEnd:>'],
+ $this->tokens(' '),
+ );
+ }
+
+ public function testAttributeStringsKeepBackslashesAndNewlines(): void
+ {
+ $tokens = $this->tokens(" ");
+ $this->assertContains("JsxString:x\\n\ny", $tokens);
+ }
+
+ public function testExpressionContainersCountBraces(): void
+ {
+ $this->assertSame(
+ ['JsxTagStart:<', 'JsxName:a', 'JsxName:b', 'Equal:=', 'LeftBrace:{', 'LeftBrace:{', 'Identifier:c', 'Colon::',
+ 'Number:1', 'RightBrace:}', 'RightBrace:}', 'Slash:/', 'JsxTagEnd:>'],
+ $this->tokens(' '),
+ );
+ }
+
+ public function testTemplatesAndJsxNestBothWays(): void
+ {
+ $this->assertSame(
+ ['TemplateHead:', 'JsxTagStart:<', 'JsxName:b', 'JsxTagEnd:>', 'LeftBrace:{', 'NoSubstitutionTemplate:x',
+ 'RightBrace:}', 'JsxTagStart:<', 'Slash:/', 'JsxName:b', 'JsxTagEnd:>', 'TemplateTail:'],
+ $this->tokens('`${{`x`} }`'),
+ );
+ }
+
+ public function testLessThanAfterAValueStaysAComparison(): void
+ {
+ $this->assertSame(['Identifier:a', 'LessThan:<', 'Identifier:b'], $this->tokens('a < b'));
+ $this->assertSame(
+ ['Identifier:x', 'Equal:=', 'JsxTagStart:<', 'JsxName:a', 'Slash:/', 'JsxTagEnd:>', 'Slash:/', 'Number:2'],
+ $this->tokens('x = / 2'),
+ );
+ }
+
+ public function testJsxOffLeavesTokenizationUnchanged(): void
+ {
+ $this->assertSame(['Identifier:x', 'Equal:=', 'LessThan:<', 'Identifier:a', 'GreaterThan:>'], $this->tokens('x = ', false));
+ }
+
+ public function testPragmasAreCaptured(): void
+ {
+ $lexer = new Lexer("/** @jsxRuntime classic */\n// @jsx h\n/* @jsxFrag Frag */ x");
+ $lexer->setJsx(true);
+ $lexer->tokenize();
+ $this->assertSame(['jsxRuntime' => 'classic', 'jsx' => 'h', 'jsxFrag' => 'Frag'], $lexer->jsxPragmas());
+ }
+
+ public function testAdjacentRootsAreRejected(): void
+ {
+ $this->expectException(SyntaxError::class);
+ $this->expectExceptionMessage('Adjacent JSX elements must be wrapped in an enclosing tag');
+ $this->tokens("x = \n ");
+ }
+
+ public function testUnterminatedContentsAreRejected(): void
+ {
+ $this->expectException(SyntaxError::class);
+ $this->expectExceptionMessage('Unterminated JSX contents.');
+ $this->tokens('text');
+ }
+
+ public function testLocationsTrackLinesInsideText(): void
+ {
+ $lexer = new Lexer(" \n\n x{y} ");
+ $lexer->setJsx(true);
+ $tokens = $lexer->tokenize();
+ $this->assertSame(TokenType::Identifier, $tokens[5]->type);
+ $this->assertSame(3, $tokens[5]->location->line);
+ $this->assertSame(4, $tokens[5]->location->column);
+ }
+}
diff --git a/tests/Unit/Parser/JsxParserTest.php b/tests/Unit/Parser/JsxParserTest.php
new file mode 100644
index 00000000..f9feefb9
--- /dev/null
+++ b/tests/Unit/Parser/JsxParserTest.php
@@ -0,0 +1,71 @@
+a & b
;', 'module', new JsxOptions(mode: JsxMode::Preserve));
+ $statement = $program->body[0];
+ $this->assertInstanceOf(ExpressionStatement::class, $statement);
+ $element = $statement->expression;
+ $this->assertInstanceOf(JsxElement::class, $element);
+ $text = $element->children[0];
+ $this->assertInstanceOf(JsxText::class, $text);
+ $this->assertSame('a & b', $text->value);
+ $this->assertSame('a & b', $text->raw);
+ }
+
+ public function testJsxIsOffWithoutOptions(): void
+ {
+ $this->expectException(SyntaxError::class);
+ Parser::parseSource('x =
;', 'module');
+ }
+
+ public function testMismatchedClosingTag(): void
+ {
+ $this->expectException(SyntaxError::class);
+ $this->expectExceptionMessage('Expected corresponding JSX closing tag for .');
+ Parser::parseSource('x = ;', 'module', new JsxOptions());
+ }
+
+ public function testEmptyAttributeExpression(): void
+ {
+ $this->expectException(SyntaxError::class);
+ $this->expectExceptionMessage('JSX attributes must only be assigned a non-empty expression.');
+ Parser::parseSource('x = ;', 'module', new JsxOptions());
+ }
+
+ public function testAutomaticRuntimeNeedsModuleCode(): void
+ {
+ $this->expectException(SyntaxError::class);
+ $this->expectExceptionMessage('The automatic JSX runtime needs module code');
+ Parser::parseSource('x = ;', 'script', new JsxOptions());
+ }
+
+ public function testClassicRuntimeWorksInScripts(): void
+ {
+ $program = Parser::parseSource('x = ;', 'script', new JsxOptions(mode: JsxMode::Classic, factory: 'h'));
+ $this->assertCount(1, $program->body);
+ }
+
+ public function testInjectedImportsAvoidNamesUsedInTheFile(): void
+ {
+ $program = Parser::parseSource('const _jsx = 1; x = ;', 'module', new JsxOptions());
+ $import = $program->body[0];
+ $this->assertInstanceOf(\Phasis\Ast\Declaration\ImportDeclaration::class, $import);
+ $this->assertSame('_jsx2', $import->specifiers[0]->local);
+ }
+}
From 7927103b6a6b7b77fc03f11186fcaa41049d5be9 Mon Sep 17 00:00:00 2001
From: dnnsjsk
Date: Tue, 13 Oct 2026 19:41:30 +0200
Subject: [PATCH 05/18] Resolve imports like a bundler, run CommonJS, prune
barrels (Phases 3-4)
Module resolution follows bundler semantics so components import what
the client bundle imports, with no build step: import maps (null stubs),
built-in modules, package.json imports, jsconfig/tsconfig paths,
extension and index probing, and node_modules with exports conditions,
module and main fields. Style imports become empty modules; asset and
CSS-module imports go through host resolvers and fail without one.
CommonJS modules run in Node's wrapper with require, module.exports,
require cycles, JSON and require() of ES modules, and bundler __esModule
interop (Node's default-export rule for .mjs importers). process.env
.NODE_ENV is a parse-time define in ES modules and a minimal process
object in CommonJS. Side-effect-free barrels are pruned so one icon
import loads only that icon's module.
Engine gains setJsxOptions, setModuleOptions, setImportMap,
setAssetResolver, setCssModuleResolver and getExport; execFile routes
.mjs and JSX files through the module loader.
Oracles: a fixture app bundled by esbuild and run in Node (resolution)
and 10 package smoke tests rendered in Node.
Co-Authored-By: Claude Opus 5.5
---
src/Engine.php | 116 ++++
src/Module/BarrelPruner.php | 312 +++++++++
src/Module/CommonJsLoader.php | 137 ++++
src/Module/ModuleLoader.php | 477 +++++++++++--
src/Module/ModuleOptions.php | 52 ++
src/Module/ModuleRecord.php | 19 +
src/Module/ModuleResolver.php | 640 ++++++++++++++++++
tests/Jsx/JsxPackageSmokeTest.php | 38 ++
tests/Jsx/JsxResolutionOracleTest.php | 36 +
.../class-variance-authority.expected.txt | 1 +
.../oracle/smoke/class-variance-authority.mjs | 3 +
.../Jsx/oracle/smoke/classnames.expected.txt | 1 +
tests/Jsx/oracle/smoke/classnames.mjs | 3 +
tests/Jsx/oracle/smoke/clsx.expected.txt | 1 +
tests/Jsx/oracle/smoke/clsx.mjs | 2 +
tests/Jsx/oracle/smoke/date-fns.expected.txt | 1 +
tests/Jsx/oracle/smoke/date-fns.mjs | 3 +
tests/Jsx/oracle/smoke/dayjs.expected.txt | 1 +
tests/Jsx/oracle/smoke/dayjs.mjs | 4 +
tests/Jsx/oracle/smoke/lodash-es.expected.txt | 1 +
tests/Jsx/oracle/smoke/lodash-es.mjs | 2 +
tests/Jsx/oracle/smoke/lodash.expected.txt | 1 +
tests/Jsx/oracle/smoke/lodash.mjs | 3 +
.../Jsx/oracle/smoke/prop-types.expected.txt | 1 +
tests/Jsx/oracle/smoke/prop-types.mjs | 7 +
tests/Jsx/oracle/smoke/react-is.expected.txt | 1 +
tests/Jsx/oracle/smoke/react-is.mjs | 3 +
.../oracle/smoke/tailwind-merge.expected.txt | 1 +
tests/Jsx/oracle/smoke/tailwind-merge.mjs | 2 +
tests/Jsx/resolution/app.expected.txt | 26 +
tests/Jsx/resolution/app/jsconfig.json | 10 +
tests/Jsx/resolution/app/main.mjs | 49 ++
.../app/node_modules/@scope/pkg/main.js | 1 +
.../app/node_modules/@scope/pkg/package.json | 1 +
.../app/node_modules/barrel-pkg/alpha.js | 2 +
.../app/node_modules/barrel-pkg/beta.js | 2 +
.../app/node_modules/barrel-pkg/gamma.js | 2 +
.../app/node_modules/barrel-pkg/index.js | 3 +
.../app/node_modules/barrel-pkg/package.json | 1 +
.../app/node_modules/cjs-pkg/lib/index.js | 2 +
.../app/node_modules/cjs-pkg/package.json | 1 +
.../app/node_modules/cjs-requires/data.json | 1 +
.../app/node_modules/cjs-requires/index.js | 5 +
.../app/node_modules/cjs-requires/other.js | 1 +
.../node_modules/cjs-requires/package.json | 1 +
.../app/node_modules/cond-pkg/browser.js | 1 +
.../app/node_modules/cond-pkg/default.js | 1 +
.../app/node_modules/cond-pkg/node.js | 1 +
.../app/node_modules/cond-pkg/package.json | 2 +
.../app/node_modules/cond-pkg/phasis.js | 1 +
.../resolution/app/node_modules/cycle-a/a.js | 3 +
.../resolution/app/node_modules/cycle-a/b.js | 2 +
.../app/node_modules/cycle-a/package.json | 1 +
.../app/node_modules/detect-pkg/index.js | 2 +
.../app/node_modules/detect-pkg/package.json | 1 +
.../app/node_modules/dual-pkg/cjs.cjs | 1 +
.../app/node_modules/dual-pkg/esm.mjs | 1 +
.../app/node_modules/dual-pkg/package.json | 1 +
.../app/node_modules/esm-pkg/dist/feature.js | 1 +
.../node_modules/esm-pkg/dist/icons/star.js | 1 +
.../app/node_modules/esm-pkg/dist/index.cjs | 1 +
.../app/node_modules/esm-pkg/dist/index.js | 3 +
.../app/node_modules/esm-pkg/package.json | 6 +
.../app/node_modules/esmodule-flag/index.js | 4 +
.../node_modules/esmodule-flag/package.json | 1 +
.../app/node_modules/imports-field/index.js | 3 +
.../node_modules/imports-field/package.json | 1 +
.../imports-field/src/internal.js | 1 +
.../app/node_modules/module-field/cjs.js | 1 +
.../app/node_modules/module-field/esm.js | 1 +
.../node_modules/module-field/package.json | 1 +
.../app/node_modules/node-env/esm.mjs | 1 +
.../app/node_modules/node-env/index.js | 4 +
.../app/node_modules/node-env/package.json | 1 +
.../app/node_modules/prod-pkg/dev.js | 1 +
.../app/node_modules/prod-pkg/package.json | 1 +
.../app/node_modules/prod-pkg/prod.js | 1 +
.../Jsx/resolution/app/src/base-url-target.js | 1 +
.../resolution/app/src/components/Card.jsx | 2 +
.../app/src/components/List/index.js | 1 +
tests/Jsx/resolution/app/src/data.json | 1 +
tests/Jsx/resolution/app/src/logo.svg | 1 +
tests/Jsx/resolution/app/src/show.js | 3 +
tests/Jsx/resolution/app/src/style.css | 1 +
tests/Jsx/resolution/app/src/utils/index.js | 1 +
85 files changed, 1984 insertions(+), 56 deletions(-)
create mode 100644 src/Module/BarrelPruner.php
create mode 100644 src/Module/CommonJsLoader.php
create mode 100644 src/Module/ModuleOptions.php
create mode 100644 src/Module/ModuleResolver.php
create mode 100644 tests/Jsx/JsxPackageSmokeTest.php
create mode 100644 tests/Jsx/JsxResolutionOracleTest.php
create mode 100644 tests/Jsx/oracle/smoke/class-variance-authority.expected.txt
create mode 100644 tests/Jsx/oracle/smoke/class-variance-authority.mjs
create mode 100644 tests/Jsx/oracle/smoke/classnames.expected.txt
create mode 100644 tests/Jsx/oracle/smoke/classnames.mjs
create mode 100644 tests/Jsx/oracle/smoke/clsx.expected.txt
create mode 100644 tests/Jsx/oracle/smoke/clsx.mjs
create mode 100644 tests/Jsx/oracle/smoke/date-fns.expected.txt
create mode 100644 tests/Jsx/oracle/smoke/date-fns.mjs
create mode 100644 tests/Jsx/oracle/smoke/dayjs.expected.txt
create mode 100644 tests/Jsx/oracle/smoke/dayjs.mjs
create mode 100644 tests/Jsx/oracle/smoke/lodash-es.expected.txt
create mode 100644 tests/Jsx/oracle/smoke/lodash-es.mjs
create mode 100644 tests/Jsx/oracle/smoke/lodash.expected.txt
create mode 100644 tests/Jsx/oracle/smoke/lodash.mjs
create mode 100644 tests/Jsx/oracle/smoke/prop-types.expected.txt
create mode 100644 tests/Jsx/oracle/smoke/prop-types.mjs
create mode 100644 tests/Jsx/oracle/smoke/react-is.expected.txt
create mode 100644 tests/Jsx/oracle/smoke/react-is.mjs
create mode 100644 tests/Jsx/oracle/smoke/tailwind-merge.expected.txt
create mode 100644 tests/Jsx/oracle/smoke/tailwind-merge.mjs
create mode 100644 tests/Jsx/resolution/app.expected.txt
create mode 100644 tests/Jsx/resolution/app/jsconfig.json
create mode 100644 tests/Jsx/resolution/app/main.mjs
create mode 100644 tests/Jsx/resolution/app/node_modules/@scope/pkg/main.js
create mode 100644 tests/Jsx/resolution/app/node_modules/@scope/pkg/package.json
create mode 100644 tests/Jsx/resolution/app/node_modules/barrel-pkg/alpha.js
create mode 100644 tests/Jsx/resolution/app/node_modules/barrel-pkg/beta.js
create mode 100644 tests/Jsx/resolution/app/node_modules/barrel-pkg/gamma.js
create mode 100644 tests/Jsx/resolution/app/node_modules/barrel-pkg/index.js
create mode 100644 tests/Jsx/resolution/app/node_modules/barrel-pkg/package.json
create mode 100644 tests/Jsx/resolution/app/node_modules/cjs-pkg/lib/index.js
create mode 100644 tests/Jsx/resolution/app/node_modules/cjs-pkg/package.json
create mode 100644 tests/Jsx/resolution/app/node_modules/cjs-requires/data.json
create mode 100644 tests/Jsx/resolution/app/node_modules/cjs-requires/index.js
create mode 100644 tests/Jsx/resolution/app/node_modules/cjs-requires/other.js
create mode 100644 tests/Jsx/resolution/app/node_modules/cjs-requires/package.json
create mode 100644 tests/Jsx/resolution/app/node_modules/cond-pkg/browser.js
create mode 100644 tests/Jsx/resolution/app/node_modules/cond-pkg/default.js
create mode 100644 tests/Jsx/resolution/app/node_modules/cond-pkg/node.js
create mode 100644 tests/Jsx/resolution/app/node_modules/cond-pkg/package.json
create mode 100644 tests/Jsx/resolution/app/node_modules/cond-pkg/phasis.js
create mode 100644 tests/Jsx/resolution/app/node_modules/cycle-a/a.js
create mode 100644 tests/Jsx/resolution/app/node_modules/cycle-a/b.js
create mode 100644 tests/Jsx/resolution/app/node_modules/cycle-a/package.json
create mode 100644 tests/Jsx/resolution/app/node_modules/detect-pkg/index.js
create mode 100644 tests/Jsx/resolution/app/node_modules/detect-pkg/package.json
create mode 100644 tests/Jsx/resolution/app/node_modules/dual-pkg/cjs.cjs
create mode 100644 tests/Jsx/resolution/app/node_modules/dual-pkg/esm.mjs
create mode 100644 tests/Jsx/resolution/app/node_modules/dual-pkg/package.json
create mode 100644 tests/Jsx/resolution/app/node_modules/esm-pkg/dist/feature.js
create mode 100644 tests/Jsx/resolution/app/node_modules/esm-pkg/dist/icons/star.js
create mode 100644 tests/Jsx/resolution/app/node_modules/esm-pkg/dist/index.cjs
create mode 100644 tests/Jsx/resolution/app/node_modules/esm-pkg/dist/index.js
create mode 100644 tests/Jsx/resolution/app/node_modules/esm-pkg/package.json
create mode 100644 tests/Jsx/resolution/app/node_modules/esmodule-flag/index.js
create mode 100644 tests/Jsx/resolution/app/node_modules/esmodule-flag/package.json
create mode 100644 tests/Jsx/resolution/app/node_modules/imports-field/index.js
create mode 100644 tests/Jsx/resolution/app/node_modules/imports-field/package.json
create mode 100644 tests/Jsx/resolution/app/node_modules/imports-field/src/internal.js
create mode 100644 tests/Jsx/resolution/app/node_modules/module-field/cjs.js
create mode 100644 tests/Jsx/resolution/app/node_modules/module-field/esm.js
create mode 100644 tests/Jsx/resolution/app/node_modules/module-field/package.json
create mode 100644 tests/Jsx/resolution/app/node_modules/node-env/esm.mjs
create mode 100644 tests/Jsx/resolution/app/node_modules/node-env/index.js
create mode 100644 tests/Jsx/resolution/app/node_modules/node-env/package.json
create mode 100644 tests/Jsx/resolution/app/node_modules/prod-pkg/dev.js
create mode 100644 tests/Jsx/resolution/app/node_modules/prod-pkg/package.json
create mode 100644 tests/Jsx/resolution/app/node_modules/prod-pkg/prod.js
create mode 100644 tests/Jsx/resolution/app/src/base-url-target.js
create mode 100644 tests/Jsx/resolution/app/src/components/Card.jsx
create mode 100644 tests/Jsx/resolution/app/src/components/List/index.js
create mode 100644 tests/Jsx/resolution/app/src/data.json
create mode 100644 tests/Jsx/resolution/app/src/logo.svg
create mode 100644 tests/Jsx/resolution/app/src/show.js
create mode 100644 tests/Jsx/resolution/app/src/style.css
create mode 100644 tests/Jsx/resolution/app/src/utils/index.js
diff --git a/src/Engine.php b/src/Engine.php
index 74e8b384..d0366a94 100644
--- a/src/Engine.php
+++ b/src/Engine.php
@@ -146,9 +146,22 @@ public static function getCurrentSourceURL(): ?string
*/
private EventLoop $eventLoop;
+ private \Phasis\Parser\Jsx\JsxOptions $jsxOptions;
+
+ private \Phasis\Module\ModuleOptions $moduleOptions;
+
+ /** @var array */
+ private array $importMap = [];
+
+ private ?\Closure $assetResolver = null;
+
+ private ?\Closure $cssModuleResolver = null;
+
public function __construct(bool $eager = false)
{
$this->eager = $eager;
+ $this->jsxOptions = new \Phasis\Parser\Jsx\JsxOptions();
+ $this->moduleOptions = new \Phasis\Module\ModuleOptions();
$this->lazyRegistry = new LazyBuiltinRegistry();
// Clear any cached intrinsic prototypes from prior Engine instances
// so each realm gets its own wired object graph.
@@ -161,6 +174,7 @@ public function __construct(bool $eager = false)
self::$currentInterpreter = $this->interpreter;
$this->installBuiltins();
+ $this->configureModuleLoader();
// Global 'this' should be the global object
$objProto = $this->globalEnv->has('__ObjectPrototype__')
@@ -1717,6 +1731,12 @@ public function evalAsModule(string $source, ?string $path = null): mixed
public function execFile(string $path): mixed
{
+ // ES modules (.mjs) and JSX files go through the module loader, so
+ // their imports resolve. Like evalAsModule(), they return null.
+ if (str_ends_with(strtolower($path), '.mjs') || $this->jsxOptions->appliesTo($path)) {
+ $this->importModule($path);
+ return null;
+ }
$source = file_get_contents($path);
if ($source === false) {
throw new \RuntimeException("Cannot read file: {$path}");
@@ -1906,9 +1926,105 @@ public function reset(): void
$this->lazyRegistry = new LazyBuiltinRegistry();
$this->installBuiltins();
+ $this->configureModuleLoader();
$this->eventLoop = new EventLoop($this);
}
+ /**
+ * JSX settings for modules. By default files ending in `.jsx` parse
+ * with JSX, using the automatic runtime with `react` as import source.
+ */
+ public function setJsxOptions(\Phasis\Parser\Jsx\JsxOptions $options): void
+ {
+ $this->jsxOptions = $options;
+ $this->configureModuleLoader();
+ }
+
+ /** How module specifiers resolve and how style and asset imports load. */
+ public function setModuleOptions(\Phasis\Module\ModuleOptions $options): void
+ {
+ $this->moduleOptions = $options;
+ $this->configureModuleLoader();
+ }
+
+ /**
+ * Map specifiers to files, following the WHATWG import map `imports`
+ * table: exact keys, plus keys ending in `/` as prefixes. Values are
+ * absolute paths, or null to load the module as an empty stub.
+ *
+ * @param array $imports
+ */
+ public function setImportMap(array $imports): void
+ {
+ $this->importMap = $imports;
+ $this->configureModuleLoader();
+ }
+
+ /**
+ * Decide the URL an imported asset (image, font, ...) default-exports,
+ * for example by looking it up in the client build's manifest.
+ *
+ * @param callable(string): string $resolver Receives the asset's absolute path.
+ */
+ public function setAssetResolver(callable $resolver): void
+ {
+ $this->assetResolver = \Closure::fromCallable($resolver);
+ $this->configureModuleLoader();
+ }
+
+ /**
+ * Supply the class names of a CSS module, matching what the client
+ * bundler generates.
+ *
+ * @param callable(string): array $resolver Receives the CSS file's absolute path.
+ */
+ public function setCssModuleResolver(callable $resolver): void
+ {
+ $this->cssModuleResolver = \Closure::fromCallable($resolver);
+ $this->configureModuleLoader();
+ }
+
+ /**
+ * Load a module and return one of its exports, converted to PHP.
+ */
+ public function getExport(string $path, string $name = 'default'): mixed
+ {
+ $namespace = $this->importModule($path);
+ return $this->toPhp($namespace->get($name));
+ }
+
+ /** Load and evaluate a module from disk, returning its namespace object. */
+ private function importModule(string $path): JsObject
+ {
+ $resolved = realpath($path);
+ if ($resolved === false) {
+ throw new \RuntimeException("Cannot read module: {$path}");
+ }
+ $previousInterpreter = self::$currentInterpreter;
+ self::$currentInterpreter = $this->interpreter;
+ try {
+ $namespace = $this->interpreter->getModuleLoader()->loadModule($resolved);
+ $this->eventLoop->runUntilEmpty();
+ return $namespace;
+ } finally {
+ self::$currentInterpreter = $previousInterpreter;
+ }
+ }
+
+ private function configureModuleLoader(): void
+ {
+ $loader = $this->interpreter->getModuleLoader();
+ $loader->setJsxOptions($this->jsxOptions);
+ $loader->setModuleOptions($this->moduleOptions);
+ $loader->setImportMap($this->importMap);
+ if ($this->assetResolver !== null) {
+ $loader->setAssetResolver($this->assetResolver);
+ }
+ if ($this->cssModuleResolver !== null) {
+ $loader->setCssModuleResolver($this->cssModuleResolver);
+ }
+ }
+
/**
* Reset cached intrinsic prototypes shared across BuiltIn classes.
* Each new Engine instance must rebuild its own prototype graph so
diff --git a/src/Module/BarrelPruner.php b/src/Module/BarrelPruner.php
new file mode 100644
index 00000000..c2967d5e
--- /dev/null
+++ b/src/Module/BarrelPruner.php
@@ -0,0 +1,312 @@
+, stars: list}> */
+ private array $barrels = [];
+
+ /** @var array> */
+ private array $exportNames = [];
+
+ /** @var array */
+ private array $sideEffectFree = [];
+
+ /**
+ * @param \Closure(string): ?Program $parse Parses a module file, or returns null if it can't be read.
+ */
+ public function __construct(
+ private readonly ModuleResolver $resolver,
+ private readonly \Closure $parse,
+ ) {
+ }
+
+ public function rewrite(Program $program, string $importerPath): Program
+ {
+ $changed = false;
+ $body = [];
+ foreach ($program->body as $node) {
+ if ($node instanceof ImportDeclaration && $node->phase === 'evaluation' && $node->attributes === null) {
+ $rewritten = $this->rewriteImport($node, $importerPath);
+ if ($rewritten !== null) {
+ array_push($body, ...$rewritten);
+ $changed = true;
+ continue;
+ }
+ }
+ $body[] = $node;
+ }
+ return $changed ? new Program($program->location, $body) : $program;
+ }
+
+ /** @return ?list */
+ private function rewriteImport(ImportDeclaration $node, string $importerPath): ?array
+ {
+ if ($node->specifiers === []) {
+ return null;
+ }
+ try {
+ $target = $this->resolver->resolve($node->source, $importerPath);
+ } catch (\Throwable) {
+ return null;
+ }
+ if (!$this->isPrunableBarrel($target)) {
+ return null;
+ }
+ $imports = [];
+ foreach ($node->specifiers as $specifier) {
+ if ($specifier->specType === 'namespace') {
+ return null;
+ }
+ $name = $specifier->specType === 'default' ? 'default' : ($specifier->imported ?? $specifier->local);
+ $origin = $this->findOrigin($target, $name, 0);
+ if ($origin === null) {
+ return null;
+ }
+ [$module, $imported] = $origin;
+ $spec = match (true) {
+ $imported === '*' => new ImportSpecifier($specifier->location, 'namespace', $specifier->local),
+ $imported === 'default' => new ImportSpecifier($specifier->location, 'default', $specifier->local),
+ default => new ImportSpecifier($specifier->location, 'named', $specifier->local, $imported),
+ };
+ $imports[] = new ImportDeclaration($node->location, [$spec], $module);
+ }
+ return $imports;
+ }
+
+ /**
+ * The module and export name that $name of $module really comes from,
+ * following re-exports through side-effect-free barrels. An imported
+ * name of `*` means the whole namespace of that module.
+ *
+ * @return ?array{0: string, 1: string}
+ */
+ private function findOrigin(string $module, string $name, int $depth): ?array
+ {
+ if ($depth > 32 || !$this->isPrunableBarrel($module)) {
+ return $depth === 0 ? null : (isset($this->exportNamesOf($module)[$name]) ? [$module, $name] : null);
+ }
+ $barrel = $this->barrels[$module];
+ if ($barrel === null) {
+ return null;
+ }
+ if (isset($barrel['names'][$name])) {
+ [$source, $imported] = $barrel['names'][$name];
+ if ($imported === '*') {
+ return [$source, '*'];
+ }
+ return $this->findOrigin($source, $imported, $depth + 1) ?? [$source, $imported];
+ }
+ if ($name === 'default') {
+ return null;
+ }
+ foreach ($barrel['stars'] as $source) {
+ $origin = $this->findOrigin($source, $name, $depth + 1);
+ if ($origin !== null) {
+ return $origin;
+ }
+ }
+ return null;
+ }
+
+ private function isPrunableBarrel(string $path): bool
+ {
+ if (!str_starts_with($path, '/') || !$this->isSideEffectFree($path)) {
+ return false;
+ }
+ if (!array_key_exists($path, $this->barrels)) {
+ $this->barrels[$path] = $this->analyzeBarrel($path);
+ }
+ return $this->barrels[$path] !== null;
+ }
+
+ /**
+ * A barrel holds only re-exports: `export { a as b } from`, `export *
+ * from`, `export * as ns from`, and `import * as ns from` followed by
+ * `export { ns }`. Returns its export table, or null for anything else.
+ *
+ * @return ?array{names: array, stars: list}
+ */
+ private function analyzeBarrel(string $path): ?array
+ {
+ $program = ($this->parse)($path);
+ if ($program === null) {
+ return null;
+ }
+ $names = [];
+ $stars = [];
+ $namespaceImports = [];
+ foreach ($program->body as $node) {
+ if ($node instanceof ExpressionStatement && $node->expression instanceof Literal && is_string($node->expression->value)) {
+ continue;
+ }
+ if ($node instanceof ImportDeclaration) {
+ if (count($node->specifiers) !== 1 || $node->specifiers[0]->specType !== 'namespace') {
+ return null;
+ }
+ $namespaceImports[$node->specifiers[0]->local] = $this->resolveFrom($node->source, $path);
+ continue;
+ }
+ if (!$node instanceof ExportDeclaration || $node->declaration !== null || $node->isDefault) {
+ return null;
+ }
+ if ($node->isAll) {
+ $source = $this->resolveFrom((string) $node->source, $path);
+ if ($source === null) {
+ return null;
+ }
+ if ($node->allAs !== null) {
+ $names[$node->allAs] = [$source, '*'];
+ } else {
+ $stars[] = $source;
+ }
+ continue;
+ }
+ foreach ($node->specifiers as $specifier) {
+ $exported = $specifier->exported;
+ if ($node->source !== null) {
+ $source = $this->resolveFrom($node->source, $path);
+ if ($source === null) {
+ return null;
+ }
+ $names[$exported] = [$source, $specifier->local];
+ } elseif (isset($namespaceImports[$specifier->local])) {
+ // isset() is false for a namespace import that didn't resolve.
+ $names[$exported] = [$namespaceImports[$specifier->local], '*'];
+ } else {
+ return null;
+ }
+ }
+ }
+ return ['names' => $names, 'stars' => $stars];
+ }
+
+ private function resolveFrom(string $specifier, string $path): ?string
+ {
+ try {
+ $resolved = $this->resolver->resolve($specifier, $path);
+ } catch (\Throwable) {
+ return null;
+ }
+ return str_starts_with($resolved, '/') ? $resolved : null;
+ }
+
+ /**
+ * Export names of a module, from a token scan of its top level. Used to
+ * find which `export *` source provides a name without running anything.
+ *
+ * @return array
+ */
+ private function exportNamesOf(string $path): array
+ {
+ if (isset($this->exportNames[$path])) {
+ return $this->exportNames[$path];
+ }
+ $this->exportNames[$path] = [];
+ $source = @file_get_contents($path);
+ if ($source === false) {
+ return [];
+ }
+ try {
+ $lexer = new Lexer($source);
+ $lexer->setModuleMode(true);
+ $tokens = $lexer->tokenize();
+ } catch (\Throwable) {
+ return [];
+ }
+ $names = [];
+ $depth = 0;
+ $count = count($tokens);
+ for ($i = 0; $i < $count; $i++) {
+ $type = $tokens[$i]->type;
+ if ($type === TokenType::LeftBrace || $type === TokenType::TemplateHead) {
+ $depth++;
+ } elseif ($type === TokenType::RightBrace || $type === TokenType::TemplateTail) {
+ $depth--;
+ }
+ if ($depth !== 0 || $type !== TokenType::Export) {
+ continue;
+ }
+ $next = $tokens[$i + 1] ?? null;
+ if ($next === null) {
+ break;
+ }
+ if ($next->type === TokenType::Default_) {
+ $names['default'] = true;
+ } elseif (in_array($next->type, [TokenType::Function_, TokenType::Class_, TokenType::Const_, TokenType::Let, TokenType::Var], true)) {
+ $j = $i + 2;
+ if (($tokens[$j]->type ?? null) === TokenType::Star) {
+ $j++;
+ }
+ if (($tokens[$j]->type ?? null) === TokenType::Identifier) {
+ $names[$tokens[$j]->value] = true;
+ }
+ } elseif ($next->type === TokenType::Async && ($tokens[$i + 2]->type ?? null) === TokenType::Function_) {
+ if (($tokens[$i + 3]->type ?? null) === TokenType::Identifier) {
+ $names[$tokens[$i + 3]->value] = true;
+ }
+ } elseif ($next->type === TokenType::LeftBrace) {
+ for ($j = $i + 2; $j < $count && $tokens[$j]->type !== TokenType::RightBrace; $j++) {
+ $after = $tokens[$j + 1] ?? null;
+ if ($after !== null && ($after->type === TokenType::Comma || $after->type === TokenType::RightBrace)) {
+ $names[$tokens[$j]->value] = true;
+ }
+ }
+ } elseif ($next->type === TokenType::Star && ($tokens[$i + 2]->value ?? '') === 'as') {
+ $names[$tokens[$i + 3]->value ?? ''] = true;
+ }
+ }
+ return $this->exportNames[$path] = $names;
+ }
+
+ private function isSideEffectFree(string $path): bool
+ {
+ $scope = $this->resolver->packageScope($path);
+ if ($scope === null || !str_contains($path, '/node_modules/')) {
+ return false;
+ }
+ $key = $scope . "\0" . $path;
+ if (isset($this->sideEffectFree[$key])) {
+ return $this->sideEffectFree[$key];
+ }
+ $flag = ($this->resolver->readPackageJson($scope) ?? [])['sideEffects'] ?? true;
+ $result = false;
+ if ($flag === false) {
+ $result = true;
+ } elseif (is_array($flag)) {
+ $relative = substr($path, strlen($scope) + 1);
+ $result = true;
+ foreach ($flag as $pattern) {
+ $pattern = ltrim((string) $pattern, './');
+ if (fnmatch($pattern, $relative) || (!str_contains($pattern, '/') && fnmatch($pattern, basename($relative)))) {
+ $result = false;
+ break;
+ }
+ }
+ }
+ return $this->sideEffectFree[$key] = $result;
+ }
+}
diff --git a/src/Module/CommonJsLoader.php b/src/Module/CommonJsLoader.php
new file mode 100644
index 00000000..6a06c806
--- /dev/null
+++ b/src/Module/CommonJsLoader.php
@@ -0,0 +1,137 @@
+parse();
+ }
+
+ /**
+ * Run a parsed CommonJS module and return its `module` object. The module
+ * object is registered with the loader before the body runs, so require
+ * cycles see the partially filled exports, as in Node.
+ */
+ public function run(ModuleRecord $record, string $path, Program $program): JsObject
+ {
+ $module = new JsObject();
+ $exports = new JsObject();
+ $module->set('exports', $exports);
+ $module->set('id', new JsString($path));
+ $module->set('filename', new JsString($path));
+ $module->set('loaded', new JsBoolean(false));
+ $record->commonJsModule = $module;
+
+ $previousStrict = $this->interpreter->isStrictMode();
+ $this->interpreter->setStrictMode(false);
+ try {
+ $wrapper = $this->interpreter->execute($program);
+ } finally {
+ $this->interpreter->setStrictMode($previousStrict);
+ }
+ if (!$wrapper instanceof JsFunction) {
+ throw new TypeError("CommonJS wrapper for '{$path}' did not produce a function");
+ }
+ $this->interpreter->callFunction($wrapper, $exports, [
+ $exports,
+ $this->makeRequire($path),
+ $module,
+ new JsString($path),
+ new JsString(dirname($path)),
+ $this->process(),
+ ]);
+ $module->set('loaded', new JsBoolean(true));
+ return $module;
+ }
+
+ /**
+ * Fill an ES-module namespace from `module.exports`, following bundler
+ * interop: the default export is `module.exports`, or its `default`
+ * property when `__esModule` is set; every own enumerable property is
+ * also a named export.
+ */
+ public static function populateNamespace(JsObject $namespace, JsValue $exports): void
+ {
+ $default = $exports;
+ if ($exports instanceof JsObject && $exports->get('__esModule')->toBoolean()) {
+ $default = $exports->get('default');
+ }
+ $namespace->defineOwnProperty('default', PropertyDescriptor::data($default, true, true, false));
+ if (!$exports instanceof JsObject) {
+ return;
+ }
+ foreach ($exports->getOwnEnumerableKeys() as $key) {
+ $key = (string) $key;
+ if ($key === 'default') {
+ continue;
+ }
+ $namespace->defineOwnProperty($key, PropertyDescriptor::data($exports->get($key), true, true, false));
+ }
+ }
+
+ private function makeRequire(string $path): JsFunction
+ {
+ $loader = $this->loader;
+ $require = JsFunction::fromCallable('require', static function (JsValue $this_, array $args) use ($loader, $path): JsValue {
+ $specifier = ($args[0] ?? JsUndefined::instance())->toJsString();
+ return $loader->requireModule($specifier, $path);
+ }, 1);
+ $require->set('resolve', JsFunction::fromCallable('resolve', static function (JsValue $this_, array $args) use ($loader, $path): JsValue {
+ $specifier = ($args[0] ?? JsUndefined::instance())->toJsString();
+ return new JsString($loader->resolve($specifier, $path, 'require'));
+ }, 1));
+ return $require;
+ }
+
+ /** The minimal `process` CommonJS modules see: only `env.NODE_ENV`. */
+ private function process(): JsObject
+ {
+ if ($this->process === null) {
+ $env = new JsObject();
+ $env->set('NODE_ENV', new JsString($this->loader->moduleOptions()->nodeEnv));
+ $this->process = new JsObject();
+ $this->process->set('env', $env);
+ }
+ return $this->process;
+ }
+}
diff --git a/src/Module/ModuleLoader.php b/src/Module/ModuleLoader.php
index c65e73a8..43386753 100644
--- a/src/Module/ModuleLoader.php
+++ b/src/Module/ModuleLoader.php
@@ -12,6 +12,7 @@
use Phasis\Ast\Declaration\ClassDeclaration;
use Phasis\Ast\Node;
use Phasis\Object\PropertyDescriptor;
+use Phasis\Parser\Jsx\JsxOptions;
use Phasis\Parser\Parser;
use Phasis\Runtime\Environment;
use Phasis\Runtime\Interpreter;
@@ -59,10 +60,101 @@ class ModuleLoader
private Interpreter $interpreter;
private Environment $globalEnv;
+ private ModuleResolver $resolver;
+
+ private ?CommonJsLoader $commonJs = null;
+
+ private ?BarrelPruner $barrelPruner = null;
+
+ private ?JsxOptions $jsxOptions = null;
+
+ /** @var array> Built-in module export factories by specifier. */
+ private array $builtins = [];
+
+ /** @var ?\Closure(string): string */
+ private ?\Closure $assetResolver = null;
+
+ /** @var ?\Closure(string): array */
+ private ?\Closure $cssModuleResolver = null;
+
public function __construct(Interpreter $interpreter, Environment $globalEnv)
{
$this->interpreter = $interpreter;
$this->globalEnv = $globalEnv;
+ $builtins = &$this->builtins;
+ $this->resolver = new ModuleResolver(
+ new ModuleOptions(),
+ static fn (string $specifier): bool => isset($builtins[$specifier]),
+ );
+ }
+
+ /** Parse modules whose path matches the options' extensions with JSX. */
+ public function setJsxOptions(?JsxOptions $options): void
+ {
+ $this->jsxOptions = $options;
+ }
+
+ public function jsxOptions(): ?JsxOptions
+ {
+ return $this->jsxOptions;
+ }
+
+ public function setModuleOptions(ModuleOptions $options): void
+ {
+ $this->resolver->setOptions($options);
+ }
+
+ public function moduleOptions(): ModuleOptions
+ {
+ return $this->resolver->options();
+ }
+
+ /**
+ * @param array $imports
+ */
+ public function setImportMap(array $imports): void
+ {
+ $this->resolver->setImportMap($imports);
+ }
+
+ /**
+ * @param \Closure(string): string $resolver Absolute asset path => URL.
+ */
+ public function setAssetResolver(\Closure $resolver): void
+ {
+ $this->assetResolver = $resolver;
+ }
+
+ /**
+ * @param \Closure(string): array $resolver Absolute CSS-module path => class-name map.
+ */
+ public function setCssModuleResolver(\Closure $resolver): void
+ {
+ $this->cssModuleResolver = $resolver;
+ }
+
+ /**
+ * Register a module that exists only in the engine (no file), such as
+ * `react`. The factory runs once per engine, on first import, and
+ * returns the module's exports by name.
+ *
+ * @param \Closure(): array $factory
+ */
+ public function registerBuiltinModule(string $specifier, \Closure $factory): void
+ {
+ $this->builtins[$specifier] = $factory;
+ $this->resolver->clearCache();
+ }
+
+ public function resolver(): ModuleResolver
+ {
+ return $this->resolver;
+ }
+
+ /** The record of a loaded module, by resolved key. */
+ public function record(string $resolved): ?ModuleRecord
+ {
+ return $this->modules[$resolved] ?? null;
}
/**
@@ -146,10 +238,7 @@ public function loadModuleForDeferred(string $specifier, ?string $referrer = nul
*/
private function loadModuleInternal(string $resolved, string $specifier): JsObject
{
- $source = @file_get_contents($resolved);
- if ($source === false) {
- throw new \Phasis\Exceptions\TypeError("Cannot find module '{$specifier}'");
- }
+ $source = $this->readSource($resolved, $specifier);
// Nested loads issued during linking (from finalizeExports /
// processImports) just link and return; the outer call owns
@@ -346,23 +435,17 @@ private function linkModule(string $absolutePath, string $source): JsObject
$record = new ModuleRecord($absolutePath, $namespace);
$this->modules[$absolutePath] = $record;
+ $format = $this->moduleFormat($absolutePath);
+
// JSON modules: per the import attributes / json-modules proposal,
// a .json source becomes a synthetic module whose only export is
// `default`, holding the parsed JSON value.
- if (str_ends_with(strtolower($absolutePath), '.json')) {
+ if ($format === 'json') {
$parsed = \Phasis\BuiltIn\JsonObject::parseSource($source);
- $namespace->defineOwnProperty(
- 'default',
- PropertyDescriptor::data($parsed, false, true, false),
- );
- $namespace->setPrototype(null);
- $toStringTagSym = \Phasis\BuiltIn\SymbolConstructor::toStringTag();
- $namespace->definePropertyBySymbol(
- $toStringTagSym,
- PropertyDescriptor::data(new JsString('Module'), false, false, false),
- );
- $record->exportsFinalized = true;
- return $namespace;
+ return $this->finishSyntheticModule($record, ['default' => $parsed], false);
+ }
+ if ($format !== 'esm' && $format !== 'detect') {
+ return $this->linkSyntheticModule($record, $absolutePath, $format, $source);
}
// Detect circular dependency.
@@ -373,9 +456,14 @@ private function linkModule(string $absolutePath, string $source): JsObject
$prevModulePath = $this->interpreter->getCurrentModulePath();
try {
- $parser = new Parser($source);
- $parser->setModuleMode(true);
- $program = $parser->parse();
+ if ($format === 'detect') {
+ $program = $this->detectFormat($record, $absolutePath, $source);
+ if ($program === null) {
+ return $namespace;
+ }
+ } else {
+ $program = $this->parseModule($record, $absolutePath, $source);
+ }
// Detect top-level await before linking dependencies. The
// markEagerlyReachable walk needs the flag so a deferred
@@ -687,6 +775,10 @@ private function runDeferredBody(ModuleRecord $record, array $pending, array &$a
$prevModulePath = $this->interpreter->getCurrentModulePath();
$this->interpreter->setCurrentModulePath($record->path);
try {
+ if ($record->commonJsProgram !== null) {
+ $this->runCommonJs($record);
+ return;
+ }
$this->interpreter->executeModuleBody(
$pending['program']->body,
$pending['moduleEnv'],
@@ -1028,6 +1120,10 @@ public function evaluateModuleBodyOnDemand(ModuleRecord $record): void
$this->evaluateModuleBodyOnDemand($dep);
}
}
+ if ($record->commonJsProgram !== null) {
+ $this->runCommonJs($record);
+ return;
+ }
$pending = $record->pendingBody;
$record->pendingBody = null;
$record->bodyEvaluated = true;
@@ -1078,6 +1174,10 @@ public function evaluateModuleBodyOnDemand(ModuleRecord $record): void
private function lockDownNamespaces(): void
{
foreach ($this->modules as $record) {
+ if ($record->namespaceOpen) {
+ // Filled in and locked once the CommonJS module has run.
+ continue;
+ }
$record->namespace->markAsModuleNamespace();
$record->namespace->preventExtensions();
}
@@ -1234,6 +1334,9 @@ private function resolveExport(string $modulePath, string $exportName, array $re
if ($record === null) {
return null;
}
+ if ($record->permissiveExports) {
+ return ['module' => $modulePath, 'bindingName' => $exportName];
+ }
// Indirect re-export: recurse through the source module.
if (isset($record->indirectExports[$exportName])) {
@@ -1281,50 +1384,73 @@ private function resolveExport(string $modulePath, string $exportName, array $re
/**
* Resolve a module specifier to an absolute path.
*/
- public function resolve(string $specifier, ?string $referrer = null): string
+ /**
+ * @param 'import'|'require' $kind
+ */
+ public function resolve(string $specifier, ?string $referrer = null, string $kind = 'import'): string
{
- // If the specifier is already an absolute path.
- if ($specifier !== '' && $specifier[0] === '/') {
- return $this->normalizeModulePath($specifier);
- }
+ return $this->resolver->resolve($specifier, $referrer, $kind);
+ }
- // Relative path: resolve relative to referrer.
- if ($specifier !== '' && ($specifier[0] === '.' || str_starts_with($specifier, './'))) {
- if ($referrer === null) {
- throw new \Phasis\Exceptions\TypeError(
- "Cannot resolve relative module specifier '{$specifier}' without a referrer",
- );
+ /**
+ * `require()` from a CommonJS module: returns `module.exports` for
+ * CommonJS, the namespace object for ES modules and built-ins.
+ */
+ public function requireModule(string $specifier, string $referrer): JsValue
+ {
+ $resolved = $this->resolve($specifier, $referrer, 'require');
+ $record = $this->modules[$resolved] ?? null;
+ if ($record === null) {
+ // require() runs in the middle of another module's evaluation,
+ // so link the new graph into the current one and evaluate it on
+ // demand instead of starting a fresh top-level load, which would
+ // take over the body queue of the run in progress.
+ $source = $this->readSource($resolved, $specifier);
+ $wasLinking = $this->linking;
+ $this->linking = true;
+ try {
+ $this->linkModule($resolved, $source);
+ if (!$wasLinking) {
+ $this->finalizeStarReExports();
+ $this->lockDownNamespaces();
+ $this->validateIndirectExports();
+ }
+ } finally {
+ $this->linking = $wasLinking;
+ }
+ $record = $this->modules[$resolved];
+ if (!$wasLinking && $record->commonJsProgram === null && $record->pendingBody !== null) {
+ $this->evaluateModuleBodyOnDemand($record);
}
- $base = dirname($referrer);
- return $this->normalizeModulePath($base . '/' . $specifier);
}
-
- // Bare specifiers: not supported in our simple loader.
- throw new \Phasis\Exceptions\TypeError(
- "Cannot resolve module specifier '{$specifier}'",
- );
+ if ($record->commonJsProgram !== null) {
+ $this->runCommonJs($record);
+ }
+ if ($record->commonJsModule !== null) {
+ return $record->commonJsModule->get('exports');
+ }
+ if (str_ends_with(strtolower($resolved), '.json')) {
+ return $record->namespace->get('default');
+ }
+ if (!$record->bodyEvaluated && $record->pendingBody !== null && !$record->bodyEvaluating) {
+ $this->evaluateModuleBodyOnDemand($record);
+ }
+ if ($record->evaluationError !== null) {
+ throw new \Phasis\Exceptions\JsThrowable($record->evaluationError);
+ }
+ return $record->namespace;
}
- private function normalizeModulePath(string $path): string
+ private function readSource(string $resolved, string $specifier): string
{
- $realpath = realpath($path);
- if ($realpath !== false) {
- return $realpath;
- }
- // If realpath fails, use the path as-is after resolving . and ..
- $parts = explode('/', $path);
- $stack = [];
- foreach ($parts as $part) {
- if ($part === '' || $part === '.') {
- continue;
- }
- if ($part === '..') {
- array_pop($stack);
- } else {
- $stack[] = $part;
- }
+ if (str_starts_with($resolved, ModuleResolver::BUILTIN) || str_starts_with($resolved, ModuleResolver::STUB)) {
+ return '';
+ }
+ $source = @file_get_contents($resolved);
+ if ($source === false) {
+ throw new \Phasis\Exceptions\TypeError("Cannot find module '{$specifier}'");
}
- return '/' . implode('/', $stack);
+ return $source;
}
/**
@@ -1454,6 +1580,19 @@ private function processImportDeclaration(
'exportName' => $exportName,
];
}
+ $sourceRecord = $this->modules[$resolved] ?? null;
+ if (
+ $exportName === 'default'
+ && $sourceRecord !== null
+ && $sourceRecord->namespaceOpen
+ && $this->isNodeModeImporter($modulePath)
+ ) {
+ $resolver = static function () use ($sourceRecord, $importedNs): JsValue {
+ return $sourceRecord->commonJsModule?->get('exports') ?? $importedNs->get('default');
+ };
+ $moduleEnv->defineImportBinding($spec->local, $resolver);
+ continue;
+ }
$resolver = static function () use ($importedNs, $exportName): JsValue {
return $importedNs->get($exportName);
};
@@ -1762,6 +1901,232 @@ private function nodeContainsTopLevelAwait(mixed $node): bool
/**
* Reset the module cache (useful for testing).
*/
+ /**
+ * How a resolved module loads: 'esm', 'json', 'commonjs', 'detect'
+ * (CommonJS unless it has ES module syntax), 'builtin', 'stub', 'style',
+ * 'css-module' or 'asset'.
+ */
+ private function moduleFormat(string $path): string
+ {
+ if (str_starts_with($path, ModuleResolver::BUILTIN)) {
+ return 'builtin';
+ }
+ if (str_starts_with($path, ModuleResolver::STUB)) {
+ return 'stub';
+ }
+ $lower = strtolower($path);
+ if (str_ends_with($lower, '.json')) {
+ return 'json';
+ }
+ $options = $this->resolver->options();
+ foreach ($options->styleExtensions as $extension) {
+ if (str_ends_with($lower, $extension)) {
+ return str_ends_with($lower, '.module' . $extension) ? 'css-module' : 'style';
+ }
+ }
+ foreach ($options->assetExtensions as $extension) {
+ if (str_ends_with($lower, $extension)) {
+ return 'asset';
+ }
+ }
+ if (str_ends_with($lower, '.cjs')) {
+ return 'commonjs';
+ }
+ if (str_ends_with($lower, '.mjs') || ($this->jsxOptions !== null && $this->jsxOptions->appliesTo($path))) {
+ return 'esm';
+ }
+ // Application code is ES modules, as bundlers treat it. Inside
+ // node_modules, package.json "type" decides, with Node's syntax
+ // detection when it is absent.
+ if (!str_contains($path, '/node_modules/')) {
+ return 'esm';
+ }
+ if (str_ends_with($lower, '.js')) {
+ $scope = $this->resolver->packageScope($path);
+ $type = $scope === null ? null : ($this->resolver->readPackageJson($scope)['type'] ?? null);
+ if ($type === 'module') {
+ return 'esm';
+ }
+ return 'detect';
+ }
+ return 'esm';
+ }
+
+ private function parseModule(ModuleRecord $record, string $path, string $source): \Phasis\Ast\Program
+ {
+ $parser = new Parser($source);
+ $parser->setModuleMode(true);
+ $parser->setDefines(['process.env.NODE_ENV' => $this->resolver->options()->nodeEnv]);
+ if ($this->jsxOptions !== null && $this->jsxOptions->appliesTo($path)) {
+ $parser->setJsxOptions($this->jsxOptions);
+ }
+ $program = $parser->parse();
+ $record->jsxImportSource = $parser->jsxImportSource();
+ return $this->barrelPruner()->rewrite($program, $path);
+ }
+
+ private function barrelPruner(): BarrelPruner
+ {
+ $nodeEnv = fn (): string => $this->resolver->options()->nodeEnv;
+ return $this->barrelPruner ??= new BarrelPruner(
+ $this->resolver,
+ static function (string $path) use ($nodeEnv): ?\Phasis\Ast\Program {
+ $source = @file_get_contents($path);
+ if ($source === false) {
+ return null;
+ }
+ try {
+ $parser = new Parser($source);
+ $parser->setModuleMode(true);
+ $parser->setDefines(['process.env.NODE_ENV' => $nodeEnv()]);
+ return $parser->parse();
+ } catch (\Phasis\Exceptions\SyntaxError) {
+ return null;
+ }
+ },
+ );
+ }
+
+ /**
+ * Whether an importer gets Node's CommonJS interop (default import is
+ * the whole `module.exports`) rather than the `__esModule` convention:
+ * true for `.mjs` files and for `.js`/`.jsx` files in a package whose
+ * package.json says "type": "module", as esbuild decides it.
+ */
+ private function isNodeModeImporter(string $path): bool
+ {
+ $lower = strtolower($path);
+ if (str_ends_with($lower, '.mjs')) {
+ return true;
+ }
+ if (!str_ends_with($lower, '.js') && !str_ends_with($lower, '.jsx')) {
+ return false;
+ }
+ $scope = $this->resolver->packageScope($path);
+ return $scope !== null && (($this->resolver->readPackageJson($scope) ?? [])['type'] ?? null) === 'module';
+ }
+
+ /**
+ * A package file without "type": CommonJS unless it only parses as an
+ * ES module. Returns the ES module program, or null after linking the
+ * file as CommonJS.
+ */
+ private function detectFormat(ModuleRecord $record, string $path, string $source): ?\Phasis\Ast\Program
+ {
+ try {
+ $program = $this->commonJs()->parse($source);
+ } catch (\Phasis\Exceptions\SyntaxError $commonJsError) {
+ try {
+ $program = $this->parseModule($record, $path, $source);
+ } catch (\Phasis\Exceptions\SyntaxError) {
+ throw $commonJsError;
+ }
+ foreach ($program->body as $node) {
+ if ($node instanceof ImportDeclaration || $node instanceof ExportDeclaration) {
+ return $program;
+ }
+ }
+ throw $commonJsError;
+ }
+ $this->linkCommonJs($record, $path, $program);
+ return null;
+ }
+
+ private function commonJs(): CommonJsLoader
+ {
+ return $this->commonJs ??= new CommonJsLoader($this, $this->interpreter);
+ }
+
+ /**
+ * Link a CommonJS module. It runs later, in body-evaluation order like
+ * an ES module (or immediately when required), so evaluation order
+ * matches Node and bundlers. Its namespace stays open until then.
+ */
+ private function linkCommonJs(ModuleRecord $record, string $path, \Phasis\Ast\Program $program): JsObject
+ {
+ $record->commonJsProgram = $program;
+ $record->namespaceOpen = true;
+ $record->pendingBody = ['program' => $program, 'moduleEnv' => $this->globalEnv, 'prevModulePath' => null];
+ $this->bodyQueue[] = $path;
+ return $this->finishSyntheticModule($record, [], true);
+ }
+
+ private function runCommonJs(ModuleRecord $record): void
+ {
+ $program = $record->commonJsProgram;
+ if ($program === null) {
+ return;
+ }
+ $record->commonJsProgram = null;
+ $record->pendingBody = null;
+ $record->bodyEvaluated = true;
+ try {
+ $module = $this->commonJs()->run($record, $record->path, $program);
+ } catch (\Phasis\Exceptions\JsThrowable $e) {
+ $record->evaluationError = $e->jsValue;
+ throw $e;
+ }
+ CommonJsLoader::populateNamespace($record->namespace, $module->get('exports'));
+ $record->namespaceOpen = false;
+ $record->namespace->markAsModuleNamespace();
+ $record->namespace->preventExtensions();
+ }
+
+ private function linkSyntheticModule(ModuleRecord $record, string $path, string $format, string $source): JsObject
+ {
+ switch ($format) {
+ case 'commonjs':
+ unset($this->evaluating[$path]);
+ return $this->linkCommonJs($record, $path, $this->commonJs()->parse($source));
+ case 'builtin':
+ $specifier = substr($path, strlen(ModuleResolver::BUILTIN));
+ $factory = $this->builtins[$specifier] ?? null;
+ if ($factory === null) {
+ throw new \Phasis\Exceptions\TypeError("Unknown built-in module '{$specifier}'");
+ }
+ return $this->finishSyntheticModule($record, $factory(), false);
+ case 'stub':
+ case 'style':
+ return $this->finishSyntheticModule($record, [], true);
+ case 'css-module':
+ if ($this->cssModuleResolver === null) {
+ throw new \Phasis\Exceptions\TypeError(
+ "CSS module '{$path}' needs the class names your client bundler generates; "
+ . 'configure Engine::setCssModuleResolver() or use plain class names',
+ );
+ }
+ $classes = ($this->cssModuleResolver)($path);
+ return $this->finishSyntheticModule($record, ['default' => \Phasis\Interop\PhpToJs::convert($classes)], true);
+ case 'asset':
+ if ($this->assetResolver === null) {
+ throw new \Phasis\Exceptions\TypeError(
+ "Importing asset '{$path}' needs a URL; configure Engine::setAssetResolver()",
+ );
+ }
+ return $this->finishSyntheticModule($record, ['default' => new JsString(($this->assetResolver)($path))], false);
+ }
+ throw new \Phasis\Exceptions\TypeError("No loader for module '{$path}'");
+ }
+
+ /**
+ * @param array $exports
+ */
+ private function finishSyntheticModule(ModuleRecord $record, array $exports, bool $permissive): JsObject
+ {
+ $namespace = $record->namespace;
+ foreach ($exports as $name => $value) {
+ $namespace->defineOwnProperty((string) $name, PropertyDescriptor::data($value, false, true, false));
+ }
+ $namespace->setPrototype(null);
+ $namespace->definePropertyBySymbol(
+ \Phasis\BuiltIn\SymbolConstructor::toStringTag(),
+ PropertyDescriptor::data(new JsString('Module'), false, false, false),
+ );
+ $record->permissiveExports = $record->permissiveExports || $permissive;
+ $record->exportsFinalized = true;
+ return $namespace;
+ }
+
public function reset(): void
{
$this->modules = [];
diff --git a/src/Module/ModuleOptions.php b/src/Module/ModuleOptions.php
new file mode 100644
index 00000000..025147a8
--- /dev/null
+++ b/src/Module/ModuleOptions.php
@@ -0,0 +1,52 @@
+ $extensions Tried in order for paths without an extension and for index files.
+ * @param list $conditions Active package.json conditions, besides "import" or "require" and "default".
+ * @param string $nodeEnv The value process.env.NODE_ENV reads as; also selects the production/development condition.
+ * @param list $styleExtensions Imports with these extensions load as empty modules.
+ * @param list $assetExtensions Imports with these extensions default-export a URL from the asset resolver.
+ */
+ public function __construct(
+ public ?string $projectRoot = null,
+ public array $extensions = ['.jsx', '.js', '.mjs', '.cjs', '.json'],
+ public array $conditions = ['phasis', 'module', 'production'],
+ public string $nodeEnv = 'production',
+ public array $styleExtensions = ['.css', '.scss', '.sass', '.less', '.styl'],
+ public array $assetExtensions = [
+ '.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.ico', '.bmp',
+ '.woff', '.woff2', '.ttf', '.otf', '.eot', '.mp4', '.webm', '.mp3', '.wav', '.ogg',
+ ],
+ ) {
+ }
+
+ /**
+ * Active conditions for `import` or `require`, with the production
+ * condition following $nodeEnv.
+ *
+ * @return array
+ */
+ public function activeConditions(string $kind): array
+ {
+ $active = [$kind => true, 'default' => true];
+ foreach ($this->conditions as $condition) {
+ if ($condition === 'production' || $condition === 'development') {
+ $condition = $this->nodeEnv === 'production' ? 'production' : 'development';
+ }
+ $active[$condition] = true;
+ }
+ return $active;
+ }
+}
diff --git a/src/Module/ModuleRecord.php b/src/Module/ModuleRecord.php
index d3bdefaf..16c22c3d 100644
--- a/src/Module/ModuleRecord.php
+++ b/src/Module/ModuleRecord.php
@@ -11,6 +11,25 @@
*/
class ModuleRecord
{
+ /**
+ * True for modules whose named exports aren't known statically
+ * (CommonJS, stubs). Named imports from them never fail to link;
+ * missing names read as undefined, as bundlers do.
+ */
+ public bool $permissiveExports = false;
+
+ /** A linked CommonJS module that hasn't run yet; it runs in body-evaluation order. */
+ public ?\Phasis\Ast\Program $commonJsProgram = null;
+
+ /** True until a CommonJS module has run and its exports are copied onto the namespace. */
+ public bool $namespaceOpen = false;
+
+ /** The `module` object of a CommonJS module, holding `exports`. */
+ public ?JsObject $commonJsModule = null;
+
+ /** JSX import source in effect for this module, or null when it has no JSX. */
+ public ?string $jsxImportSource = null;
+
/**
* @var array Map of star-exported name → source module
* path that contributed it via `export * from`.
diff --git a/src/Module/ModuleResolver.php b/src/Module/ModuleResolver.php
new file mode 100644
index 00000000..4258fec2
--- /dev/null
+++ b/src/Module/ModuleResolver.php
@@ -0,0 +1,640 @@
+` / `stub:` for synthetic modules.
+ */
+final class ModuleResolver
+{
+ public const BUILTIN = 'builtin:';
+ public const STUB = 'stub:';
+
+ /** @var array */
+ private array $importMap = [];
+
+ /** @var array */
+ private array $cache = [];
+
+ /** @var array> package.json contents by directory */
+ private array $packageJson = [];
+
+ /** @var array>}> */
+ private array $aliasConfig = [];
+
+ /**
+ * @param \Closure(string): bool $isBuiltin
+ */
+ public function __construct(
+ private ModuleOptions $options,
+ private readonly \Closure $isBuiltin,
+ ) {
+ }
+
+ public function options(): ModuleOptions
+ {
+ return $this->options;
+ }
+
+ public function setOptions(ModuleOptions $options): void
+ {
+ $this->options = $options;
+ $this->clearCache();
+ }
+
+ /**
+ * @param array $imports Specifier (or `/`-terminated prefix) => absolute path, or null for a stub.
+ */
+ public function setImportMap(array $imports): void
+ {
+ $this->importMap = $imports;
+ $this->clearCache();
+ }
+
+ public function clearCache(): void
+ {
+ $this->cache = [];
+ $this->packageJson = [];
+ $this->aliasConfig = [];
+ }
+
+ /**
+ * @param 'import'|'require' $kind
+ */
+ public function resolve(string $specifier, ?string $referrer, string $kind = 'import'): string
+ {
+ if (str_starts_with($specifier, self::BUILTIN) || str_starts_with($specifier, self::STUB)) {
+ return $specifier;
+ }
+ $cacheKey = $kind . "\0" . ($referrer === null ? '' : dirname($referrer)) . "\0" . $specifier;
+ if (isset($this->cache[$cacheKey])) {
+ return $this->cache[$cacheKey];
+ }
+ return $this->cache[$cacheKey] = $this->resolveUncached($specifier, $referrer, $kind);
+ }
+
+ /** The directory of the nearest package.json at or above $path, or null. */
+ public function packageScope(string $path): ?string
+ {
+ $dir = is_dir($path) ? $path : dirname($path);
+ while (true) {
+ if (is_file($dir . '/package.json')) {
+ return $dir;
+ }
+ $parent = dirname($dir);
+ if ($parent === $dir) {
+ return null;
+ }
+ $dir = $parent;
+ }
+ }
+
+ /** @return array|null */
+ public function readPackageJson(string $dir): ?array
+ {
+ if (array_key_exists($dir, $this->packageJson)) {
+ return $this->packageJson[$dir];
+ }
+ $file = $dir . '/package.json';
+ $data = null;
+ if (is_file($file)) {
+ $decoded = json_decode((string) file_get_contents($file), true);
+ $data = is_array($decoded) ? $decoded : null;
+ }
+ return $this->packageJson[$dir] = $data;
+ }
+
+ /**
+ * @param 'import'|'require' $kind
+ */
+ private function resolveUncached(string $specifier, ?string $referrer, string $kind): string
+ {
+ $mapped = $this->resolveImportMap($specifier);
+ if ($mapped !== false) {
+ if ($mapped === null) {
+ return self::STUB . $specifier;
+ }
+ return $this->resolveFile($mapped) ?? throw $this->notFound($specifier, $referrer);
+ }
+
+ if (($this->isBuiltin)($specifier)) {
+ return self::BUILTIN . $specifier;
+ }
+
+ if (str_starts_with($specifier, 'node:')) {
+ throw new TypeError(
+ "Cannot load Node built-in module '{$specifier}'" . self::from($referrer)
+ . '; stub it through the import map if it is not used while rendering',
+ );
+ }
+
+ if (str_starts_with($specifier, '#')) {
+ if ($referrer !== null) {
+ $resolved = $this->resolvePackageImports($specifier, $referrer, $kind);
+ if ($resolved !== null) {
+ return $resolved;
+ }
+ }
+ throw $this->notFound($specifier, $referrer);
+ }
+
+ if ($specifier !== '' && ($specifier[0] === '/' || $specifier[0] === '.')) {
+ if ($specifier[0] === '.') {
+ if ($referrer === null) {
+ throw new TypeError("Cannot resolve relative module specifier '{$specifier}' without a referrer");
+ }
+ $path = dirname($referrer) . '/' . $specifier;
+ } else {
+ $path = $specifier;
+ }
+ return $this->resolveFile(self::normalize($path)) ?? throw $this->notFound($specifier, $referrer);
+ }
+
+ $alias = $this->resolveAlias($specifier, $referrer);
+ if ($alias !== null) {
+ return $alias;
+ }
+
+ $package = $this->resolvePackage($specifier, $referrer, $kind);
+ if ($package !== null) {
+ return $package;
+ }
+ if (self::isNodeBuiltinName($specifier)) {
+ throw new TypeError(
+ "Cannot load Node built-in module '{$specifier}'" . self::from($referrer)
+ . '; stub it through the import map if it is not used while rendering',
+ );
+ }
+ throw $this->notFound($specifier, $referrer);
+ }
+
+ /** @return string|null|false The mapped path, null for a stub, false when the import map has no entry. */
+ private function resolveImportMap(string $specifier): string|null|false
+ {
+ if (array_key_exists($specifier, $this->importMap)) {
+ return $this->importMap[$specifier];
+ }
+ $best = null;
+ foreach ($this->importMap as $key => $target) {
+ if (!str_ends_with($key, '/') || !str_starts_with($specifier, $key)) {
+ continue;
+ }
+ if ($best === null || strlen($key) > strlen($best)) {
+ $best = $key;
+ }
+ }
+ if ($best === null) {
+ return false;
+ }
+ $target = $this->importMap[$best];
+ if ($target === null) {
+ return null;
+ }
+ return rtrim($target, '/') . '/' . substr($specifier, strlen($best));
+ }
+
+ /**
+ * A path to a file: the exact file, then each extension appended, then a
+ * directory's index file.
+ */
+ public function resolveFile(string $path): ?string
+ {
+ if (is_file($path)) {
+ return realpath($path) ?: $path;
+ }
+ foreach ($this->options->extensions as $extension) {
+ if (is_file($path . $extension)) {
+ return realpath($path . $extension) ?: $path . $extension;
+ }
+ }
+ if (is_dir($path)) {
+ foreach ($this->options->extensions as $extension) {
+ $index = $path . '/index' . $extension;
+ if (is_file($index)) {
+ return realpath($index) ?: $index;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * @param 'import'|'require' $kind
+ */
+ private function resolvePackage(string $specifier, ?string $referrer, string $kind): ?string
+ {
+ [$name, $subpath] = self::splitPackageSpecifier($specifier);
+ if ($name === null) {
+ return null;
+ }
+ $dir = $referrer === null
+ ? ($this->options->projectRoot ?? (getcwd() ?: '/'))
+ : dirname($referrer);
+ while (true) {
+ $packageDir = $dir . '/node_modules/' . $name;
+ if (is_dir($packageDir)) {
+ return $this->resolveInPackage($packageDir, $subpath, $specifier, $referrer, $kind);
+ }
+ $parent = dirname($dir);
+ if ($parent === $dir) {
+ return null;
+ }
+ $dir = $parent;
+ }
+ }
+
+ /**
+ * @param 'import'|'require' $kind
+ */
+ private function resolveInPackage(string $packageDir, string $subpath, string $specifier, ?string $referrer, string $kind): string
+ {
+ $packageDir = realpath($packageDir) ?: $packageDir;
+ $json = $this->readPackageJson($packageDir) ?? [];
+ if (array_key_exists('exports', $json) && $json['exports'] !== null) {
+ $target = $this->resolveExportsMap($json['exports'], $subpath, $this->options->activeConditions($kind));
+ if ($target === null) {
+ throw new TypeError(
+ "Package subpath '{$subpath}' is not defined by \"exports\" in {$packageDir}/package.json"
+ . self::from($referrer),
+ );
+ }
+ $file = $packageDir . '/' . self::stripDotSlash($target);
+ if (!is_file($file)) {
+ throw $this->notFound($specifier, $referrer);
+ }
+ return realpath($file) ?: $file;
+ }
+ if ($subpath === '.') {
+ foreach (['module', 'main'] as $field) {
+ if (isset($json[$field]) && is_string($json[$field]) && $json[$field] !== '') {
+ $resolved = $this->resolveFile($packageDir . '/' . self::stripDotSlash($json[$field]));
+ if ($resolved !== null) {
+ return $resolved;
+ }
+ }
+ }
+ return $this->resolveFile($packageDir . '/index') ?? throw $this->notFound($specifier, $referrer);
+ }
+ return $this->resolveFile($packageDir . '/' . self::stripDotSlash($subpath)) ?? throw $this->notFound($specifier, $referrer);
+ }
+
+ /**
+ * @param 'import'|'require' $kind
+ */
+ private function resolvePackageImports(string $specifier, string $referrer, string $kind): ?string
+ {
+ $scope = $this->packageScope($referrer);
+ if ($scope === null) {
+ return null;
+ }
+ $json = $this->readPackageJson($scope) ?? [];
+ if (!isset($json['imports']) || !is_array($json['imports'])) {
+ return null;
+ }
+ $target = $this->resolveSubpathMap($json['imports'], $specifier, $this->options->activeConditions($kind));
+ if ($target === null) {
+ return null;
+ }
+ if (str_starts_with($target, './')) {
+ $file = $scope . '/' . substr($target, 2);
+ return is_file($file) ? (realpath($file) ?: $file) : null;
+ }
+ return $this->resolvePackage($target, $referrer, $kind);
+ }
+
+ /**
+ * Node's PACKAGE_EXPORTS_RESOLVE: returns the target path relative to
+ * the package (starting with `./`) or null when the subpath is not
+ * exported or is blocked.
+ *
+ * @param array $conditions
+ */
+ private function resolveExportsMap(mixed $exports, string $subpath, array $conditions): ?string
+ {
+ $isSubpathMap = is_array($exports) && !array_is_list($exports)
+ && str_starts_with((string) array_key_first($exports), '.');
+ if (!$isSubpathMap) {
+ return $subpath === '.' ? $this->resolveTarget($exports, null, $conditions) : null;
+ }
+ /** @var array $exports */
+ return $this->resolveSubpathMap($exports, $subpath, $conditions);
+ }
+
+ /**
+ * @param array $map
+ * @param array $conditions
+ */
+ private function resolveSubpathMap(array $map, string $subpath, array $conditions): ?string
+ {
+ if (array_key_exists($subpath, $map) && !str_contains($subpath, '*')) {
+ return $this->resolveTarget($map[$subpath], null, $conditions);
+ }
+ $bestKey = null;
+ $bestMatch = null;
+ foreach ($map as $key => $_) {
+ $key = (string) $key;
+ $star = strpos($key, '*');
+ if ($star === false) {
+ // Legacy folder mapping: "./dir/": "./lib/dir/".
+ if (
+ str_ends_with($key, '/') && str_starts_with($subpath, $key)
+ && ($bestKey === null || strlen($key) > strlen($bestKey))
+ ) {
+ $bestKey = $key;
+ $bestMatch = substr($subpath, strlen($key));
+ }
+ continue;
+ }
+ $prefix = substr($key, 0, $star);
+ $suffix = substr($key, $star + 1);
+ if (
+ str_starts_with($subpath, $prefix)
+ && $subpath !== $prefix
+ && strlen($subpath) >= strlen($key) - 1
+ && ($suffix === '' || str_ends_with($subpath, $suffix))
+ && ($bestKey === null || self::patternKeyCompare($key, $bestKey) < 0)
+ ) {
+ $bestKey = $key;
+ $bestMatch = substr($subpath, strlen($prefix), strlen($subpath) - strlen($prefix) - strlen($suffix));
+ }
+ }
+ if ($bestKey === null) {
+ return null;
+ }
+ if (!str_contains($bestKey, '*')) {
+ $target = $this->resolveTarget($map[$bestKey], null, $conditions);
+ return $target === null ? null : $target . $bestMatch;
+ }
+ return $this->resolveTarget($map[$bestKey], $bestMatch, $conditions);
+ }
+
+ /** Node's PATTERN_KEY_COMPARE: the more specific pattern sorts first. */
+ private static function patternKeyCompare(string $a, string $b): int
+ {
+ $baseA = strpos($a, '*');
+ $baseB = strpos($b, '*');
+ $lengthA = $baseA === false ? strlen($a) : $baseA + 1;
+ $lengthB = $baseB === false ? strlen($b) : $baseB + 1;
+ if ($lengthA !== $lengthB) {
+ return $lengthA > $lengthB ? -1 : 1;
+ }
+ return strlen($a) > strlen($b) ? -1 : (strlen($a) < strlen($b) ? 1 : 0);
+ }
+
+ /**
+ * @param array $conditions
+ */
+ private function resolveTarget(mixed $target, ?string $patternMatch, array $conditions): ?string
+ {
+ if (is_string($target)) {
+ if ($patternMatch !== null) {
+ $target = str_replace('*', $patternMatch, $target);
+ }
+ return $target;
+ }
+ if (is_array($target)) {
+ if (array_is_list($target)) {
+ foreach ($target as $candidate) {
+ $resolved = $this->resolveTarget($candidate, $patternMatch, $conditions);
+ if ($resolved !== null) {
+ return $resolved;
+ }
+ }
+ return null;
+ }
+ foreach ($target as $condition => $value) {
+ if (isset($conditions[(string) $condition])) {
+ $resolved = $this->resolveTarget($value, $patternMatch, $conditions);
+ if ($resolved !== null) {
+ return $resolved;
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ private function resolveAlias(string $specifier, ?string $referrer): ?string
+ {
+ $config = $this->aliasConfigFor($referrer);
+ if ($config === null) {
+ return null;
+ }
+ $base = $config['baseUrl'] ?? $config['dir'];
+ $bestKey = null;
+ $bestMatch = '';
+ foreach ($config['paths'] as $pattern => $_) {
+ $star = strpos($pattern, '*');
+ if ($star === false) {
+ if ($pattern === $specifier) {
+ $bestKey = $pattern;
+ $bestMatch = '';
+ break;
+ }
+ continue;
+ }
+ $prefix = substr($pattern, 0, $star);
+ $suffix = substr($pattern, $star + 1);
+ if (
+ str_starts_with($specifier, $prefix)
+ && str_ends_with($specifier, $suffix)
+ && strlen($specifier) >= strlen($prefix) + strlen($suffix)
+ && ($bestKey === null || strlen($prefix) > strpos($bestKey, '*'))
+ ) {
+ $bestKey = $pattern;
+ $bestMatch = substr($specifier, strlen($prefix), strlen($specifier) - strlen($prefix) - strlen($suffix));
+ }
+ }
+ if ($bestKey !== null) {
+ foreach ($config['paths'][$bestKey] as $target) {
+ $resolved = $this->resolveFile(self::normalize($base . '/' . str_replace('*', $bestMatch, $target)));
+ if ($resolved !== null) {
+ return $resolved;
+ }
+ }
+ }
+ if ($config['baseUrl'] !== null) {
+ return $this->resolveFile(self::normalize($config['baseUrl'] . '/' . $specifier));
+ }
+ return null;
+ }
+
+ /** @return ?array{dir: string, baseUrl: ?string, paths: array>} */
+ private function aliasConfigFor(?string $referrer): ?array
+ {
+ $start = $this->options->projectRoot ?? ($referrer === null ? null : dirname($referrer));
+ if ($start === null) {
+ return null;
+ }
+ if (array_key_exists($start, $this->aliasConfig)) {
+ return $this->aliasConfig[$start];
+ }
+ $dir = $start;
+ $found = null;
+ while (true) {
+ foreach (['jsconfig.json', 'tsconfig.json'] as $name) {
+ if (is_file($dir . '/' . $name)) {
+ $found = $dir . '/' . $name;
+ break 2;
+ }
+ }
+ if ($this->options->projectRoot !== null) {
+ break;
+ }
+ $parent = dirname($dir);
+ if ($parent === $dir || is_dir($dir . '/node_modules')) {
+ break;
+ }
+ $dir = $parent;
+ }
+ return $this->aliasConfig[$start] = $found === null ? null : $this->loadAliasConfig($found, 0);
+ }
+
+ /** @return ?array{dir: string, baseUrl: ?string, paths: array>} */
+ private function loadAliasConfig(string $file, int $depth): ?array
+ {
+ $data = self::parseJsonc((string) file_get_contents($file));
+ if (!is_array($data)) {
+ return null;
+ }
+ $dir = dirname($file);
+ $result = ['dir' => $dir, 'baseUrl' => null, 'paths' => []];
+ if ($depth < 8 && isset($data['extends']) && is_string($data['extends']) && str_starts_with($data['extends'], '.')) {
+ $parentFile = self::normalize($dir . '/' . $data['extends']);
+ if (!str_ends_with($parentFile, '.json')) {
+ $parentFile .= '.json';
+ }
+ if (is_file($parentFile)) {
+ $result = $this->loadAliasConfig($parentFile, $depth + 1) ?? $result;
+ }
+ }
+ $compiler = is_array($data['compilerOptions'] ?? null) ? $data['compilerOptions'] : [];
+ if (isset($compiler['baseUrl']) && is_string($compiler['baseUrl'])) {
+ $result['baseUrl'] = self::normalize($dir . '/' . $compiler['baseUrl']);
+ $result['dir'] = $dir;
+ }
+ if (isset($compiler['paths']) && is_array($compiler['paths'])) {
+ $paths = [];
+ foreach ($compiler['paths'] as $pattern => $targets) {
+ $paths[(string) $pattern] = array_values(array_filter((array) $targets, 'is_string'));
+ }
+ $result['paths'] = $paths;
+ if ($result['baseUrl'] === null) {
+ $result['dir'] = $dir;
+ }
+ }
+ return $result;
+ }
+
+ /** JSON with comments and trailing commas, as tsconfig.json allows. */
+ public static function parseJsonc(string $text): mixed
+ {
+ $out = '';
+ $len = strlen($text);
+ $i = 0;
+ while ($i < $len) {
+ $c = $text[$i];
+ if ($c === '"') {
+ $j = $i + 1;
+ while ($j < $len && $text[$j] !== '"') {
+ $j += $text[$j] === '\\' ? 2 : 1;
+ }
+ $out .= substr($text, $i, $j - $i + 1);
+ $i = $j + 1;
+ continue;
+ }
+ if ($c === '/' && ($text[$i + 1] ?? '') === '/') {
+ $end = strpos($text, "\n", $i);
+ $i = $end === false ? $len : $end;
+ continue;
+ }
+ if ($c === '/' && ($text[$i + 1] ?? '') === '*') {
+ $end = strpos($text, '*/', $i + 2);
+ $i = $end === false ? $len : $end + 2;
+ continue;
+ }
+ $out .= $c;
+ $i++;
+ }
+ $out = (string) preg_replace('/,(\s*[}\]])/', '$1', $out);
+ return json_decode($out, true);
+ }
+
+ /** @return array{0: ?string, 1: string} Package name and subpath (`.` or `./rest`). */
+ private static function splitPackageSpecifier(string $specifier): array
+ {
+ $parts = explode('/', $specifier);
+ if ($specifier === '' || $parts[0] === '') {
+ return [null, '.'];
+ }
+ if ($parts[0][0] === '@') {
+ if (count($parts) < 2 || $parts[1] === '') {
+ return [null, '.'];
+ }
+ $name = $parts[0] . '/' . $parts[1];
+ $rest = array_slice($parts, 2);
+ } else {
+ $name = $parts[0];
+ $rest = array_slice($parts, 1);
+ }
+ return [$name, $rest === [] ? '.' : './' . implode('/', $rest)];
+ }
+
+ private static function stripDotSlash(string $path): string
+ {
+ return str_starts_with($path, './') ? substr($path, 2) : $path;
+ }
+
+ /** Resolve `.` and `..` segments without touching the filesystem. */
+ public static function normalize(string $path): string
+ {
+ $absolute = str_starts_with($path, '/');
+ $stack = [];
+ foreach (explode('/', $path) as $part) {
+ if ($part === '' || $part === '.') {
+ continue;
+ }
+ if ($part === '..') {
+ array_pop($stack);
+ continue;
+ }
+ $stack[] = $part;
+ }
+ return ($absolute ? '/' : '') . implode('/', $stack);
+ }
+
+ private static function isNodeBuiltinName(string $specifier): bool
+ {
+ $name = explode('/', $specifier)[0];
+ return in_array($name, [
+ 'assert', 'buffer', 'child_process', 'cluster', 'crypto', 'dgram', 'dns', 'events', 'fs', 'http',
+ 'http2', 'https', 'module', 'net', 'os', 'path', 'perf_hooks', 'process', 'querystring', 'readline',
+ 'stream', 'string_decoder', 'timers', 'tls', 'tty', 'url', 'util', 'v8', 'vm', 'worker_threads', 'zlib',
+ ], true);
+ }
+
+ private function notFound(string $specifier, ?string $referrer): TypeError
+ {
+ return new TypeError("Cannot find module '{$specifier}'" . self::from($referrer));
+ }
+
+ private static function from(?string $referrer): string
+ {
+ return $referrer === null ? '' : " imported from '{$referrer}'";
+ }
+}
diff --git a/tests/Jsx/JsxPackageSmokeTest.php b/tests/Jsx/JsxPackageSmokeTest.php
new file mode 100644
index 00000000..2904fb0c
--- /dev/null
+++ b/tests/Jsx/JsxPackageSmokeTest.php
@@ -0,0 +1,38 @@
+ */
+ public static function fixtureProvider(): iterable
+ {
+ foreach (glob(__DIR__ . '/oracle/smoke/*.mjs') ?: [] as $file) {
+ yield basename($file, '.mjs') => [$file];
+ }
+ }
+
+ #[DataProvider('fixtureProvider')]
+ public function testMatchesNode(string $file): void
+ {
+ if (!is_dir(__DIR__ . '/oracle/node_modules')) {
+ $this->markTestSkipped('Run `npm ci` in tests/Jsx/oracle to install the pinned packages.');
+ }
+ $expected = (string) file_get_contents(substr($file, 0, -4) . '.expected.txt');
+ $engine = new Engine();
+ $engine->execFile($file);
+ $this->assertSame(rtrim($expected, "\n"), rtrim($engine->getConsoleOutput(), "\n"));
+ }
+}
diff --git a/tests/Jsx/JsxResolutionOracleTest.php b/tests/Jsx/JsxResolutionOracleTest.php
new file mode 100644
index 00000000..09da9da8
--- /dev/null
+++ b/tests/Jsx/JsxResolutionOracleTest.php
@@ -0,0 +1,36 @@
+ */
+ public static function fixtureProvider(): iterable
+ {
+ foreach (glob(__DIR__ . '/resolution/*', GLOB_ONLYDIR) ?: [] as $dir) {
+ yield basename($dir) => [$dir];
+ }
+ }
+
+ #[DataProvider('fixtureProvider')]
+ public function testMatchesBundledNode(string $dir): void
+ {
+ $expected = (string) file_get_contents($dir . '.expected.txt');
+ $engine = new Engine();
+ $engine->setAssetResolver(static fn (string $file): string => '/assets/' . basename($file));
+ $engine->execFile($dir . '/main.mjs');
+ $this->assertSame(rtrim($expected, "\n"), rtrim($engine->getConsoleOutput(), "\n"));
+ }
+}
diff --git a/tests/Jsx/oracle/smoke/class-variance-authority.expected.txt b/tests/Jsx/oracle/smoke/class-variance-authority.expected.txt
new file mode 100644
index 00000000..1fb60111
--- /dev/null
+++ b/tests/Jsx/oracle/smoke/class-variance-authority.expected.txt
@@ -0,0 +1 @@
+btn text-sm btn text-lg bg-blue
diff --git a/tests/Jsx/oracle/smoke/class-variance-authority.mjs b/tests/Jsx/oracle/smoke/class-variance-authority.mjs
new file mode 100644
index 00000000..8046db80
--- /dev/null
+++ b/tests/Jsx/oracle/smoke/class-variance-authority.mjs
@@ -0,0 +1,3 @@
+import { cva } from "class-variance-authority";
+const button = cva("btn", { variants: { size: { sm: "text-sm", lg: "text-lg" }, intent: { primary: "bg-blue" } }, defaultVariants: { size: "sm" } });
+console.log(button(), button({ size: "lg", intent: "primary" }));
diff --git a/tests/Jsx/oracle/smoke/classnames.expected.txt b/tests/Jsx/oracle/smoke/classnames.expected.txt
new file mode 100644
index 00000000..3cefe4a2
--- /dev/null
+++ b/tests/Jsx/oracle/smoke/classnames.expected.txt
@@ -0,0 +1 @@
+a b d x y
diff --git a/tests/Jsx/oracle/smoke/classnames.mjs b/tests/Jsx/oracle/smoke/classnames.mjs
new file mode 100644
index 00000000..66c8fb60
--- /dev/null
+++ b/tests/Jsx/oracle/smoke/classnames.mjs
@@ -0,0 +1,3 @@
+import cx from "classnames";
+import dedupe from "classnames/dedupe";
+console.log(cx("a", { b: true, c: false }, ["d"]), dedupe("x", "x", { y: true }));
diff --git a/tests/Jsx/oracle/smoke/clsx.expected.txt b/tests/Jsx/oracle/smoke/clsx.expected.txt
new file mode 100644
index 00000000..2079f5fc
--- /dev/null
+++ b/tests/Jsx/oracle/smoke/clsx.expected.txt
@@ -0,0 +1 @@
+a b d e f
diff --git a/tests/Jsx/oracle/smoke/clsx.mjs b/tests/Jsx/oracle/smoke/clsx.mjs
new file mode 100644
index 00000000..672e889c
--- /dev/null
+++ b/tests/Jsx/oracle/smoke/clsx.mjs
@@ -0,0 +1,2 @@
+import clsx from "clsx";
+console.log(clsx("a", { b: true, c: false }, ["d", ["e"]], null, 0, "f"));
diff --git a/tests/Jsx/oracle/smoke/date-fns.expected.txt b/tests/Jsx/oracle/smoke/date-fns.expected.txt
new file mode 100644
index 00000000..ba6b2435
--- /dev/null
+++ b/tests/Jsx/oracle/smoke/date-fns.expected.txt
@@ -0,0 +1 @@
+2026-10-18 40
diff --git a/tests/Jsx/oracle/smoke/date-fns.mjs b/tests/Jsx/oracle/smoke/date-fns.mjs
new file mode 100644
index 00000000..07f565ad
--- /dev/null
+++ b/tests/Jsx/oracle/smoke/date-fns.mjs
@@ -0,0 +1,3 @@
+import { format, addDays, differenceInCalendarDays, parseISO } from "date-fns";
+const d = parseISO("2026-10-13T12:00:00Z");
+console.log(format(addDays(d, 5), "yyyy-MM-dd"), differenceInCalendarDays(addDays(d, 40), d));
diff --git a/tests/Jsx/oracle/smoke/dayjs.expected.txt b/tests/Jsx/oracle/smoke/dayjs.expected.txt
new file mode 100644
index 00000000..6b73f34a
--- /dev/null
+++ b/tests/Jsx/oracle/smoke/dayjs.expected.txt
@@ -0,0 +1 @@
+2026-11-13 12:00
diff --git a/tests/Jsx/oracle/smoke/dayjs.mjs b/tests/Jsx/oracle/smoke/dayjs.mjs
new file mode 100644
index 00000000..d8298fa3
--- /dev/null
+++ b/tests/Jsx/oracle/smoke/dayjs.mjs
@@ -0,0 +1,4 @@
+import dayjs from "dayjs";
+import utc from "dayjs/plugin/utc.js";
+dayjs.extend(utc);
+console.log(dayjs.utc("2026-10-13T12:00:00Z").add(1, "month").format("YYYY-MM-DD HH:mm"));
diff --git a/tests/Jsx/oracle/smoke/lodash-es.expected.txt b/tests/Jsx/oracle/smoke/lodash-es.expected.txt
new file mode 100644
index 00000000..10abd4e6
--- /dev/null
+++ b/tests/Jsx/oracle/smoke/lodash-es.expected.txt
@@ -0,0 +1 @@
+[[1,2],[3,4],[5]] [{"a":1},{"a":2}] 2
diff --git a/tests/Jsx/oracle/smoke/lodash-es.mjs b/tests/Jsx/oracle/smoke/lodash-es.mjs
new file mode 100644
index 00000000..cea1bad0
--- /dev/null
+++ b/tests/Jsx/oracle/smoke/lodash-es.mjs
@@ -0,0 +1,2 @@
+import { chunk, sortBy, uniqBy } from "lodash-es";
+console.log(JSON.stringify(chunk([1, 2, 3, 4, 5], 2)), JSON.stringify(sortBy([{ a: 2 }, { a: 1 }], "a")), uniqBy([1, 1.5, 2], Math.floor).length);
diff --git a/tests/Jsx/oracle/smoke/lodash.expected.txt b/tests/Jsx/oracle/smoke/lodash.expected.txt
new file mode 100644
index 00000000..933a1768
--- /dev/null
+++ b/tests/Jsx/oracle/smoke/lodash.expected.txt
@@ -0,0 +1 @@
+{"1":[1.2,1.4],"2":[2.1]} hello-world function
diff --git a/tests/Jsx/oracle/smoke/lodash.mjs b/tests/Jsx/oracle/smoke/lodash.mjs
new file mode 100644
index 00000000..bcfc9ae4
--- /dev/null
+++ b/tests/Jsx/oracle/smoke/lodash.mjs
@@ -0,0 +1,3 @@
+import _ from "lodash";
+import debounce from "lodash/debounce.js";
+console.log(JSON.stringify(_.groupBy([1.2, 1.4, 2.1], Math.floor)), _.kebabCase("Hello World"), typeof debounce);
diff --git a/tests/Jsx/oracle/smoke/prop-types.expected.txt b/tests/Jsx/oracle/smoke/prop-types.expected.txt
new file mode 100644
index 00000000..a58eb43c
--- /dev/null
+++ b/tests/Jsx/oracle/smoke/prop-types.expected.txt
@@ -0,0 +1 @@
+function 0
diff --git a/tests/Jsx/oracle/smoke/prop-types.mjs b/tests/Jsx/oracle/smoke/prop-types.mjs
new file mode 100644
index 00000000..07ed59f2
--- /dev/null
+++ b/tests/Jsx/oracle/smoke/prop-types.mjs
@@ -0,0 +1,7 @@
+import PropTypes from "prop-types";
+const errors = [];
+const original = console.error;
+console.error = (m) => errors.push(String(m).split("\n")[0]);
+PropTypes.checkPropTypes({ name: PropTypes.string.isRequired }, { name: 1 }, "prop", "Card");
+console.error = original;
+console.log(typeof PropTypes.string, errors.length);
diff --git a/tests/Jsx/oracle/smoke/react-is.expected.txt b/tests/Jsx/oracle/smoke/react-is.expected.txt
new file mode 100644
index 00000000..43c232db
--- /dev/null
+++ b/tests/Jsx/oracle/smoke/react-is.expected.txt
@@ -0,0 +1 @@
+true true false true Symbol(react.fragment)
diff --git a/tests/Jsx/oracle/smoke/react-is.mjs b/tests/Jsx/oracle/smoke/react-is.mjs
new file mode 100644
index 00000000..73a14f25
--- /dev/null
+++ b/tests/Jsx/oracle/smoke/react-is.mjs
@@ -0,0 +1,3 @@
+import { isElement, isValidElementType, Fragment, typeOf } from "react-is";
+const el = { $$typeof: Symbol.for("react.transitional.element"), type: "div", props: {} };
+console.log(isElement(el), isValidElementType("div"), isValidElementType(42), typeOf(el) === Symbol.for("react.transitional.element"), String(Fragment));
diff --git a/tests/Jsx/oracle/smoke/tailwind-merge.expected.txt b/tests/Jsx/oracle/smoke/tailwind-merge.expected.txt
new file mode 100644
index 00000000..0ed2ba5d
--- /dev/null
+++ b/tests/Jsx/oracle/smoke/tailwind-merge.expected.txt
@@ -0,0 +1 @@
+hover:bg-red-600 p-3 bg-blue-500
diff --git a/tests/Jsx/oracle/smoke/tailwind-merge.mjs b/tests/Jsx/oracle/smoke/tailwind-merge.mjs
new file mode 100644
index 00000000..b5b7538a
--- /dev/null
+++ b/tests/Jsx/oracle/smoke/tailwind-merge.mjs
@@ -0,0 +1,2 @@
+import { twMerge } from "tailwind-merge";
+console.log(twMerge("px-2 py-1 bg-red-500 hover:bg-red-600", "p-3 bg-blue-500"));
diff --git a/tests/Jsx/resolution/app.expected.txt b/tests/Jsx/resolution/app.expected.txt
new file mode 100644
index 00000000..3d8fda17
--- /dev/null
+++ b/tests/Jsx/resolution/app.expected.txt
@@ -0,0 +1,26 @@
+eval Card
+eval esm-pkg
+eval alpha
+Card: "card"
+List: "list"
+util: "util"
+baseUrl: "from baseUrl"
+data: {"answer":42,"list":[1,2]}
+logo: "/assets/logo.svg"
+esm: "esm default" | "feature"
+feature: "feature"
+star: "star icon"
+cjs: {"a":"A","b":"B"} | "A" |
+flagged: {"default":"flagged default","named":"flagged named"} | "flagged named"
+moduleField: "from module field"
+dual: "dual esm"
+cond: "phasis condition"
+prod: "production build"
+scoped: {"scoped":true}
+usesImports: "internal + feature"
+requirer: ["other",1,"feature","dual cjs"]
+cycle: {"early":"a-early","seenByB":"a-early/undefined"}
+nodeEnv: "prod mode" | "production"
+detected: "detected esm"
+barrel: "alpha"
+ns: ["a","b","default"] | true
diff --git a/tests/Jsx/resolution/app/jsconfig.json b/tests/Jsx/resolution/app/jsconfig.json
new file mode 100644
index 00000000..ebe28be7
--- /dev/null
+++ b/tests/Jsx/resolution/app/jsconfig.json
@@ -0,0 +1,10 @@
+{
+ // Comments and trailing commas are allowed, as in tsconfig.json.
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"],
+ "~utils": ["src/utils/index.js"],
+ },
+ },
+}
diff --git a/tests/Jsx/resolution/app/main.mjs b/tests/Jsx/resolution/app/main.mjs
new file mode 100644
index 00000000..638f6ab4
--- /dev/null
+++ b/tests/Jsx/resolution/app/main.mjs
@@ -0,0 +1,49 @@
+import { show } from "./src/show.js";
+import Card from "@/components/Card";
+import List from "./src/components/List";
+import { util } from "~utils";
+import fromBaseUrl from "src/base-url-target";
+import data from "./src/data.json";
+import "./src/style.css";
+import logo from "./src/logo.svg";
+import esm, { feature as rootFeature } from "esm-pkg";
+import { feature } from "esm-pkg/feature";
+import star from "esm-pkg/icons/star";
+import cjs, { a as cjsA, missing } from "cjs-pkg";
+import flagged, { named as flaggedNamed } from "esmodule-flag";
+import moduleField from "module-field";
+import dual from "dual-pkg";
+import cond from "cond-pkg";
+import prod from "prod-pkg";
+import scoped from "@scope/pkg";
+import usesImports from "imports-field";
+import requirer from "cjs-requires";
+import cycle from "cycle-a";
+import nodeEnv, { esmNodeEnv } from "node-env";
+import detected from "detect-pkg";
+import { Alpha } from "barrel-pkg";
+import * as ns from "cjs-pkg";
+
+show("Card", Card());
+show("List", List());
+show("util", util);
+show("baseUrl", fromBaseUrl);
+show("data", data);
+show("logo", logo);
+show("esm", esm, rootFeature);
+show("feature", feature);
+show("star", star);
+show("cjs", cjs, cjsA, missing);
+show("flagged", flagged, flaggedNamed);
+show("moduleField", moduleField);
+show("dual", dual);
+show("cond", cond);
+show("prod", prod);
+show("scoped", scoped);
+show("usesImports", usesImports);
+show("requirer", requirer);
+show("cycle", cycle);
+show("nodeEnv", nodeEnv.mode, esmNodeEnv);
+show("detected", detected);
+show("barrel", Alpha());
+show("ns", Object.keys(ns).sort(), ns.default === cjs);
diff --git a/tests/Jsx/resolution/app/node_modules/@scope/pkg/main.js b/tests/Jsx/resolution/app/node_modules/@scope/pkg/main.js
new file mode 100644
index 00000000..e5bb7f06
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/@scope/pkg/main.js
@@ -0,0 +1 @@
+module.exports = { scoped: true };
diff --git a/tests/Jsx/resolution/app/node_modules/@scope/pkg/package.json b/tests/Jsx/resolution/app/node_modules/@scope/pkg/package.json
new file mode 100644
index 00000000..9373d771
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/@scope/pkg/package.json
@@ -0,0 +1 @@
+{ "name": "@scope/pkg", "main": "main.js" }
diff --git a/tests/Jsx/resolution/app/node_modules/barrel-pkg/alpha.js b/tests/Jsx/resolution/app/node_modules/barrel-pkg/alpha.js
new file mode 100644
index 00000000..89c5f0b7
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/barrel-pkg/alpha.js
@@ -0,0 +1,2 @@
+console.log("eval alpha");
+export function Alpha() { return "alpha"; }
diff --git a/tests/Jsx/resolution/app/node_modules/barrel-pkg/beta.js b/tests/Jsx/resolution/app/node_modules/barrel-pkg/beta.js
new file mode 100644
index 00000000..0c99fc4f
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/barrel-pkg/beta.js
@@ -0,0 +1,2 @@
+console.log("eval beta (should be pruned)");
+export function Beta() { return "beta"; }
diff --git a/tests/Jsx/resolution/app/node_modules/barrel-pkg/gamma.js b/tests/Jsx/resolution/app/node_modules/barrel-pkg/gamma.js
new file mode 100644
index 00000000..87484b42
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/barrel-pkg/gamma.js
@@ -0,0 +1,2 @@
+console.log("eval gamma (should be pruned)");
+export const Gamma = "gamma";
diff --git a/tests/Jsx/resolution/app/node_modules/barrel-pkg/index.js b/tests/Jsx/resolution/app/node_modules/barrel-pkg/index.js
new file mode 100644
index 00000000..19de2233
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/barrel-pkg/index.js
@@ -0,0 +1,3 @@
+export { Alpha } from "./alpha.js";
+export { Beta } from "./beta.js";
+export * from "./gamma.js";
diff --git a/tests/Jsx/resolution/app/node_modules/barrel-pkg/package.json b/tests/Jsx/resolution/app/node_modules/barrel-pkg/package.json
new file mode 100644
index 00000000..bcdb4163
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/barrel-pkg/package.json
@@ -0,0 +1 @@
+{ "name": "barrel-pkg", "type": "module", "sideEffects": false, "main": "index.js" }
diff --git a/tests/Jsx/resolution/app/node_modules/cjs-pkg/lib/index.js b/tests/Jsx/resolution/app/node_modules/cjs-pkg/lib/index.js
new file mode 100644
index 00000000..f9ca8066
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/cjs-pkg/lib/index.js
@@ -0,0 +1,2 @@
+exports.a = "A";
+exports.b = "B";
diff --git a/tests/Jsx/resolution/app/node_modules/cjs-pkg/package.json b/tests/Jsx/resolution/app/node_modules/cjs-pkg/package.json
new file mode 100644
index 00000000..f6d2f71d
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/cjs-pkg/package.json
@@ -0,0 +1 @@
+{ "name": "cjs-pkg", "main": "./lib/index" }
diff --git a/tests/Jsx/resolution/app/node_modules/cjs-requires/data.json b/tests/Jsx/resolution/app/node_modules/cjs-requires/data.json
new file mode 100644
index 00000000..10ee251e
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/cjs-requires/data.json
@@ -0,0 +1 @@
+{ "x": 1 }
diff --git a/tests/Jsx/resolution/app/node_modules/cjs-requires/index.js b/tests/Jsx/resolution/app/node_modules/cjs-requires/index.js
new file mode 100644
index 00000000..2b69c414
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/cjs-requires/index.js
@@ -0,0 +1,5 @@
+const other = require("./other");
+const json = require("./data.json");
+const esm = require("esm-pkg/feature");
+const dual = require("dual-pkg");
+module.exports = [other, json.x, esm.feature, dual];
diff --git a/tests/Jsx/resolution/app/node_modules/cjs-requires/other.js b/tests/Jsx/resolution/app/node_modules/cjs-requires/other.js
new file mode 100644
index 00000000..7f4090ad
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/cjs-requires/other.js
@@ -0,0 +1 @@
+module.exports = "other";
diff --git a/tests/Jsx/resolution/app/node_modules/cjs-requires/package.json b/tests/Jsx/resolution/app/node_modules/cjs-requires/package.json
new file mode 100644
index 00000000..ef2608cb
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/cjs-requires/package.json
@@ -0,0 +1 @@
+{ "name": "cjs-requires", "main": "index.js" }
diff --git a/tests/Jsx/resolution/app/node_modules/cond-pkg/browser.js b/tests/Jsx/resolution/app/node_modules/cond-pkg/browser.js
new file mode 100644
index 00000000..a7a916ae
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/cond-pkg/browser.js
@@ -0,0 +1 @@
+module.exports = "WRONG: browser";
diff --git a/tests/Jsx/resolution/app/node_modules/cond-pkg/default.js b/tests/Jsx/resolution/app/node_modules/cond-pkg/default.js
new file mode 100644
index 00000000..80da43ca
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/cond-pkg/default.js
@@ -0,0 +1 @@
+module.exports = "WRONG: default";
diff --git a/tests/Jsx/resolution/app/node_modules/cond-pkg/node.js b/tests/Jsx/resolution/app/node_modules/cond-pkg/node.js
new file mode 100644
index 00000000..f177eb01
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/cond-pkg/node.js
@@ -0,0 +1 @@
+module.exports = "WRONG: node";
diff --git a/tests/Jsx/resolution/app/node_modules/cond-pkg/package.json b/tests/Jsx/resolution/app/node_modules/cond-pkg/package.json
new file mode 100644
index 00000000..d05d2a43
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/cond-pkg/package.json
@@ -0,0 +1,2 @@
+{ "name": "cond-pkg", "exports": { ".": {
+ "node": "./node.js", "browser": "./browser.js", "phasis": "./phasis.js", "default": "./default.js" } } }
diff --git a/tests/Jsx/resolution/app/node_modules/cond-pkg/phasis.js b/tests/Jsx/resolution/app/node_modules/cond-pkg/phasis.js
new file mode 100644
index 00000000..84f8d8a5
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/cond-pkg/phasis.js
@@ -0,0 +1 @@
+module.exports = "phasis condition";
diff --git a/tests/Jsx/resolution/app/node_modules/cycle-a/a.js b/tests/Jsx/resolution/app/node_modules/cycle-a/a.js
new file mode 100644
index 00000000..2c6dd0b5
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/cycle-a/a.js
@@ -0,0 +1,3 @@
+exports.early = "a-early";
+const b = require("./b");
+exports.seenByB = b.sawA;
diff --git a/tests/Jsx/resolution/app/node_modules/cycle-a/b.js b/tests/Jsx/resolution/app/node_modules/cycle-a/b.js
new file mode 100644
index 00000000..98ce22c1
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/cycle-a/b.js
@@ -0,0 +1,2 @@
+const a = require("./a");
+exports.sawA = a.early + "/" + String(a.seenByB);
diff --git a/tests/Jsx/resolution/app/node_modules/cycle-a/package.json b/tests/Jsx/resolution/app/node_modules/cycle-a/package.json
new file mode 100644
index 00000000..5e2155e6
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/cycle-a/package.json
@@ -0,0 +1 @@
+{ "name": "cycle-a", "main": "a.js" }
diff --git a/tests/Jsx/resolution/app/node_modules/detect-pkg/index.js b/tests/Jsx/resolution/app/node_modules/detect-pkg/index.js
new file mode 100644
index 00000000..7752b3d3
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/detect-pkg/index.js
@@ -0,0 +1,2 @@
+const x = "detected esm";
+export default x;
diff --git a/tests/Jsx/resolution/app/node_modules/detect-pkg/package.json b/tests/Jsx/resolution/app/node_modules/detect-pkg/package.json
new file mode 100644
index 00000000..f6cd3431
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/detect-pkg/package.json
@@ -0,0 +1 @@
+{ "name": "detect-pkg", "main": "index.js" }
diff --git a/tests/Jsx/resolution/app/node_modules/dual-pkg/cjs.cjs b/tests/Jsx/resolution/app/node_modules/dual-pkg/cjs.cjs
new file mode 100644
index 00000000..62a5e6c6
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/dual-pkg/cjs.cjs
@@ -0,0 +1 @@
+module.exports = "dual cjs";
diff --git a/tests/Jsx/resolution/app/node_modules/dual-pkg/esm.mjs b/tests/Jsx/resolution/app/node_modules/dual-pkg/esm.mjs
new file mode 100644
index 00000000..7e97a102
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/dual-pkg/esm.mjs
@@ -0,0 +1 @@
+export default "dual esm";
diff --git a/tests/Jsx/resolution/app/node_modules/dual-pkg/package.json b/tests/Jsx/resolution/app/node_modules/dual-pkg/package.json
new file mode 100644
index 00000000..ae64e45a
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/dual-pkg/package.json
@@ -0,0 +1 @@
+{ "name": "dual-pkg", "exports": { "import": "./esm.mjs", "require": "./cjs.cjs" } }
diff --git a/tests/Jsx/resolution/app/node_modules/esm-pkg/dist/feature.js b/tests/Jsx/resolution/app/node_modules/esm-pkg/dist/feature.js
new file mode 100644
index 00000000..b73e5f81
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/esm-pkg/dist/feature.js
@@ -0,0 +1 @@
+export const feature = "feature";
diff --git a/tests/Jsx/resolution/app/node_modules/esm-pkg/dist/icons/star.js b/tests/Jsx/resolution/app/node_modules/esm-pkg/dist/icons/star.js
new file mode 100644
index 00000000..563a6590
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/esm-pkg/dist/icons/star.js
@@ -0,0 +1 @@
+export default "star icon";
diff --git a/tests/Jsx/resolution/app/node_modules/esm-pkg/dist/index.cjs b/tests/Jsx/resolution/app/node_modules/esm-pkg/dist/index.cjs
new file mode 100644
index 00000000..c817b41c
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/esm-pkg/dist/index.cjs
@@ -0,0 +1 @@
+module.exports = "WRONG: require build";
diff --git a/tests/Jsx/resolution/app/node_modules/esm-pkg/dist/index.js b/tests/Jsx/resolution/app/node_modules/esm-pkg/dist/index.js
new file mode 100644
index 00000000..847d0f2e
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/esm-pkg/dist/index.js
@@ -0,0 +1,3 @@
+console.log("eval esm-pkg");
+export default "esm default";
+export { feature } from "./feature.js";
diff --git a/tests/Jsx/resolution/app/node_modules/esm-pkg/package.json b/tests/Jsx/resolution/app/node_modules/esm-pkg/package.json
new file mode 100644
index 00000000..7d85e683
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/esm-pkg/package.json
@@ -0,0 +1,6 @@
+{ "name": "esm-pkg", "type": "module", "exports": {
+ ".": { "import": "./dist/index.js", "require": "./dist/index.cjs", "default": "./dist/index.js" },
+ "./feature": "./dist/feature.js",
+ "./icons/*": "./dist/icons/*.js",
+ "./internal/*": null
+} }
diff --git a/tests/Jsx/resolution/app/node_modules/esmodule-flag/index.js b/tests/Jsx/resolution/app/node_modules/esmodule-flag/index.js
new file mode 100644
index 00000000..7069b426
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/esmodule-flag/index.js
@@ -0,0 +1,4 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.default = "flagged default";
+exports.named = "flagged named";
diff --git a/tests/Jsx/resolution/app/node_modules/esmodule-flag/package.json b/tests/Jsx/resolution/app/node_modules/esmodule-flag/package.json
new file mode 100644
index 00000000..c38d21a4
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/esmodule-flag/package.json
@@ -0,0 +1 @@
+{ "name": "esmodule-flag", "main": "index.js" }
diff --git a/tests/Jsx/resolution/app/node_modules/imports-field/index.js b/tests/Jsx/resolution/app/node_modules/imports-field/index.js
new file mode 100644
index 00000000..19b307cd
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/imports-field/index.js
@@ -0,0 +1,3 @@
+import internal from "#internal";
+import { feature } from "#dep";
+export default internal + " + " + feature;
diff --git a/tests/Jsx/resolution/app/node_modules/imports-field/package.json b/tests/Jsx/resolution/app/node_modules/imports-field/package.json
new file mode 100644
index 00000000..a9a1832e
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/imports-field/package.json
@@ -0,0 +1 @@
+{ "name": "imports-field", "type": "module", "main": "index.js", "imports": { "#internal": "./src/internal.js", "#dep": "esm-pkg/feature" } }
diff --git a/tests/Jsx/resolution/app/node_modules/imports-field/src/internal.js b/tests/Jsx/resolution/app/node_modules/imports-field/src/internal.js
new file mode 100644
index 00000000..9685d442
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/imports-field/src/internal.js
@@ -0,0 +1 @@
+export default "internal";
diff --git a/tests/Jsx/resolution/app/node_modules/module-field/cjs.js b/tests/Jsx/resolution/app/node_modules/module-field/cjs.js
new file mode 100644
index 00000000..e37961ea
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/module-field/cjs.js
@@ -0,0 +1 @@
+module.exports = "WRONG: main";
diff --git a/tests/Jsx/resolution/app/node_modules/module-field/esm.js b/tests/Jsx/resolution/app/node_modules/module-field/esm.js
new file mode 100644
index 00000000..d3d5e522
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/module-field/esm.js
@@ -0,0 +1 @@
+export default "from module field";
diff --git a/tests/Jsx/resolution/app/node_modules/module-field/package.json b/tests/Jsx/resolution/app/node_modules/module-field/package.json
new file mode 100644
index 00000000..3acbd7cc
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/module-field/package.json
@@ -0,0 +1 @@
+{ "name": "module-field", "main": "cjs.js", "module": "esm.js" }
diff --git a/tests/Jsx/resolution/app/node_modules/node-env/esm.mjs b/tests/Jsx/resolution/app/node_modules/node-env/esm.mjs
new file mode 100644
index 00000000..b8ecddc5
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/node-env/esm.mjs
@@ -0,0 +1 @@
+export const mode = process.env.NODE_ENV;
diff --git a/tests/Jsx/resolution/app/node_modules/node-env/index.js b/tests/Jsx/resolution/app/node_modules/node-env/index.js
new file mode 100644
index 00000000..edbfce67
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/node-env/index.js
@@ -0,0 +1,4 @@
+module.exports = {
+ mode: process.env.NODE_ENV === "production" ? "prod mode" : "dev mode",
+ esmNodeEnv: require("./esm.mjs").mode,
+};
diff --git a/tests/Jsx/resolution/app/node_modules/node-env/package.json b/tests/Jsx/resolution/app/node_modules/node-env/package.json
new file mode 100644
index 00000000..6403848c
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/node-env/package.json
@@ -0,0 +1 @@
+{ "name": "node-env", "main": "index.js" }
diff --git a/tests/Jsx/resolution/app/node_modules/prod-pkg/dev.js b/tests/Jsx/resolution/app/node_modules/prod-pkg/dev.js
new file mode 100644
index 00000000..6f02984f
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/prod-pkg/dev.js
@@ -0,0 +1 @@
+module.exports = "WRONG: development";
diff --git a/tests/Jsx/resolution/app/node_modules/prod-pkg/package.json b/tests/Jsx/resolution/app/node_modules/prod-pkg/package.json
new file mode 100644
index 00000000..b0a06256
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/prod-pkg/package.json
@@ -0,0 +1 @@
+{ "name": "prod-pkg", "exports": { "development": "./dev.js", "production": "./prod.js", "default": "./dev.js" } }
diff --git a/tests/Jsx/resolution/app/node_modules/prod-pkg/prod.js b/tests/Jsx/resolution/app/node_modules/prod-pkg/prod.js
new file mode 100644
index 00000000..bae6f89c
--- /dev/null
+++ b/tests/Jsx/resolution/app/node_modules/prod-pkg/prod.js
@@ -0,0 +1 @@
+module.exports = "production build";
diff --git a/tests/Jsx/resolution/app/src/base-url-target.js b/tests/Jsx/resolution/app/src/base-url-target.js
new file mode 100644
index 00000000..7d8171f3
--- /dev/null
+++ b/tests/Jsx/resolution/app/src/base-url-target.js
@@ -0,0 +1 @@
+export default "from baseUrl";
diff --git a/tests/Jsx/resolution/app/src/components/Card.jsx b/tests/Jsx/resolution/app/src/components/Card.jsx
new file mode 100644
index 00000000..ff359b36
--- /dev/null
+++ b/tests/Jsx/resolution/app/src/components/Card.jsx
@@ -0,0 +1,2 @@
+console.log("eval Card");
+export default function Card() { return "card"; }
diff --git a/tests/Jsx/resolution/app/src/components/List/index.js b/tests/Jsx/resolution/app/src/components/List/index.js
new file mode 100644
index 00000000..d974505b
--- /dev/null
+++ b/tests/Jsx/resolution/app/src/components/List/index.js
@@ -0,0 +1 @@
+export default function List() { return "list"; }
diff --git a/tests/Jsx/resolution/app/src/data.json b/tests/Jsx/resolution/app/src/data.json
new file mode 100644
index 00000000..b88daea6
--- /dev/null
+++ b/tests/Jsx/resolution/app/src/data.json
@@ -0,0 +1 @@
+{ "answer": 42, "list": [1, 2] }
diff --git a/tests/Jsx/resolution/app/src/logo.svg b/tests/Jsx/resolution/app/src/logo.svg
new file mode 100644
index 00000000..0c7be4e2
--- /dev/null
+++ b/tests/Jsx/resolution/app/src/logo.svg
@@ -0,0 +1 @@
+
diff --git a/tests/Jsx/resolution/app/src/show.js b/tests/Jsx/resolution/app/src/show.js
new file mode 100644
index 00000000..1b5a48ac
--- /dev/null
+++ b/tests/Jsx/resolution/app/src/show.js
@@ -0,0 +1,3 @@
+export function show(label, ...values) {
+ console.log(label + ": " + values.map((v) => JSON.stringify(v)).join(" | "));
+}
diff --git a/tests/Jsx/resolution/app/src/style.css b/tests/Jsx/resolution/app/src/style.css
new file mode 100644
index 00000000..38a5229f
--- /dev/null
+++ b/tests/Jsx/resolution/app/src/style.css
@@ -0,0 +1 @@
+.card { color: red; }
diff --git a/tests/Jsx/resolution/app/src/utils/index.js b/tests/Jsx/resolution/app/src/utils/index.js
new file mode 100644
index 00000000..ea6f4e78
--- /dev/null
+++ b/tests/Jsx/resolution/app/src/utils/index.js
@@ -0,0 +1 @@
+export const util = "util";
From cb708b292e01bcf62c2020e4fe96983d6b7206cb Mon Sep 17 00:00:00 2001
From: dnnsjsk
Date: Tue, 13 Oct 2026 20:40:10 +0200
Subject: [PATCH 06/18] Fix two core engine bugs found by running real React in
Phasis
A call inside a try Block is never in tail position, but strict code
turned `try { return f(); } catch (e) { ... }` into a tail call, so the
exception skipped the catch. React's server renderer relies on exactly
this shape (renderNode), and lost content after a suspension. The Catch
block keeps proper tail calls when there is no Finally, as test262's tco
tests require.
Reflect.construct skipped class fields: a base class got none, and a
derived class with an implicit constructor missed its own. It now
initializes them where `new` does.
test262: language/statements/try, language/expressions/call and the other
tail-call areas, built-ins/Reflect, built-ins/Function and both class
categories all pass at 100%.
Co-Authored-By: Claude Opus 5.5
---
src/BuiltIn/ReflectObject.php | 15 +++++++
src/Runtime/Parts/StatementExecution.php | 15 +++++--
.../Runtime/ReflectConstructFieldsTest.php | 27 ++++++++++++
tests/Unit/Runtime/TailCallInTryTest.php | 42 +++++++++++++++++++
4 files changed, 95 insertions(+), 4 deletions(-)
create mode 100644 tests/Unit/Runtime/ReflectConstructFieldsTest.php
create mode 100644 tests/Unit/Runtime/TailCallInTryTest.php
diff --git a/src/BuiltIn/ReflectObject.php b/src/BuiltIn/ReflectObject.php
index c86cb9c9..1d98d93e 100644
--- a/src/BuiltIn/ReflectObject.php
+++ b/src/BuiltIn/ReflectObject.php
@@ -448,8 +448,23 @@ public static function install(Environment $env): void
'[[NewTarget]]',
PropertyDescriptor::data($ntValue, false, false, false),
);
+ // Class fields, as `new` initializes them: a base class
+ // before its body runs, a derived class with an implicit
+ // constructor after super() has produced `this`.
+ $interpreter = \Phasis\Engine::getCurrentInterpreter();
+ if ($interpreter !== null && $target->isClassConstructor() && !$target->isDerivedConstructor()) {
+ $interpreter->initializeInstanceFields($target, $newObj, $target->getPrivateEnv() ?? $interpreter->getGlobalEnv());
+ }
$result = $target->call($newObj, $callArgs);
$finalObj = ($result instanceof JsObject && $result !== $newObj) ? $result : $newObj;
+ if (
+ $interpreter !== null
+ && $target->isDerivedConstructor()
+ && $target->isNative()
+ && ($target->getPrivateMethodEntries() !== [] || $target->getInstanceFieldInitializers() !== [])
+ ) {
+ $interpreter->initializeInstanceFields($target, $finalObj, $target->getPrivateEnv() ?? $interpreter->getGlobalEnv());
+ }
// For native targets where newTarget !== target, apply
// newTarget.prototype after the body. Native [[Construct]]
// implementations rely on [[NewTarget]] for this lookup;
diff --git a/src/Runtime/Parts/StatementExecution.php b/src/Runtime/Parts/StatementExecution.php
index 255e871c..e621821a 100644
--- a/src/Runtime/Parts/StatementExecution.php
+++ b/src/Runtime/Parts/StatementExecution.php
@@ -2933,10 +2933,11 @@ private function execTryStatement(TryStatement $node, Environment $env): Complet
// shape: `s.length -= a` in the finally pre-mutated the path
// stack so the visitor saw the parent node instead of the
// pushed child and recursed forever on the same root.
+ // The try Block itself is never in tail position (§15.10.2
+ // HasCallInTailPosition): a catch clause must still see the call's
+ // exception, so `try { return f(); } catch (e) {}` cannot thunk f.
$savedTailPosForTry = $this->inTailPosition;
- if ($node->finalizer !== null) {
- $this->inTailPosition = false;
- }
+ $this->inTailPosition = false;
try {
$completion = $this->execBlockStatement($node->block, $env);
$this->inTailPosition = $savedTailPosForTry;
@@ -2990,7 +2991,13 @@ private function execTryStatement(TryStatement $node, Environment $env): Complet
$this->skipAnnexBHoisting = true;
$this->hoistDeclarations($node->handler->body->body, $bodyEnv);
$this->skipAnnexBHoisting = $savedSkip;
- $completion = $this->executeBody($node->handler->body->body, $bodyEnv);
+ // The Catch block is in tail position only without a Finally.
+ $this->inTailPosition = $node->finalizer === null && $savedTailPosForTry;
+ try {
+ $completion = $this->executeBody($node->handler->body->body, $bodyEnv);
+ } finally {
+ $this->inTailPosition = $savedTailPosForTry;
+ }
}
if ($node->finalizer !== null) {
diff --git a/tests/Unit/Runtime/ReflectConstructFieldsTest.php b/tests/Unit/Runtime/ReflectConstructFieldsTest.php
new file mode 100644
index 00000000..235e23bf
--- /dev/null
+++ b/tests/Unit/Runtime/ReflectConstructFieldsTest.php
@@ -0,0 +1,27 @@
+eval(<<<'JS'
+ class Base { x = 1; #p = 2; p() { return this.#p; } }
+ class Implicit extends Base { y = 3; }
+ class Explicit extends Base { y = 4; constructor() { super(); } }
+ function Plain() {}
+ class FromFunction extends Plain { z = 5; }
+ const b = Reflect.construct(Base, []);
+ console.log(b.x, b.p(), Reflect.construct(Implicit, []).y, Reflect.construct(Explicit, []).y, Reflect.construct(FromFunction, []).z);
+ JS);
+ $this->assertSame('1 2 3 4 5', $engine->getConsoleOutput());
+ }
+}
diff --git a/tests/Unit/Runtime/TailCallInTryTest.php b/tests/Unit/Runtime/TailCallInTryTest.php
new file mode 100644
index 00000000..9bb8e0d7
--- /dev/null
+++ b/tests/Unit/Runtime/TailCallInTryTest.php
@@ -0,0 +1,42 @@
+eval(<<<'JS'
+ 'use strict';
+ function thrower() { throw new Error('x'); }
+ function viaCatch() { try { return thrower(); } catch (e) { return 'caught ' + e.message; } }
+ function viaFinally() { try { return thrower(); } finally { console.log('finally ran'); } }
+ console.log(viaCatch());
+ try { viaFinally(); } catch (e) { console.log('outer'); }
+ JS);
+ $this->assertSame("caught x\nfinally ran\nouter", $engine->getConsoleOutput());
+ }
+
+ public function testCatchBlockKeepsProperTailCalls(): void
+ {
+ $engine = new Engine();
+ $engine->eval(<<<'JS'
+ 'use strict';
+ function count(n) { try { throw null; } catch (e) { return n === 0 ? 'done' : count(n - 1); } }
+ console.log(count(20000));
+ JS);
+ $this->assertSame('done', $engine->getConsoleOutput());
+ }
+}
From f9f135c99e7c91b07d03b10ba7f80d5b661968ea Mon Sep 17 00:00:00 2001
From: dnnsjsk
Date: Tue, 13 Oct 2026 20:40:37 +0200
Subject: [PATCH 07/18] Render React 19.3 natively in PHP (Phases 5-6)
react, react/jsx-runtime, react-dom, react-dom/server and react-dom/static
are built-in modules implemented in PHP. The server renderer is a port of
React's MIT-licensed Fizz sources (ReactFizzServer, ReactFizzHooks,
ReactFizzConfigDOM, legacy config for renderToString) and keeps React's
copyright notices; React's inline runtime scripts are copied byte for
byte. Only the application's components run as JavaScript.
- Elements are React 19's shape (JsElement, Symbol.for keys); jsx, jsxs,
createElement, cloneElement, Children, memo, forwardRef, lazy, context
in all three forms, Component and PureComponent.
- Hooks follow Fizz: render-phase updates, useId tree ids (_R_..._),
use() for context and promises, browser() client-only boundaries.
- renderToString, renderToStaticMarkup and prerender (waiting for async
components and use(promise), outlining large boundaries with $RC).
- Resource hints and hoisting: preloads, stylesheet precedence, async
scripts, title/meta/link, document preamble.
- Engine::render() and renderSource() render a component module with
PHP props (prerender semantics); getExport(); the CLI runs .mjs/.jsx
through the module loader and gains --render.
- React packages also default-export module.exports, as bundlers do.
Oracles: 51 React fixtures byte-identical to react-dom@19.3.0 in Node
(renderToString, renderToStaticMarkup, prerender) and through
Engine::render(); real React 19.3 (vendored edge build) inside Phasis
agrees on every fixture (three-way, which is also the exact-React
fallback); React's client hydrates every hydratable fixture in jsdom with
production and development builds (76/76 runs, zero recoverable errors,
every server node reused). The Goals fixture project renders through
Engine::render() byte-identical to its esbuild bundle in Node, and the
five React smoke packages (lucide-react, react-icons, @heroicons/react,
react-markdown, use-sync-external-store) match Node.
Co-Authored-By: Claude Opus 5.5
---
.gitignore | 1 +
bin/phasis | 51 +-
phpcs.xml | 2 +
src/BuiltIn/Jsx/Js.php | 408 ++
src/BuiltIn/Jsx/React/Fizz/Boundary.php | 46 +
.../Jsx/React/Fizz/BoundaryPreamble.php | 28 +
src/BuiltIn/Jsx/React/Fizz/Chunks.php | 22 +
src/BuiltIn/Jsx/React/Fizz/ContextNode.php | 31 +
src/BuiltIn/Jsx/React/Fizz/Destination.php | 21 +
src/BuiltIn/Jsx/React/Fizz/Dom.php | 1735 +++++
src/BuiltIn/Jsx/React/Fizz/FormatContext.php | 33 +
src/BuiltIn/Jsx/React/Fizz/HoistableState.php | 27 +
src/BuiltIn/Jsx/React/Fizz/Hooks.php | 497 ++
src/BuiltIn/Jsx/React/Fizz/PreambleState.php | 28 +
src/BuiltIn/Jsx/React/Fizz/RenderState.php | 92 +
src/BuiltIn/Jsx/React/Fizz/Request.php | 99 +
src/BuiltIn/Jsx/React/Fizz/Resources.php | 863 +++
src/BuiltIn/Jsx/React/Fizz/ResumableState.php | 59 +
src/BuiltIn/Jsx/React/Fizz/Scripts.php | 43 +
src/BuiltIn/Jsx/React/Fizz/Segment.php | 48 +
src/BuiltIn/Jsx/React/Fizz/Server.php | 2571 +++++++
src/BuiltIn/Jsx/React/Fizz/StyleQueue.php | 32 +
.../Jsx/React/Fizz/StylesheetResource.php | 26 +
src/BuiltIn/Jsx/React/Fizz/Task.php | 44 +
src/BuiltIn/Jsx/React/Fizz/TaskSet.php | 49 +
src/BuiltIn/Jsx/React/Fizz/TreeContext.php | 23 +
.../Jsx/React/Fizz/ViewTransitionState.php | 32 +
src/BuiltIn/Jsx/React/ReactDomModule.php | 357 +
src/BuiltIn/Jsx/React/ReactModule.php | 694 ++
src/BuiltIn/Jsx/React/ReactSymbols.php | 67 +
src/BuiltIn/SymbolConstructor.php | 6 +
src/Engine.php | 129 +
src/Module/ModuleLoader.php | 14 +-
src/RenderProfile.php | 18 +
src/Value/JsElement.php | 41 +
tests/Jsx/JsxGoalsTest.php | 70 +
tests/Jsx/JsxPackageSmokeTest.php | 47 +
tests/Jsx/JsxReactRenderTest.php | 60 +
tests/Jsx/JsxReactThreeWayTest.php | 53 +
tests/Jsx/ReactFixture.php | 21 +
tests/Jsx/oracle/build-scripts.mjs | 27 +
tests/Jsx/oracle/build-vendor.mjs | 51 +
tests/Jsx/oracle/hydrate-one.mjs | 77 +
tests/Jsx/oracle/hydrate.mjs | 76 +
tests/Jsx/oracle/jsx-oracle.mjs | 84 +
tests/Jsx/oracle/project/Page.jsx | 20 +
tests/Jsx/oracle/project/assets/avatar.svg | 1 +
tests/Jsx/oracle/project/comments.json | 1 +
.../project/components/Avatar/index.jsx | 11 +
.../oracle/project/components/Comments.jsx | 5 +
.../project/components/CommentsToggle.jsx | 14 +
.../oracle/project/components/PostCard.css | 2 +
.../oracle/project/components/PostCard.jsx | 15 +
tests/Jsx/oracle/project/expected.html | 1 +
tests/Jsx/oracle/project/jsconfig.json | 7 +
tests/Jsx/oracle/project/lib/text.js | 3 +
.../node_modules/site-config/dist/config.cjs | 2 +
.../node_modules/site-config/dist/config.js | 2 +
.../node_modules/site-config/package.json | 13 +
tests/Jsx/oracle/project/props.json | 10 +
tests/Jsx/oracle/smoke/heroicons.expected.txt | 1 +
tests/Jsx/oracle/smoke/heroicons.mjs | 6 +
.../oracle/smoke/lucide-react.expected.txt | 1 +
tests/Jsx/oracle/smoke/lucide-react.mjs | 4 +
.../Jsx/oracle/smoke/react-icons.expected.txt | 1 +
tests/Jsx/oracle/smoke/react-icons.mjs | 5 +
.../oracle/smoke/react-markdown.expected.txt | 11 +
tests/Jsx/oracle/smoke/react-markdown.mjs | 6 +
.../use-sync-external-store.expected.txt | 1 +
.../oracle/smoke/use-sync-external-store.mjs | 11 +
tests/Jsx/react-run.php | 37 +
tests/Jsx/react/_driver.mjs | 40 +
tests/Jsx/react/activity-modes.expected.txt | 5 +
tests/Jsx/react/activity-modes.jsx | 15 +
tests/Jsx/react/async-component.expected.txt | 5 +
tests/Jsx/react/async-component.jsx | 16 +
tests/Jsx/react/attribute-quirks.expected.txt | 5 +
tests/Jsx/react/attribute-quirks.jsx | 13 +
tests/Jsx/react/attributes.expected.txt | 5 +
tests/Jsx/react/attributes.jsx | 21 +
tests/Jsx/react/browser-only.expected.txt | 5 +
tests/Jsx/react/browser-only.jsx | 11 +
.../browser-outside-boundary.expected.txt | 5 +
tests/Jsx/react/browser-outside-boundary.jsx | 4 +
tests/Jsx/react/children-api.expected.txt | 5 +
tests/Jsx/react/children-api.jsx | 21 +
tests/Jsx/react/class-lifecycle.expected.txt | 5 +
tests/Jsx/react/class-lifecycle.jsx | 35 +
tests/Jsx/react/comments-toggle.expected.txt | 5 +
tests/Jsx/react/comments-toggle.jsx | 15 +
tests/Jsx/react/components.expected.txt | 5 +
tests/Jsx/react/components.jsx | 25 +
tests/Jsx/react/context.expected.txt | 5 +
tests/Jsx/react/context.jsx | 20 +
tests/Jsx/react/custom-elements.expected.txt | 5 +
tests/Jsx/react/custom-elements.jsx | 11 +
tests/Jsx/react/document.expected.txt | 5 +
tests/Jsx/react/document.jsx | 22 +
.../Jsx/react/error-root-throws.expected.txt | 5 +
tests/Jsx/react/error-root-throws.jsx | 2 +
tests/Jsx/react/escaping.expected.txt | 5 +
tests/Jsx/react/escaping.jsx | 11 +
tests/Jsx/react/form-actions.expected.txt | 5 +
tests/Jsx/react/form-actions.jsx | 8 +
tests/Jsx/react/forms.expected.txt | 5 +
tests/Jsx/react/forms.jsx | 30 +
tests/Jsx/react/fragments-keys.expected.txt | 5 +
tests/Jsx/react/fragments-keys.jsx | 18 +
tests/Jsx/react/head-only.expected.txt | 5 +
tests/Jsx/react/head-only.jsx | 8 +
.../Jsx/react/hoisting-fragment.expected.txt | 5 +
tests/Jsx/react/hoisting-fragment.jsx | 14 +
tests/Jsx/react/hooks.expected.txt | 5 +
tests/Jsx/react/hooks.jsx | 35 +
tests/Jsx/react/images.expected.txt | 5 +
tests/Jsx/react/images.jsx | 14 +
.../react/inner-html-both-throws.expected.txt | 5 +
tests/Jsx/react/inner-html-both-throws.jsx | 3 +
tests/Jsx/react/inner-html.expected.txt | 5 +
tests/Jsx/react/inner-html.jsx | 13 +
.../react/invalid-child-throws.expected.txt | 5 +
tests/Jsx/react/invalid-child-throws.jsx | 1 +
.../react/invalid-type-throws.expected.txt | 5 +
tests/Jsx/react/invalid-type-throws.jsx | 2 +
tests/Jsx/react/large-boundary.expected.txt | 5 +
tests/Jsx/react/large-boundary.jsx | 12 +
tests/Jsx/react/lazy.expected.txt | 5 +
tests/Jsx/react/lazy.jsx | 5 +
.../react/nested-suspense-async.expected.txt | 5 +
tests/Jsx/react/nested-suspense-async.jsx | 10 +
.../Jsx/react/obsolete-elements.expected.txt | 5 +
tests/Jsx/react/obsolete-elements.jsx | 12 +
tests/Jsx/react/portal-throws.expected.txt | 5 +
tests/Jsx/react/portal-throws.jsx | 2 +
.../Jsx/react/raw-text-escaping.expected.txt | 5 +
tests/Jsx/react/raw-text-escaping.jsx | 12 +
.../react/render-phase-update.expected.txt | 5 +
tests/Jsx/react/render-phase-update.jsx | 13 +
tests/Jsx/react/resource-apis.expected.txt | 5 +
tests/Jsx/react/resource-apis.jsx | 14 +
.../react/style-string-throws.expected.txt | 5 +
tests/Jsx/react/style-string-throws.jsx | 3 +
tests/Jsx/react/style.expected.txt | 5 +
tests/Jsx/react/style.jsx | 9 +
tests/Jsx/react/suspense-error.expected.txt | 5 +
tests/Jsx/react/suspense-error.jsx | 13 +
.../suspense-outside-boundary.expected.txt | 5 +
tests/Jsx/react/suspense-outside-boundary.jsx | 4 +
.../suspense-resolved-promise.expected.txt | 5 +
tests/Jsx/react/suspense-resolved-promise.jsx | 6 +
.../Jsx/react/suspense-resolved.expected.txt | 5 +
tests/Jsx/react/suspense-resolved.jsx | 10 +
.../react/suspense-use-promise.expected.txt | 5 +
tests/Jsx/react/suspense-use-promise.jsx | 15 +
tests/Jsx/react/svg-math.expected.txt | 5 +
tests/Jsx/react/svg-math.jsx | 14 +
tests/Jsx/react/tables.expected.txt | 5 +
tests/Jsx/react/tables.jsx | 14 +
tests/Jsx/react/text-markers.expected.txt | 5 +
tests/Jsx/react/text-markers.jsx | 12 +
.../too-many-rerenders-throws.expected.txt | 5 +
tests/Jsx/react/too-many-rerenders-throws.jsx | 6 +
tests/Jsx/react/urls.expected.txt | 5 +
tests/Jsx/react/urls.jsx | 13 +
tests/Jsx/react/use-id-deep.expected.txt | 5 +
tests/Jsx/react/use-id-deep.jsx | 7 +
tests/Jsx/react/use-id-prefix.expected.txt | 5 +
tests/Jsx/react/use-id-prefix.jsx | 3 +
tests/Jsx/react/use-id.expected.txt | 5 +
tests/Jsx/react/use-id.jsx | 25 +
tests/Jsx/react/view-transition.expected.txt | 5 +
tests/Jsx/react/view-transition.jsx | 10 +
.../react/void-children-throws.expected.txt | 5 +
tests/Jsx/react/void-children-throws.jsx | 3 +
tests/Jsx/react/void-elements.expected.txt | 5 +
tests/Jsx/react/void-elements.jsx | 8 +
tests/Jsx/vendor/react-19.3.0/LICENSE | 21 +
.../vendor/react-19.3.0/chunk-66PTQVN5.mjs | 5986 +++++++++++++++++
.../vendor/react-19.3.0/chunk-B7F5QLE5.mjs | 34 +
.../vendor/react-19.3.0/chunk-SN3JDJTZ.mjs | 202 +
.../vendor/react-19.3.0/chunk-YGJZAC6A.mjs | 480 ++
tests/Jsx/vendor/react-19.3.0/jsx-runtime.mjs | 66 +
tests/Jsx/vendor/react-19.3.0/react-dom.mjs | 30 +
tests/Jsx/vendor/react-19.3.0/react.mjs | 58 +
tests/Jsx/vendor/react-19.3.0/server.mjs | 5217 ++++++++++++++
tests/Jsx/vendor/react-19.3.0/static.mjs | 36 +
tests/Unit/Jsx/JsxLoweringTest.php | 68 +
tests/Unit/Jsx/ReactProfileTest.php | 86 +
tests/Unit/Jsx/RenderCoreTest.php | 64 +
tests/Unit/Module/BarrelPruningTest.php | 65 +
tests/Unit/Module/CommonJsTest.php | 63 +
tests/Unit/Module/ImportMapTest.php | 49 +
tests/Unit/Module/PackageExportsTest.php | 82 +
tests/Unit/Module/ResolverTest.php | 80 +
tests/Unit/Module/TempProject.php | 41 +
third-party/README.md | 8 +
third-party/react/LICENSE | 21 +
197 files changed, 22687 insertions(+), 7 deletions(-)
create mode 100644 src/BuiltIn/Jsx/Js.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/Boundary.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/BoundaryPreamble.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/Chunks.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/ContextNode.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/Destination.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/Dom.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/FormatContext.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/HoistableState.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/Hooks.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/PreambleState.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/RenderState.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/Request.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/Resources.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/ResumableState.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/Scripts.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/Segment.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/Server.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/StyleQueue.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/StylesheetResource.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/Task.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/TaskSet.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/TreeContext.php
create mode 100644 src/BuiltIn/Jsx/React/Fizz/ViewTransitionState.php
create mode 100644 src/BuiltIn/Jsx/React/ReactDomModule.php
create mode 100644 src/BuiltIn/Jsx/React/ReactModule.php
create mode 100644 src/BuiltIn/Jsx/React/ReactSymbols.php
create mode 100644 src/RenderProfile.php
create mode 100644 src/Value/JsElement.php
create mode 100644 tests/Jsx/JsxGoalsTest.php
create mode 100644 tests/Jsx/JsxReactRenderTest.php
create mode 100644 tests/Jsx/JsxReactThreeWayTest.php
create mode 100644 tests/Jsx/ReactFixture.php
create mode 100644 tests/Jsx/oracle/build-scripts.mjs
create mode 100644 tests/Jsx/oracle/build-vendor.mjs
create mode 100644 tests/Jsx/oracle/hydrate-one.mjs
create mode 100644 tests/Jsx/oracle/hydrate.mjs
create mode 100644 tests/Jsx/oracle/project/Page.jsx
create mode 100644 tests/Jsx/oracle/project/assets/avatar.svg
create mode 100644 tests/Jsx/oracle/project/comments.json
create mode 100644 tests/Jsx/oracle/project/components/Avatar/index.jsx
create mode 100644 tests/Jsx/oracle/project/components/Comments.jsx
create mode 100644 tests/Jsx/oracle/project/components/CommentsToggle.jsx
create mode 100644 tests/Jsx/oracle/project/components/PostCard.css
create mode 100644 tests/Jsx/oracle/project/components/PostCard.jsx
create mode 100644 tests/Jsx/oracle/project/expected.html
create mode 100644 tests/Jsx/oracle/project/jsconfig.json
create mode 100644 tests/Jsx/oracle/project/lib/text.js
create mode 100644 tests/Jsx/oracle/project/node_modules/site-config/dist/config.cjs
create mode 100644 tests/Jsx/oracle/project/node_modules/site-config/dist/config.js
create mode 100644 tests/Jsx/oracle/project/node_modules/site-config/package.json
create mode 100644 tests/Jsx/oracle/project/props.json
create mode 100644 tests/Jsx/oracle/smoke/heroicons.expected.txt
create mode 100644 tests/Jsx/oracle/smoke/heroicons.mjs
create mode 100644 tests/Jsx/oracle/smoke/lucide-react.expected.txt
create mode 100644 tests/Jsx/oracle/smoke/lucide-react.mjs
create mode 100644 tests/Jsx/oracle/smoke/react-icons.expected.txt
create mode 100644 tests/Jsx/oracle/smoke/react-icons.mjs
create mode 100644 tests/Jsx/oracle/smoke/react-markdown.expected.txt
create mode 100644 tests/Jsx/oracle/smoke/react-markdown.mjs
create mode 100644 tests/Jsx/oracle/smoke/use-sync-external-store.expected.txt
create mode 100644 tests/Jsx/oracle/smoke/use-sync-external-store.mjs
create mode 100644 tests/Jsx/react-run.php
create mode 100644 tests/Jsx/react/_driver.mjs
create mode 100644 tests/Jsx/react/activity-modes.expected.txt
create mode 100644 tests/Jsx/react/activity-modes.jsx
create mode 100644 tests/Jsx/react/async-component.expected.txt
create mode 100644 tests/Jsx/react/async-component.jsx
create mode 100644 tests/Jsx/react/attribute-quirks.expected.txt
create mode 100644 tests/Jsx/react/attribute-quirks.jsx
create mode 100644 tests/Jsx/react/attributes.expected.txt
create mode 100644 tests/Jsx/react/attributes.jsx
create mode 100644 tests/Jsx/react/browser-only.expected.txt
create mode 100644 tests/Jsx/react/browser-only.jsx
create mode 100644 tests/Jsx/react/browser-outside-boundary.expected.txt
create mode 100644 tests/Jsx/react/browser-outside-boundary.jsx
create mode 100644 tests/Jsx/react/children-api.expected.txt
create mode 100644 tests/Jsx/react/children-api.jsx
create mode 100644 tests/Jsx/react/class-lifecycle.expected.txt
create mode 100644 tests/Jsx/react/class-lifecycle.jsx
create mode 100644 tests/Jsx/react/comments-toggle.expected.txt
create mode 100644 tests/Jsx/react/comments-toggle.jsx
create mode 100644 tests/Jsx/react/components.expected.txt
create mode 100644 tests/Jsx/react/components.jsx
create mode 100644 tests/Jsx/react/context.expected.txt
create mode 100644 tests/Jsx/react/context.jsx
create mode 100644 tests/Jsx/react/custom-elements.expected.txt
create mode 100644 tests/Jsx/react/custom-elements.jsx
create mode 100644 tests/Jsx/react/document.expected.txt
create mode 100644 tests/Jsx/react/document.jsx
create mode 100644 tests/Jsx/react/error-root-throws.expected.txt
create mode 100644 tests/Jsx/react/error-root-throws.jsx
create mode 100644 tests/Jsx/react/escaping.expected.txt
create mode 100644 tests/Jsx/react/escaping.jsx
create mode 100644 tests/Jsx/react/form-actions.expected.txt
create mode 100644 tests/Jsx/react/form-actions.jsx
create mode 100644 tests/Jsx/react/forms.expected.txt
create mode 100644 tests/Jsx/react/forms.jsx
create mode 100644 tests/Jsx/react/fragments-keys.expected.txt
create mode 100644 tests/Jsx/react/fragments-keys.jsx
create mode 100644 tests/Jsx/react/head-only.expected.txt
create mode 100644 tests/Jsx/react/head-only.jsx
create mode 100644 tests/Jsx/react/hoisting-fragment.expected.txt
create mode 100644 tests/Jsx/react/hoisting-fragment.jsx
create mode 100644 tests/Jsx/react/hooks.expected.txt
create mode 100644 tests/Jsx/react/hooks.jsx
create mode 100644 tests/Jsx/react/images.expected.txt
create mode 100644 tests/Jsx/react/images.jsx
create mode 100644 tests/Jsx/react/inner-html-both-throws.expected.txt
create mode 100644 tests/Jsx/react/inner-html-both-throws.jsx
create mode 100644 tests/Jsx/react/inner-html.expected.txt
create mode 100644 tests/Jsx/react/inner-html.jsx
create mode 100644 tests/Jsx/react/invalid-child-throws.expected.txt
create mode 100644 tests/Jsx/react/invalid-child-throws.jsx
create mode 100644 tests/Jsx/react/invalid-type-throws.expected.txt
create mode 100644 tests/Jsx/react/invalid-type-throws.jsx
create mode 100644 tests/Jsx/react/large-boundary.expected.txt
create mode 100644 tests/Jsx/react/large-boundary.jsx
create mode 100644 tests/Jsx/react/lazy.expected.txt
create mode 100644 tests/Jsx/react/lazy.jsx
create mode 100644 tests/Jsx/react/nested-suspense-async.expected.txt
create mode 100644 tests/Jsx/react/nested-suspense-async.jsx
create mode 100644 tests/Jsx/react/obsolete-elements.expected.txt
create mode 100644 tests/Jsx/react/obsolete-elements.jsx
create mode 100644 tests/Jsx/react/portal-throws.expected.txt
create mode 100644 tests/Jsx/react/portal-throws.jsx
create mode 100644 tests/Jsx/react/raw-text-escaping.expected.txt
create mode 100644 tests/Jsx/react/raw-text-escaping.jsx
create mode 100644 tests/Jsx/react/render-phase-update.expected.txt
create mode 100644 tests/Jsx/react/render-phase-update.jsx
create mode 100644 tests/Jsx/react/resource-apis.expected.txt
create mode 100644 tests/Jsx/react/resource-apis.jsx
create mode 100644 tests/Jsx/react/style-string-throws.expected.txt
create mode 100644 tests/Jsx/react/style-string-throws.jsx
create mode 100644 tests/Jsx/react/style.expected.txt
create mode 100644 tests/Jsx/react/style.jsx
create mode 100644 tests/Jsx/react/suspense-error.expected.txt
create mode 100644 tests/Jsx/react/suspense-error.jsx
create mode 100644 tests/Jsx/react/suspense-outside-boundary.expected.txt
create mode 100644 tests/Jsx/react/suspense-outside-boundary.jsx
create mode 100644 tests/Jsx/react/suspense-resolved-promise.expected.txt
create mode 100644 tests/Jsx/react/suspense-resolved-promise.jsx
create mode 100644 tests/Jsx/react/suspense-resolved.expected.txt
create mode 100644 tests/Jsx/react/suspense-resolved.jsx
create mode 100644 tests/Jsx/react/suspense-use-promise.expected.txt
create mode 100644 tests/Jsx/react/suspense-use-promise.jsx
create mode 100644 tests/Jsx/react/svg-math.expected.txt
create mode 100644 tests/Jsx/react/svg-math.jsx
create mode 100644 tests/Jsx/react/tables.expected.txt
create mode 100644 tests/Jsx/react/tables.jsx
create mode 100644 tests/Jsx/react/text-markers.expected.txt
create mode 100644 tests/Jsx/react/text-markers.jsx
create mode 100644 tests/Jsx/react/too-many-rerenders-throws.expected.txt
create mode 100644 tests/Jsx/react/too-many-rerenders-throws.jsx
create mode 100644 tests/Jsx/react/urls.expected.txt
create mode 100644 tests/Jsx/react/urls.jsx
create mode 100644 tests/Jsx/react/use-id-deep.expected.txt
create mode 100644 tests/Jsx/react/use-id-deep.jsx
create mode 100644 tests/Jsx/react/use-id-prefix.expected.txt
create mode 100644 tests/Jsx/react/use-id-prefix.jsx
create mode 100644 tests/Jsx/react/use-id.expected.txt
create mode 100644 tests/Jsx/react/use-id.jsx
create mode 100644 tests/Jsx/react/view-transition.expected.txt
create mode 100644 tests/Jsx/react/view-transition.jsx
create mode 100644 tests/Jsx/react/void-children-throws.expected.txt
create mode 100644 tests/Jsx/react/void-children-throws.jsx
create mode 100644 tests/Jsx/react/void-elements.expected.txt
create mode 100644 tests/Jsx/react/void-elements.jsx
create mode 100644 tests/Jsx/vendor/react-19.3.0/LICENSE
create mode 100644 tests/Jsx/vendor/react-19.3.0/chunk-66PTQVN5.mjs
create mode 100644 tests/Jsx/vendor/react-19.3.0/chunk-B7F5QLE5.mjs
create mode 100644 tests/Jsx/vendor/react-19.3.0/chunk-SN3JDJTZ.mjs
create mode 100644 tests/Jsx/vendor/react-19.3.0/chunk-YGJZAC6A.mjs
create mode 100644 tests/Jsx/vendor/react-19.3.0/jsx-runtime.mjs
create mode 100644 tests/Jsx/vendor/react-19.3.0/react-dom.mjs
create mode 100644 tests/Jsx/vendor/react-19.3.0/react.mjs
create mode 100644 tests/Jsx/vendor/react-19.3.0/server.mjs
create mode 100644 tests/Jsx/vendor/react-19.3.0/static.mjs
create mode 100644 tests/Unit/Jsx/JsxLoweringTest.php
create mode 100644 tests/Unit/Jsx/ReactProfileTest.php
create mode 100644 tests/Unit/Jsx/RenderCoreTest.php
create mode 100644 tests/Unit/Module/BarrelPruningTest.php
create mode 100644 tests/Unit/Module/CommonJsTest.php
create mode 100644 tests/Unit/Module/ImportMapTest.php
create mode 100644 tests/Unit/Module/PackageExportsTest.php
create mode 100644 tests/Unit/Module/ResolverTest.php
create mode 100644 tests/Unit/Module/TempProject.php
create mode 100644 third-party/README.md
create mode 100644 third-party/react/LICENSE
diff --git a/.gitignore b/.gitignore
index 0a22a03b..dde2f9a0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,4 @@ tests/Wpt/upstream/
# JSX Pack oracle toolchain: lockfile is committed, dependencies are not.
tests/Jsx/oracle/node_modules/
!tests/Jsx/oracle/package-lock.json
+tests/Jsx/oracle/.out/
diff --git a/bin/phasis b/bin/phasis
index 1cf69768..dc60b347 100755
--- a/bin/phasis
+++ b/bin/phasis
@@ -22,7 +22,28 @@ use Phasis\Engine;
use Phasis\Parser\Parser;
$args = array_slice($argv, 1);
-$opts = getopt('e:', ['ast', 'repl']);
+$opts = getopt('e:', ['ast', 'repl', 'render:', 'props:']);
+
+if (isset($opts['render'])) {
+ $file = (string) $opts['render'];
+ if (!file_exists($file)) {
+ fwrite(STDERR, "Usage: phasis --render [--props '']\n");
+ exit(1);
+ }
+ $props = isset($opts['props']) ? json_decode((string) $opts['props'], true) : [];
+ if (!is_array($props)) {
+ fwrite(STDERR, "--props must be a JSON object\n");
+ exit(1);
+ }
+ $engine = new Engine();
+ try {
+ echo $engine->render($file, $props) . "\n";
+ } catch (\Throwable $e) {
+ fwrite(STDERR, $e->getMessage() . "\n");
+ exit(1);
+ }
+ exit(0);
+}
if (isset($opts['e'])) {
$source = $opts['e'];
@@ -42,17 +63,34 @@ if (in_array('--ast', $args, true)) {
fwrite(STDERR, "Usage: phasis --ast \n");
exit(1);
}
- outputAst(file_get_contents($file));
+ outputAst(file_get_contents($file), $file);
exit(0);
}
// Default: execute a file
$file = $args[0] ?? null;
if ($file === null || !file_exists($file)) {
- fwrite(STDERR, "Usage: phasis | phasis -e '' | phasis --ast \n");
+ fwrite(STDERR, "Usage: phasis | phasis -e '' | phasis --ast | phasis --render [--props '']\n");
exit(1);
}
+$jsx = new \Phasis\Parser\Jsx\JsxOptions();
+if (str_ends_with(strtolower($file), '.mjs') || $jsx->appliesTo($file)) {
+ // Modules (.mjs, .jsx) go through the module loader so imports resolve.
+ $engine = new Engine();
+ try {
+ $engine->execFile($file);
+ $output = $engine->getConsoleOutput();
+ if ($output !== '') {
+ echo $output . "\n";
+ }
+ } catch (\Throwable $e) {
+ fwrite(STDERR, $e->getMessage() . "\n");
+ exit(1);
+ }
+ exit(0);
+}
+
executeSource(file_get_contents($file));
function executeSource(string $source): void
@@ -70,9 +108,12 @@ function executeSource(string $source): void
}
}
-function outputAst(string $source): void
+function outputAst(string $source, ?string $file = null): void
{
- echo jsonIndent2(Serializer::toArray(Parser::parseSource($source))) . "\n";
+ $jsx = new \Phasis\Parser\Jsx\JsxOptions(mode: \Phasis\Parser\Jsx\JsxMode::Preserve);
+ $isJsx = $file !== null && $jsx->appliesTo($file);
+ $ast = Parser::parseSource($source, $isJsx ? 'module' : 'script', $isJsx ? $jsx : null);
+ echo jsonIndent2(Serializer::toArray($ast)) . "\n";
}
function jsonIndent2(mixed $data): string
diff --git a/phpcs.xml b/phpcs.xml
index a25c39ab..60024f95 100644
--- a/phpcs.xml
+++ b/phpcs.xml
@@ -8,6 +8,8 @@
src/Regex/Unicode16Tables.php
src/Regex/FoldTable.php
src/BuiltIn/data/*
+
+ src/BuiltIn/Jsx/React/Fizz/Scripts.php
tests/Popular/*
tests/Jsx/*/*
diff --git a/src/BuiltIn/Jsx/Js.php b/src/BuiltIn/Jsx/Js.php
new file mode 100644
index 00000000..19973e45
--- /dev/null
+++ b/src/BuiltIn/Jsx/Js.php
@@ -0,0 +1,408 @@
+ Prototype id => [mutationVersion, has enumerable keys]. */
+ private static array $protoEnumerable = [];
+
+ public static function undef(): JsUndefined
+ {
+ return JsUndefined::instance();
+ }
+
+ public static function null(): JsNull
+ {
+ return JsNull::instance();
+ }
+
+ /** `value == null`: null or undefined. */
+ public static function isNullish(JsValue $value): bool
+ {
+ return $value instanceof JsUndefined || $value instanceof JsNull;
+ }
+
+ public static function typeof(JsValue $value): string
+ {
+ return $value->typeof();
+ }
+
+ public static function isObject(JsValue $value): bool
+ {
+ return $value instanceof JsObject && !$value instanceof JsFunction;
+ }
+
+ public static function isFunction(JsValue $value): bool
+ {
+ return $value instanceof JsFunction;
+ }
+
+ /** `value[key]`, including reads on primitives. */
+ public static function get(JsValue $value, string $key): JsValue
+ {
+ if ($value instanceof JsObject) {
+ return $value->properties->dataSlots[$key] ?? $value->get($key);
+ }
+ if ($value instanceof JsUndefined || $value instanceof JsNull) {
+ throw new TypeError("Cannot read properties of {$value->display()} (reading '{$key}')");
+ }
+ return TypeConversion::toObject($value)->getWithValueReceiver($key, $value);
+ }
+
+ /** `value[symbol]`. */
+ public static function getSymbol(JsValue $value, JsSymbol $symbol): JsValue
+ {
+ if ($value instanceof JsObject) {
+ return $value->getBySymbol($symbol);
+ }
+ if ($value instanceof JsUndefined || $value instanceof JsNull) {
+ throw new TypeError("Cannot read properties of {$value->display()} (reading '{$symbol->display()}')");
+ }
+ return TypeConversion::toObject($value)->getBySymbolWithReceiver($symbol, $value);
+ }
+
+ public static function set(JsObject $object, string $key, JsValue $value): void
+ {
+ $object->set($key, $value, true);
+ }
+
+ /**
+ * Call a JavaScript function. Throws TypeError, as JS does, when the
+ * value is not callable.
+ *
+ * @param list $args
+ */
+ public static function call(JsValue $fn, JsValue $thisValue, array $args = []): JsValue
+ {
+ if (!$fn instanceof JsFunction) {
+ throw new TypeError(self::describe($fn) . ' is not a function');
+ }
+ return $fn->call($thisValue, $args);
+ }
+
+ /**
+ * `new Ctor(...args)`.
+ *
+ * @param list $args
+ */
+ public static function construct(JsValue $ctor, array $args = []): JsValue
+ {
+ if (!$ctor instanceof JsFunction || !$ctor->isConstructable()) {
+ throw new TypeError(self::describe($ctor) . ' is not a constructor');
+ }
+ // Reflect.construct runs the realm's full [[Construct]] (classes,
+ // derived constructors, field initializers), like `new` in JS.
+ $interpreter = Engine::getCurrentInterpreter();
+ $reflect = $interpreter?->getGlobalEnv()->get('Reflect');
+ $construct = $reflect instanceof JsObject ? $reflect->get('construct') : null;
+ if (!$construct instanceof JsFunction) {
+ return $ctor->construct($args);
+ }
+ return $construct->call(JsUndefined::instance(), [$ctor, new JsArray($args)]);
+ }
+
+ /** JS ToString. */
+ public static function str(JsValue $value): string
+ {
+ if ($value instanceof JsString) {
+ return $value->value;
+ }
+ return TypeConversion::toString($value);
+ }
+
+ public static function string(string $value): JsString
+ {
+ return new JsString($value);
+ }
+
+ public static function number(float|int $value): JsNumber
+ {
+ return JsNumber::of((float) $value);
+ }
+
+ public static function bool(bool $value): JsBoolean
+ {
+ return JsBoolean::of($value);
+ }
+
+ /** JS ToBoolean. */
+ public static function truthy(JsValue $value): bool
+ {
+ return $value->toBoolean();
+ }
+
+ /** `x === y`. */
+ public static function same(JsValue $x, JsValue $y): bool
+ {
+ if ($x === $y) {
+ return !($x instanceof JsNumber && is_nan($x->value));
+ }
+ return AbstractOperations::strictEquals($x, $y);
+ }
+
+ /** `Object.is(x, y)`. */
+ public static function is(JsValue $x, JsValue $y): bool
+ {
+ return AbstractOperations::sameValue($x, $y);
+ }
+
+ public static function isArray(JsValue $value): bool
+ {
+ if ($value instanceof JsArray) {
+ return true;
+ }
+ if ($value instanceof \Phasis\Value\JsProxy) {
+ return $value->isRevoked() || self::isArray($value->getTarget());
+ }
+ return false;
+ }
+
+ /**
+ * Elements of a JS array, with holes as undefined.
+ *
+ * @return list
+ */
+ public static function elements(JsValue $array): array
+ {
+ if ($array instanceof JsArray && $array->isDenseMode()) {
+ $out = [];
+ $undefined = JsUndefined::instance();
+ foreach ($array->getDenseElements() as $element) {
+ $out[] = $element ?? $undefined;
+ }
+ $length = $array->getLength();
+ for ($i = count($out); $i < $length; $i++) {
+ $out[] = $undefined;
+ }
+ return $out;
+ }
+ if ($array instanceof JsObject) {
+ $length = (int) TypeConversion::toLength($array->get('length'));
+ $out = [];
+ for ($i = 0; $i < $length; $i++) {
+ $out[] = $array->get((string) $i);
+ }
+ return $out;
+ }
+ return [];
+ }
+
+ /** @param list $values */
+ public static function array(array $values = []): JsArray
+ {
+ return new JsArray($values);
+ }
+
+ /** @param array $properties */
+ public static function object(array $properties = []): JsObject
+ {
+ $object = new JsObject();
+ foreach ($properties as $key => $value) {
+ $object->properties->setDataSlot((string) $key, $value);
+ }
+ return $object;
+ }
+
+ /**
+ * Keys a `for (key in object)` loop visits: own and inherited enumerable
+ * string keys. Plain objects take a fast path.
+ *
+ * @return list
+ */
+ public static function forInKeys(JsObject $object): array
+ {
+ $own = self::ownEnumerableKeys($object);
+ $proto = $object->prototype;
+ while ($proto !== null) {
+ if ($proto instanceof \Phasis\Value\JsProxy || self::hasEnumerableKeys($proto)) {
+ return $object->getEnumerableKeys();
+ }
+ $proto = $proto->prototype;
+ }
+ return $own;
+ }
+
+ /**
+ * `Object.keys(object)`.
+ *
+ * @return list
+ */
+ public static function ownEnumerableKeys(JsObject $object): array
+ {
+ $properties = $object->properties;
+ if ($properties->descriptors === [] && get_class($object) === JsObject::class) {
+ $keys = [];
+ foreach ($properties->dataSlots as $key => $_) {
+ if (is_int($key) || ($key[0] === '[' && str_starts_with($key, '[['))) {
+ return $object->getOwnEnumerableKeys();
+ }
+ $keys[] = $key;
+ }
+ return $keys;
+ }
+ return $object->getOwnEnumerableKeys();
+ }
+
+ private static function hasEnumerableKeys(JsObject $proto): bool
+ {
+ $id = spl_object_id($proto);
+ $version = $proto->properties->mutationVersion;
+ $cached = self::$protoEnumerable[$id] ?? null;
+ if ($cached !== null && $cached[0] === $version) {
+ return $cached[1];
+ }
+ $has = $proto->properties->dataSlots !== [];
+ if (!$has) {
+ foreach ($proto->properties->descriptors as $desc) {
+ if ($desc->enumerable === true) {
+ $has = true;
+ break;
+ }
+ }
+ }
+ self::$protoEnumerable[$id] = [$version, $has];
+ return $has;
+ }
+
+ public static function hasOwn(JsObject $object, string $key): bool
+ {
+ return isset($object->properties->dataSlots[$key]) || $object->hasOwnProperty($key);
+ }
+
+ /** The `in` operator for string keys. */
+ public static function has(JsObject $object, string $key): bool
+ {
+ return isset($object->properties->dataSlots[$key]) || $object->has($key);
+ }
+
+ public static function symbolFor(string $key): JsSymbol
+ {
+ return SymbolConstructor::registered($key);
+ }
+
+ /** A JS `Error` (or subclass) object, created through the realm's constructor. */
+ public static function makeError(string $message, string $type = 'Error', ?JsValue $cause = null): JsObject
+ {
+ $interpreter = Engine::getCurrentInterpreter();
+ $ctor = null;
+ if ($interpreter !== null) {
+ try {
+ $ctor = $interpreter->getGlobalEnv()->get($type);
+ } catch (\Throwable) {
+ $ctor = null;
+ }
+ }
+ if ($ctor instanceof JsFunction) {
+ $args = [new JsString($message)];
+ if ($cause !== null) {
+ $args[] = self::object(['cause' => $cause]);
+ }
+ $error = self::construct($ctor, $args);
+ if ($error instanceof JsObject) {
+ return $error;
+ }
+ }
+ $error = new JsObject();
+ $error->set('message', new JsString($message));
+ $error->set('name', new JsString($type));
+ return $error;
+ }
+
+ /** `throw Error(message)`. */
+ public static function error(string $message, string $type = 'Error', ?JsValue $cause = null): JsThrowable
+ {
+ return new JsThrowable(self::makeError($message, $type, $cause));
+ }
+
+ /**
+ * The JS value a PHP exception stands for, as a JS catch block would
+ * see it. Resource-limit errors are not catchable and are rethrown.
+ */
+ public static function thrownValue(\Throwable $e): JsValue
+ {
+ if ($e instanceof JsThrowable) {
+ return $e->jsValue;
+ }
+ if ($e instanceof InternalError || !$e instanceof \Phasis\Exceptions\RuntimeError && !$e instanceof \Phasis\Exceptions\SyntaxError) {
+ throw $e;
+ }
+ $interpreter = Engine::getCurrentInterpreter();
+ if ($interpreter === null) {
+ throw $e;
+ }
+ return $interpreter->phpExceptionToJsValue($e);
+ }
+
+ /** Rethrow a JS value as a PHP exception. */
+ public static function throw(JsValue $value): never
+ {
+ throw new JsThrowable($value);
+ }
+
+ /** A native JS function. */
+ public static function fn(string $name, \Closure $fn, int $length = 0): JsFunction
+ {
+ return JsFunction::fromCallable($name, $fn, $length);
+ }
+
+ public static function defineHidden(JsObject $object, string $key, JsValue $value): void
+ {
+ $object->defineOwnProperty($key, PropertyDescriptor::data($value, true, false, true));
+ }
+
+ /** `Object.prototype.toString.call(value)` for error messages. */
+ public static function objectTag(JsValue $value): string
+ {
+ $tagSymbol = SymbolConstructor::toStringTag();
+ if ($value instanceof JsObject) {
+ $tag = $value->getBySymbol($tagSymbol);
+ if ($tag instanceof JsString) {
+ return '[object ' . $tag->value . ']';
+ }
+ return match (true) {
+ self::isArray($value) => '[object Array]',
+ $value instanceof JsFunction => '[object Function]',
+ default => '[object Object]',
+ };
+ }
+ return '[object ' . ucfirst($value->typeof()) . ']';
+ }
+
+ private static function describe(JsValue $value): string
+ {
+ return match (true) {
+ $value instanceof JsString => '"' . $value->value . '"',
+ $value instanceof JsObject => self::objectTag($value) === '[object Array]' ? 'object' : 'object',
+ $value instanceof JsBigInt, $value instanceof JsNumber, $value instanceof JsBoolean => $value->display(),
+ default => $value->display(),
+ };
+ }
+}
diff --git a/src/BuiltIn/Jsx/React/Fizz/Boundary.php b/src/BuiltIn/Jsx/React/Fizz/Boundary.php
new file mode 100644
index 00000000..5d7468d5
--- /dev/null
+++ b/src/BuiltIn/Jsx/React/Fizz/Boundary.php
@@ -0,0 +1,46 @@
+ */
+ public array $completedSegments = [];
+
+ public int $byteSize = 0;
+ public ?string $errorDigest = null;
+ public HoistableState $contentState;
+ public HoistableState $fallbackState;
+
+ public function __construct(
+ public TaskSet $fallbackAbortableTasks,
+ public ?BoundaryPreamble $preamble,
+ public bool $defer,
+ ) {
+ $this->contentState = new HoistableState();
+ $this->fallbackState = new HoistableState();
+ }
+}
diff --git a/src/BuiltIn/Jsx/React/Fizz/BoundaryPreamble.php b/src/BuiltIn/Jsx/React/Fizz/BoundaryPreamble.php
new file mode 100644
index 00000000..3d19ce36
--- /dev/null
+++ b/src/BuiltIn/Jsx/React/Fizz/BoundaryPreamble.php
@@ -0,0 +1,28 @@
+content = new PreambleState();
+ $this->fallback = new PreambleState();
+ }
+}
diff --git a/src/BuiltIn/Jsx/React/Fizz/Chunks.php b/src/BuiltIn/Jsx/React/Fizz/Chunks.php
new file mode 100644
index 00000000..36f6ead2
--- /dev/null
+++ b/src/BuiltIn/Jsx/React/Fizz/Chunks.php
@@ -0,0 +1,22 @@
+ */
+ public array $c = [];
+}
diff --git a/src/BuiltIn/Jsx/React/Fizz/ContextNode.php b/src/BuiltIn/Jsx/React/Fizz/ContextNode.php
new file mode 100644
index 00000000..f3a67d6f
--- /dev/null
+++ b/src/BuiltIn/Jsx/React/Fizz/ContextNode.php
@@ -0,0 +1,31 @@
+';
+ private const ACTION_JAVASCRIPT_URL = 'javascript:throw new Error('React form unexpectedly submitted.')';
+ private const BLOCKED_URL = "javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')";
+
+ private const UNITLESS_NUMBERS = [
+ 'animationIterationCount' => true, 'aspectRatio' => true, 'borderImageOutset' => true,
+ 'borderImageSlice' => true, 'borderImageWidth' => true, 'boxFlex' => true, 'boxFlexGroup' => true,
+ 'boxOrdinalGroup' => true, 'columnCount' => true, 'columns' => true, 'flex' => true, 'flexGrow' => true,
+ 'flexPositive' => true, 'flexShrink' => true, 'flexNegative' => true, 'flexOrder' => true,
+ 'gridArea' => true, 'gridRow' => true, 'gridRowEnd' => true, 'gridRowSpan' => true,
+ 'gridRowStart' => true, 'gridColumn' => true, 'gridColumnEnd' => true, 'gridColumnSpan' => true,
+ 'gridColumnStart' => true, 'fontWeight' => true, 'lineClamp' => true, 'lineHeight' => true,
+ 'opacity' => true, 'order' => true, 'orphans' => true, 'scale' => true, 'tabSize' => true,
+ 'widows' => true, 'zIndex' => true, 'zoom' => true, 'fillOpacity' => true, 'floodOpacity' => true,
+ 'stopOpacity' => true, 'strokeDasharray' => true, 'strokeDashoffset' => true,
+ 'strokeMiterlimit' => true, 'strokeOpacity' => true, 'strokeWidth' => true,
+ 'MozAnimationIterationCount' => true, 'MozBoxFlex' => true, 'MozBoxFlexGroup' => true,
+ 'MozLineClamp' => true, 'msAnimationIterationCount' => true, 'msFlex' => true, 'msZoom' => true,
+ 'msFlexGrow' => true, 'msFlexNegative' => true, 'msFlexOrder' => true, 'msFlexPositive' => true,
+ 'msFlexShrink' => true, 'msGridColumn' => true, 'msGridColumnSpan' => true, 'msGridRow' => true,
+ 'msGridRowSpan' => true, 'WebkitAnimationIterationCount' => true, 'WebkitBoxFlex' => true,
+ 'WebKitBoxFlexGroup' => true, 'WebkitBoxOrdinalGroup' => true, 'WebkitColumnCount' => true,
+ 'WebkitColumns' => true, 'WebkitFlex' => true, 'WebkitFlexGrow' => true,
+ 'WebkitFlexPositive' => true, 'WebkitFlexShrink' => true, 'WebkitLineClamp' => true,
+ ];
+
+ private const ALIASES = [
+ 'acceptCharset' => 'accept-charset', 'htmlFor' => 'for', 'httpEquiv' => 'http-equiv',
+ 'crossOrigin' => 'crossorigin', 'accentHeight' => 'accent-height',
+ 'alignmentBaseline' => 'alignment-baseline', 'arabicForm' => 'arabic-form',
+ 'baselineShift' => 'baseline-shift', 'capHeight' => 'cap-height', 'clipPath' => 'clip-path',
+ 'clipRule' => 'clip-rule', 'colorInterpolation' => 'color-interpolation',
+ 'colorInterpolationFilters' => 'color-interpolation-filters', 'colorProfile' => 'color-profile',
+ 'colorRendering' => 'color-rendering', 'dominantBaseline' => 'dominant-baseline',
+ 'enableBackground' => 'enable-background', 'fillOpacity' => 'fill-opacity', 'fillRule' => 'fill-rule',
+ 'floodColor' => 'flood-color', 'floodOpacity' => 'flood-opacity', 'fontFamily' => 'font-family',
+ 'fontSize' => 'font-size', 'fontSizeAdjust' => 'font-size-adjust', 'fontStretch' => 'font-stretch',
+ 'fontStyle' => 'font-style', 'fontVariant' => 'font-variant', 'fontWeight' => 'font-weight',
+ 'glyphName' => 'glyph-name', 'glyphOrientationHorizontal' => 'glyph-orientation-horizontal',
+ 'glyphOrientationVertical' => 'glyph-orientation-vertical', 'horizAdvX' => 'horiz-adv-x',
+ 'horizOriginX' => 'horiz-origin-x', 'imageRendering' => 'image-rendering',
+ 'letterSpacing' => 'letter-spacing', 'lightingColor' => 'lighting-color', 'markerEnd' => 'marker-end',
+ 'markerMid' => 'marker-mid', 'markerStart' => 'marker-start', 'maskType' => 'mask-type',
+ 'overlinePosition' => 'overline-position', 'overlineThickness' => 'overline-thickness',
+ 'paintOrder' => 'paint-order', 'panose-1' => 'panose-1', 'pointerEvents' => 'pointer-events',
+ 'renderingIntent' => 'rendering-intent', 'shapeRendering' => 'shape-rendering',
+ 'stopColor' => 'stop-color', 'stopOpacity' => 'stop-opacity',
+ 'strikethroughPosition' => 'strikethrough-position',
+ 'strikethroughThickness' => 'strikethrough-thickness', 'strokeDasharray' => 'stroke-dasharray',
+ 'strokeDashoffset' => 'stroke-dashoffset', 'strokeLinecap' => 'stroke-linecap',
+ 'strokeLinejoin' => 'stroke-linejoin', 'strokeMiterlimit' => 'stroke-miterlimit',
+ 'strokeOpacity' => 'stroke-opacity', 'strokeWidth' => 'stroke-width', 'textAnchor' => 'text-anchor',
+ 'textDecoration' => 'text-decoration', 'textRendering' => 'text-rendering',
+ 'transformOrigin' => 'transform-origin', 'underlinePosition' => 'underline-position',
+ 'underlineThickness' => 'underline-thickness', 'unicodeBidi' => 'unicode-bidi',
+ 'unicodeRange' => 'unicode-range', 'unitsPerEm' => 'units-per-em', 'vAlphabetic' => 'v-alphabetic',
+ 'vHanging' => 'v-hanging', 'vIdeographic' => 'v-ideographic', 'vMathematical' => 'v-mathematical',
+ 'vectorEffect' => 'vector-effect', 'vertAdvY' => 'vert-adv-y', 'vertOriginX' => 'vert-origin-x',
+ 'vertOriginY' => 'vert-origin-y', 'wordSpacing' => 'word-spacing', 'writingMode' => 'writing-mode',
+ 'xmlnsXlink' => 'xmlns:xlink', 'xHeight' => 'x-height',
+ ];
+
+ /** React's VALID_ATTRIBUTE_NAME_REGEX (XML Name production). */
+ private const ATTRIBUTE_NAME_START = ':A-Z_a-z\x{00C0}-\x{00D6}\x{00D8}-\x{00F6}\x{00F8}-\x{02FF}\x{0370}-\x{037D}\x{037F}-\x{1FFF}'
+ . '\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}';
+ private const ATTRIBUTE_NAME_PATTERN = '/^[' . self::ATTRIBUTE_NAME_START . '][' . self::ATTRIBUTE_NAME_START
+ . '\-.0-9\x{00B7}\x{0300}-\x{036F}\x{203F}-\x{2040}]*$/u';
+
+ /** React's isJavaScriptProtocol. */
+ private const JAVASCRIPT_URL_PATTERN = '/^[\x00-\x1F ]*j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*a[\r\n\t]*s'
+ . '[\r\n\t]*c[\r\n\t]*r[\r\n\t]*i[\r\n\t]*p[\r\n\t]*t[\r\n\t]*:/i';
+
+ private const ESCAPES = ['"' => '"', '&' => '&', "'" => ''', '<' => '<', '>' => '>'];
+
+ /** @var array */
+ private static array $attributeNameCache = [];
+
+ /** @var array */
+ private static array $styleNameCache = [];
+
+ /** @var array */
+ private static array $tagStartCache = [];
+
+ // Text and attribute escaping.
+
+ public static function escapeText(string $text): string
+ {
+ if (strpbrk($text, '"&\'<>') === false) {
+ return $text;
+ }
+ return strtr($text, self::ESCAPES);
+ }
+
+ /** escapeTextForBrowser: booleans, numbers and bigints are stringified as-is. */
+ public static function esc(JsValue|string $value): string
+ {
+ if (is_string($value)) {
+ return self::escapeText($value);
+ }
+ if ($value instanceof JsString) {
+ return self::escapeText($value->value);
+ }
+ if ($value instanceof JsBoolean || $value instanceof JsNumber || $value instanceof JsBigInt) {
+ return Js::str($value);
+ }
+ return self::escapeText(Js::str($value));
+ }
+
+ public static function isAttributeNameSafe(string $name): bool
+ {
+ if (isset(self::$attributeNameCache[$name])) {
+ return self::$attributeNameCache[$name];
+ }
+ $ok = preg_match(
+ self::ATTRIBUTE_NAME_PATTERN,
+ $name,
+ ) === 1;
+ return self::$attributeNameCache[$name] = $ok;
+ }
+
+ public static function sanitizeURL(string $url): string
+ {
+ if (
+ preg_match(self::JAVASCRIPT_URL_PATTERN, $url) === 1
+ ) {
+ return self::BLOCKED_URL;
+ }
+ return $url;
+ }
+
+ /**
+ * Own enumerable props with non-nullish values, in for-in order: the
+ * `for (propKey in props) if (hasOwnProperty.call(props, propKey))`
+ * loops of React.
+ *
+ * @param JsObject|array $props
+ * @return list
+ */
+ public static function propEntries(JsObject|array $props): array
+ {
+ $entries = [];
+ if (is_array($props)) {
+ foreach ($props as $key => $value) {
+ if (!$value instanceof JsUndefined && !$value instanceof JsNull) {
+ $entries[] = [(string) $key, $value];
+ }
+ }
+ return $entries;
+ }
+ $slots = $props->properties;
+ if ($slots->descriptors === [] && get_class($props) === JsObject::class) {
+ foreach ($slots->dataSlots as $key => $value) {
+ if (is_int($key)) {
+ return self::propEntriesSlow($props);
+ }
+ if (!$value instanceof JsUndefined && !$value instanceof JsNull) {
+ $entries[] = [$key, $value];
+ }
+ }
+ return $entries;
+ }
+ return self::propEntriesSlow($props);
+ }
+
+ /** @return list */
+ private static function propEntriesSlow(JsObject $props): array
+ {
+ $entries = [];
+ foreach (Js::ownEnumerableKeys($props) as $key) {
+ $value = Js::get($props, $key);
+ if (!$value instanceof JsUndefined && !$value instanceof JsNull) {
+ $entries[] = [$key, $value];
+ }
+ }
+ return $entries;
+ }
+
+ /**
+ * `props[name]` for a JS props object or an internal props array.
+ *
+ * @param JsObject|array $props
+ */
+ public static function prop(JsObject|array $props, string $name): JsValue
+ {
+ if (is_array($props)) {
+ return $props[$name] ?? JsUndefined::instance();
+ }
+ return $props->properties->dataSlots[$name] ?? $props->get($name);
+ }
+
+ private static function nullish(?JsValue $value): bool
+ {
+ return $value === null || $value instanceof JsUndefined || $value instanceof JsNull;
+ }
+
+ private static function fnOrSymbol(JsValue $value): bool
+ {
+ return $value instanceof JsFunction || $value instanceof JsSymbol;
+ }
+
+ // Styles and attributes.
+
+ /** @param list $target */
+ public static function pushStyleAttribute(array &$target, JsValue $style): void
+ {
+ if (!$style instanceof JsObject || $style instanceof JsFunction) {
+ throw Js::error(
+ "The `style` prop expects a mapping from style properties to values, not a string. For example, "
+ . "style={{marginRight: spacing + 'em'}} when using JSX.",
+ );
+ }
+ $isFirst = true;
+ foreach (self::propEntries($style) as [$styleName, $styleValue]) {
+ if ($styleValue instanceof JsBoolean || ($styleValue instanceof JsString && $styleValue->value === '')) {
+ continue;
+ }
+ if (str_starts_with($styleName, '--')) {
+ $nameChunk = self::escapeText($styleName);
+ $valueChunk = self::escapeText(TypeConversion::trimWhitespace(Js::str($styleValue)));
+ } else {
+ $nameChunk = self::$styleNameCache[$styleName] ??= self::escapeText(self::hyphenateStyleName($styleName));
+ if ($styleValue instanceof JsNumber) {
+ $valueChunk = $styleValue->value === 0.0 || isset(self::UNITLESS_NUMBERS[$styleName])
+ ? Js::str($styleValue)
+ : Js::str($styleValue) . 'px';
+ } else {
+ $valueChunk = self::escapeText(TypeConversion::trimWhitespace(Js::str($styleValue)));
+ }
+ }
+ if ($isFirst) {
+ $isFirst = false;
+ $target[] = ' style="';
+ } else {
+ $target[] = ';';
+ }
+ $target[] = $nameChunk;
+ $target[] = ':';
+ $target[] = $valueChunk;
+ }
+ if (!$isFirst) {
+ $target[] = '"';
+ }
+ }
+
+ private static function hyphenateStyleName(string $name): string
+ {
+ $name = mb_strtolower((string) preg_replace('/([A-Z])/', '-$1', $name), 'UTF-8');
+ return (string) preg_replace('/^ms-/', '-ms-', $name);
+ }
+
+ /** @param list $target */
+ public static function pushBooleanAttribute(array &$target, string $name, JsValue $value): void
+ {
+ if ($value->toBoolean() && !self::fnOrSymbol($value)) {
+ $target[] = ' ' . $name . '=""';
+ }
+ }
+
+ /** @param list $target */
+ public static function pushStringAttribute(array &$target, string $name, JsValue|string $value): void
+ {
+ if (is_string($value)) {
+ $target[] = ' ' . $name . '="' . self::escapeText($value) . '"';
+ return;
+ }
+ if (!self::fnOrSymbol($value) && !$value instanceof JsBoolean) {
+ $target[] = ' ' . $name . '="' . self::esc($value) . '"';
+ }
+ }
+
+ /** @param list $target */
+ public static function pushAttribute(array &$target, string $name, JsValue $value): void
+ {
+ switch ($name) {
+ case 'className':
+ self::pushStringAttribute($target, 'class', $value);
+ return;
+ case 'tabIndex':
+ self::pushStringAttribute($target, 'tabindex', $value);
+ return;
+ case 'dir':
+ case 'role':
+ case 'viewBox':
+ case 'width':
+ case 'height':
+ self::pushStringAttribute($target, $name, $value);
+ return;
+ case 'style':
+ self::pushStyleAttribute($target, $value);
+ return;
+ case 'src':
+ case 'href':
+ if ($value instanceof JsString && $value->value === '') {
+ return;
+ }
+ // fall through
+ case 'action':
+ case 'formAction':
+ if (self::nullish($value) || self::fnOrSymbol($value) || $value instanceof JsBoolean) {
+ return;
+ }
+ $target[] = ' ' . $name . '="' . self::escapeText(self::sanitizeURL(Js::str($value))) . '"';
+ return;
+ case 'defaultValue':
+ case 'defaultChecked':
+ case 'innerHTML':
+ case 'suppressContentEditableWarning':
+ case 'suppressHydrationWarning':
+ case 'ref':
+ return;
+ case 'autoFocus':
+ case 'multiple':
+ case 'muted':
+ self::pushBooleanAttribute($target, strtolower($name), $value);
+ return;
+ case 'xlinkHref':
+ if (self::fnOrSymbol($value) || $value instanceof JsBoolean) {
+ return;
+ }
+ $target[] = ' xlink:href="' . self::escapeText(self::sanitizeURL(Js::str($value))) . '"';
+ return;
+ case 'contentEditable':
+ case 'spellCheck':
+ case 'draggable':
+ case 'value':
+ case 'autoReverse':
+ case 'externalResourcesRequired':
+ case 'focusable':
+ case 'preserveAlpha':
+ if (!self::fnOrSymbol($value)) {
+ $target[] = ' ' . $name . '="' . self::esc($value) . '"';
+ }
+ return;
+ case 'inert':
+ case 'allowFullScreen':
+ case 'async':
+ case 'autoPlay':
+ case 'controls':
+ case 'credentialless':
+ case 'default':
+ case 'defer':
+ case 'disabled':
+ case 'disablePictureInPicture':
+ case 'disableRemotePlayback':
+ case 'formNoValidate':
+ case 'hidden':
+ case 'loop':
+ case 'noModule':
+ case 'noValidate':
+ case 'open':
+ case 'playsInline':
+ case 'readOnly':
+ case 'required':
+ case 'reversed':
+ case 'scoped':
+ case 'seamless':
+ case 'itemScope':
+ if ($value->toBoolean() && !self::fnOrSymbol($value)) {
+ $target[] = ' ' . $name . '=""';
+ }
+ return;
+ case 'capture':
+ case 'download':
+ if ($value instanceof JsBoolean && $value->value) {
+ $target[] = ' ' . $name . '=""';
+ } elseif (!($value instanceof JsBoolean && !$value->value) && !self::fnOrSymbol($value)) {
+ $target[] = ' ' . $name . '="' . self::esc($value) . '"';
+ }
+ return;
+ case 'cols':
+ case 'rows':
+ case 'size':
+ case 'span':
+ if (!self::fnOrSymbol($value)) {
+ $number = TypeConversion::toNumber($value);
+ if (!is_nan($number) && $number >= 1) {
+ $target[] = ' ' . $name . '="' . self::esc($value) . '"';
+ }
+ }
+ return;
+ case 'rowSpan':
+ case 'start':
+ if (!self::fnOrSymbol($value) && !is_nan(TypeConversion::toNumber($value))) {
+ $target[] = ' ' . $name . '="' . self::esc($value) . '"';
+ }
+ return;
+ case 'xlinkActuate':
+ self::pushStringAttribute($target, 'xlink:actuate', $value);
+ return;
+ case 'xlinkArcrole':
+ self::pushStringAttribute($target, 'xlink:arcrole', $value);
+ return;
+ case 'xlinkRole':
+ self::pushStringAttribute($target, 'xlink:role', $value);
+ return;
+ case 'xlinkShow':
+ self::pushStringAttribute($target, 'xlink:show', $value);
+ return;
+ case 'xlinkTitle':
+ self::pushStringAttribute($target, 'xlink:title', $value);
+ return;
+ case 'xlinkType':
+ self::pushStringAttribute($target, 'xlink:type', $value);
+ return;
+ case 'xmlBase':
+ self::pushStringAttribute($target, 'xml:base', $value);
+ return;
+ case 'xmlLang':
+ self::pushStringAttribute($target, 'xml:lang', $value);
+ return;
+ case 'xmlSpace':
+ self::pushStringAttribute($target, 'xml:space', $value);
+ return;
+ }
+ if (strlen($name) > 2 && ($name[0] === 'o' || $name[0] === 'O') && ($name[1] === 'n' || $name[1] === 'N')) {
+ return;
+ }
+ $name = self::ALIASES[$name] ?? $name;
+ if (!self::isAttributeNameSafe($name)) {
+ return;
+ }
+ if (self::fnOrSymbol($value)) {
+ return;
+ }
+ if ($value instanceof JsBoolean) {
+ $prefix = substr(strtolower($name), 0, 5);
+ if ($prefix !== 'data-' && $prefix !== 'aria-') {
+ return;
+ }
+ }
+ $target[] = ' ' . $name . '="' . self::esc($value) . '"';
+ }
+
+ /** @param list $target */
+ public static function pushInnerHTML(array &$target, ?JsValue $innerHTML, ?JsValue $children): void
+ {
+ if (self::nullish($innerHTML)) {
+ return;
+ }
+ if (!self::nullish($children)) {
+ throw Js::error('Can only set one of `children` or `props.dangerouslySetInnerHTML`.');
+ }
+ if (!$innerHTML instanceof JsObject || $innerHTML instanceof JsFunction || !Js::has($innerHTML, '__html')) {
+ throw Js::error(
+ '`props.dangerouslySetInnerHTML` must be in the form `{__html: ...}`. Please visit '
+ . 'https://react.dev/link/dangerously-set-inner-html for more information.',
+ );
+ }
+ $html = Js::get($innerHTML, '__html');
+ if (!self::nullish($html)) {
+ $target[] = Js::str($html);
+ }
+ }
+
+ private static function flattenOptionChildren(JsValue $children): string
+ {
+ $content = new JsString('');
+ $children = ReactModule::childrenToArray($children);
+ foreach ($children as $child) {
+ if (!self::nullish($child)) {
+ $content = AbstractOperations::add($content, $child);
+ }
+ }
+ return Js::str($content);
+ }
+
+ // Forms.
+
+ private static function injectFormReplayingRuntime(ResumableState $resumableState, RenderState $renderState): void
+ {
+ if (($resumableState->instructions & 16) !== 0) {
+ return;
+ }
+ $resumableState->instructions |= 16;
+ $preamble = $renderState->preamble;
+ if (($preamble->htmlChunks !== null || $preamble->headChunks !== null) && $renderState->bootstrapChunks === []) {
+ $renderState->bootstrapChunks[] = $renderState->startInlineScript;
+ self::pushCompletedShellIdAttribute($renderState->bootstrapChunks, $resumableState);
+ $renderState->bootstrapChunks[] = '>';
+ $renderState->bootstrapChunks[] = Scripts::FORM_REPLAYING_RUNTIME_SCRIPT;
+ $renderState->bootstrapChunks[] = '';
+ } else {
+ array_unshift($renderState->bootstrapChunks, $renderState->startInlineScript, '>', Scripts::FORM_REPLAYING_RUNTIME_SCRIPT, '');
+ }
+ }
+
+ /** @param list $target */
+ public static function pushCompletedShellIdAttribute(array &$target, ResumableState $resumableState): void
+ {
+ if (($resumableState->instructions & 32) === 0) {
+ $resumableState->instructions |= 32;
+ $target[] = ' id="';
+ $target[] = self::escapeText('_' . $resumableState->idPrefix . 'R_');
+ $target[] = '"';
+ }
+ }
+
+ private static function getCustomFormFields(ResumableState $resumableState, JsValue $formAction): ?JsValue
+ {
+ $formActionFn = Js::get($formAction, '$$FORM_ACTION');
+ if ($formActionFn instanceof JsFunction) {
+ $id = $resumableState->nextFormID++;
+ $prefix = $resumableState->idPrefix . $id;
+ try {
+ $customFields = Js::call($formActionFn, $formAction, [new JsString($prefix)]);
+ if ($customFields->toBoolean()) {
+ $formData = Js::get($customFields, 'data');
+ if (!self::nullish($formData)) {
+ self::forEachFormField($formData, static function (JsValue $value): void {
+ self::validateAdditionalFormField($value);
+ });
+ }
+ }
+ return $customFields;
+ } catch (\Throwable $e) {
+ $x = Js::thrownValue($e);
+ if ($x instanceof JsObject && Js::get($x, 'then') instanceof JsFunction) {
+ throw $e;
+ }
+ }
+ }
+ return null;
+ }
+
+ private static function validateAdditionalFormField(JsValue $value): void
+ {
+ if (!$value instanceof JsString) {
+ throw Js::error('File/Blob fields are not yet supported in progressive forms. Will fallback to client hydration.');
+ }
+ }
+
+ /** @param \Closure(JsValue, JsValue): void $callback */
+ private static function forEachFormField(JsValue $formData, \Closure $callback): void
+ {
+ Js::call(Js::get($formData, 'forEach'), $formData, [
+ Js::fn('', static function (JsValue $this_, array $args) use ($callback): JsValue {
+ $callback($args[0] ?? JsUndefined::instance(), $args[1] ?? JsUndefined::instance());
+ return JsUndefined::instance();
+ }, 2),
+ ]);
+ }
+
+ /** @param list $target */
+ private static function pushAdditionalFormFields(array &$target, JsValue $formData): void
+ {
+ self::forEachFormField($formData, static function (JsValue $value, JsValue $key) use (&$target): void {
+ $target[] = ' ';
+ });
+ }
+
+ /** @param list $target */
+ private static function pushFormActionAttribute(
+ array &$target,
+ ResumableState $resumableState,
+ RenderState $renderState,
+ ?JsValue $formAction,
+ ?JsValue $formEncType,
+ ?JsValue $formMethod,
+ ?JsValue $formTarget,
+ ?JsValue $name,
+ ): ?JsValue {
+ $formData = null;
+ if ($formAction instanceof JsFunction) {
+ $customFields = self::getCustomFormFields($resumableState, $formAction);
+ if ($customFields !== null) {
+ $name = Js::get($customFields, 'name');
+ $action = Js::get($customFields, 'action');
+ $formAction = $action->toBoolean() ? $action : new JsString('');
+ $formEncType = Js::get($customFields, 'encType');
+ $formMethod = Js::get($customFields, 'method');
+ $formTarget = Js::get($customFields, 'target');
+ $formData = Js::get($customFields, 'data');
+ } else {
+ $target[] = ' formAction="' . self::ACTION_JAVASCRIPT_URL . '"';
+ $formTarget = $formMethod = $formEncType = $formAction = $name = null;
+ self::injectFormReplayingRuntime($resumableState, $renderState);
+ }
+ }
+ if (!self::nullish($name)) {
+ self::pushAttribute($target, 'name', $name);
+ }
+ if (!self::nullish($formAction)) {
+ self::pushAttribute($target, 'formAction', $formAction);
+ }
+ if (!self::nullish($formEncType)) {
+ self::pushAttribute($target, 'formEncType', $formEncType);
+ }
+ if (!self::nullish($formMethod)) {
+ self::pushAttribute($target, 'formMethod', $formMethod);
+ }
+ if (!self::nullish($formTarget)) {
+ self::pushAttribute($target, 'formTarget', $formTarget);
+ }
+ return $formData;
+ }
+
+ // Element start tags.
+
+ public static function startChunkForTag(string $tag): string
+ {
+ if (isset(self::$tagStartCache[$tag])) {
+ return self::$tagStartCache[$tag];
+ }
+ if (preg_match('/^[a-zA-Z][a-zA-Z:_\.\-\d]*$/', $tag) !== 1) {
+ throw Js::error('Invalid tag: ' . $tag);
+ }
+ return self::$tagStartCache[$tag] = '<' . $tag;
+ }
+
+ public static function endChunkForTag(string $tag): string
+ {
+ return '' . $tag . '>';
+ }
+
+ /**
+ * @param list $target
+ * @param JsObject|array $props
+ */
+ public static function pushLinkImpl(array &$target, JsObject|array $props): null
+ {
+ $target[] = self::startChunkForTag('link');
+ foreach (self::propEntries($props) as [$key, $value]) {
+ if ($key === 'children' || $key === 'dangerouslySetInnerHTML') {
+ throw Js::error('link is a self-closing tag and must neither have `children` nor use `dangerouslySetInnerHTML`.');
+ }
+ self::pushAttribute($target, $key, $value);
+ }
+ $target[] = '/>';
+ return null;
+ }
+
+ /** @param list $target */
+ private static function pushSelfClosing(array &$target, JsObject $props, string $tag, FormatContext $formatContext): null
+ {
+ $target[] = self::startChunkForTag($tag);
+ foreach (self::propEntries($props) as [$key, $value]) {
+ if ($key === 'children' || $key === 'dangerouslySetInnerHTML') {
+ throw Js::error($tag . ' is a self-closing tag and must neither have `children` nor use `dangerouslySetInnerHTML`.');
+ }
+ self::pushAttribute($target, $key, $value);
+ }
+ self::pushViewTransitionAttributes($target, $formatContext);
+ $target[] = '/>';
+ return null;
+ }
+
+ /** @param list $target */
+ private static function pushTitleImpl(array &$target, JsObject $props): null
+ {
+ $target[] = self::startChunkForTag('title');
+ $children = null;
+ $innerHTML = null;
+ foreach (self::propEntries($props) as [$key, $value]) {
+ switch ($key) {
+ case 'children':
+ $children = $value;
+ break;
+ case 'dangerouslySetInnerHTML':
+ $innerHTML = $value;
+ break;
+ default:
+ self::pushAttribute($target, $key, $value);
+ }
+ }
+ $target[] = '>';
+ $child = $children;
+ if ($children !== null && Js::isArray($children)) {
+ $elements = Js::elements($children);
+ $child = count($elements) < 2 ? ($elements[0] ?? JsUndefined::instance()) : null;
+ }
+ if ($child !== null && !self::fnOrSymbol($child) && !self::nullish($child)) {
+ $target[] = self::escapeText(Js::str($child));
+ }
+ self::pushInnerHTML($target, $innerHTML, $children);
+ $target[] = self::endChunkForTag('title');
+ return null;
+ }
+
+ /**
+ * @param list $target
+ * @param JsObject|array $props
+ */
+ public static function pushScriptImpl(array &$target, JsObject|array $props): null
+ {
+ $target[] = self::startChunkForTag('script');
+ $children = null;
+ $innerHTML = null;
+ foreach (self::propEntries($props) as [$key, $value]) {
+ switch ($key) {
+ case 'children':
+ $children = $value;
+ break;
+ case 'dangerouslySetInnerHTML':
+ $innerHTML = $value;
+ break;
+ default:
+ self::pushAttribute($target, $key, $value);
+ }
+ }
+ $target[] = '>';
+ self::pushInnerHTML($target, $innerHTML, $children);
+ if ($children instanceof JsString) {
+ $target[] = self::escapeScript($children->value);
+ }
+ $target[] = self::endChunkForTag('script');
+ return null;
+ }
+
+ public static function escapeScript(string $text): string
+ {
+ return (string) preg_replace_callback(
+ '/(<\/|<)(s)(cript)/i',
+ static fn (array $m): string => $m[1] . ($m[2] === 's' ? '\\u0073' : '\\u0053') . $m[3],
+ $text,
+ );
+ }
+
+ public static function escapeStyle(string $text): string
+ {
+ return (string) preg_replace_callback(
+ '/(<\/|<)(s)(tyle)/i',
+ static fn (array $m): string => $m[1] . ($m[2] === 's' ? '\\73 ' : '\\53 ') . $m[3],
+ $text,
+ );
+ }
+
+ /** @param list $target */
+ private static function pushStartSingletonElement(array &$target, JsObject $props, string $tag, FormatContext $formatContext): ?JsValue
+ {
+ $target[] = self::startChunkForTag($tag);
+ $children = null;
+ $innerHTML = null;
+ foreach (self::propEntries($props) as [$key, $value]) {
+ switch ($key) {
+ case 'children':
+ $children = $value;
+ break;
+ case 'dangerouslySetInnerHTML':
+ $innerHTML = $value;
+ break;
+ default:
+ self::pushAttribute($target, $key, $value);
+ }
+ }
+ self::pushViewTransitionAttributes($target, $formatContext);
+ $target[] = '>';
+ self::pushInnerHTML($target, $innerHTML, $children);
+ return $children;
+ }
+
+ /** @param list $target */
+ private static function pushStartGenericElement(array &$target, JsObject $props, string $tag, FormatContext $formatContext): ?JsValue
+ {
+ $target[] = self::$tagStartCache[$tag] ?? self::startChunkForTag($tag);
+ $children = null;
+ $innerHTML = null;
+ foreach (self::propEntries($props) as [$key, $value]) {
+ switch ($key) {
+ case 'children':
+ $children = $value;
+ break;
+ case 'dangerouslySetInnerHTML':
+ $innerHTML = $value;
+ break;
+ default:
+ self::pushAttribute($target, $key, $value);
+ }
+ }
+ if ($formatContext->viewTransition !== null) {
+ self::pushViewTransitionAttributes($target, $formatContext);
+ }
+ $target[] = '>';
+ if ($innerHTML !== null) {
+ self::pushInnerHTML($target, $innerHTML, $children);
+ }
+ if ($children instanceof JsString) {
+ $target[] = self::escapeText($children->value);
+ return null;
+ }
+ return $children;
+ }
+
+ /**
+ * Elements whose children React renders itself (as text) or not at all
+ * return null; the rest return their children for the renderer to walk.
+ * `undefined` marks a hoisted title, style or meta, as in React.
+ *
+ * @param list $target
+ */
+ public static function pushStartInstance(
+ array &$target,
+ string $type,
+ JsObject $props,
+ ResumableState $resumableState,
+ RenderState $renderState,
+ ?PreambleState $preambleState,
+ ?HoistableState $hoistableState,
+ FormatContext $formatContext,
+ bool $textEmbedded,
+ ): ?JsValue {
+ switch ($type) {
+ case 'div':
+ case 'span':
+ case 'svg':
+ case 'path':
+ case 'g':
+ case 'p':
+ case 'li':
+ case 'annotation-xml':
+ case 'color-profile':
+ case 'font-face':
+ case 'font-face-src':
+ case 'font-face-uri':
+ case 'font-face-format':
+ case 'font-face-name':
+ case 'missing-glyph':
+ return self::pushStartGenericElement($target, $props, $type, $formatContext);
+ case 'a':
+ $target[] = self::startChunkForTag('a');
+ $children = null;
+ $innerHTML = null;
+ foreach (self::propEntries($props) as [$key, $value]) {
+ switch ($key) {
+ case 'children':
+ $children = $value;
+ break;
+ case 'dangerouslySetInnerHTML':
+ $innerHTML = $value;
+ break;
+ case 'href':
+ if ($value instanceof JsString && $value->value === '') {
+ self::pushStringAttribute($target, 'href', '');
+ } else {
+ self::pushAttribute($target, $key, $value);
+ }
+ break;
+ default:
+ self::pushAttribute($target, $key, $value);
+ }
+ }
+ self::pushViewTransitionAttributes($target, $formatContext);
+ $target[] = '>';
+ self::pushInnerHTML($target, $innerHTML, $children);
+ if ($children instanceof JsString) {
+ $target[] = self::escapeText($children->value);
+ return null;
+ }
+ return $children;
+ case 'select':
+ $target[] = self::startChunkForTag('select');
+ $children = null;
+ $innerHTML = null;
+ foreach (self::propEntries($props) as [$key, $value]) {
+ switch ($key) {
+ case 'children':
+ $children = $value;
+ break;
+ case 'dangerouslySetInnerHTML':
+ $innerHTML = $value;
+ break;
+ case 'defaultValue':
+ case 'value':
+ break;
+ default:
+ self::pushAttribute($target, $key, $value);
+ }
+ }
+ self::pushViewTransitionAttributes($target, $formatContext);
+ $target[] = '>';
+ self::pushInnerHTML($target, $innerHTML, $children);
+ return $children;
+ case 'option':
+ $selectedValue = $formatContext->selectedValue;
+ $target[] = self::startChunkForTag('option');
+ $children = null;
+ $value = null;
+ $selected = null;
+ $innerHTML = null;
+ foreach (self::propEntries($props) as [$key, $propValue]) {
+ switch ($key) {
+ case 'children':
+ $children = $propValue;
+ break;
+ case 'selected':
+ $selected = $propValue;
+ break;
+ case 'dangerouslySetInnerHTML':
+ $innerHTML = $propValue;
+ break;
+ case 'value':
+ $value = $propValue;
+ self::pushAttribute($target, $key, $propValue);
+ break;
+ default:
+ self::pushAttribute($target, $key, $propValue);
+ }
+ }
+ if (!self::nullish($selectedValue)) {
+ $stringValue = $value !== null ? Js::str($value) : self::flattenOptionChildren($children ?? JsNull::instance());
+ if (Js::isArray($selectedValue)) {
+ foreach (Js::elements($selectedValue) as $candidate) {
+ if (Js::str($candidate) === $stringValue) {
+ $target[] = ' selected=""';
+ break;
+ }
+ }
+ } elseif (Js::str($selectedValue) === $stringValue) {
+ $target[] = ' selected=""';
+ }
+ } elseif ($selected !== null && $selected->toBoolean()) {
+ $target[] = ' selected=""';
+ }
+ $target[] = '>';
+ self::pushInnerHTML($target, $innerHTML, $children);
+ return $children;
+ case 'textarea':
+ $target[] = self::startChunkForTag('textarea');
+ $value = null;
+ $defaultValue = null;
+ $children = null;
+ foreach (self::propEntries($props) as [$key, $propValue]) {
+ switch ($key) {
+ case 'children':
+ $children = $propValue;
+ break;
+ case 'value':
+ $value = $propValue;
+ break;
+ case 'defaultValue':
+ $defaultValue = $propValue;
+ break;
+ case 'dangerouslySetInnerHTML':
+ throw Js::error('`dangerouslySetInnerHTML` does not make sense on