Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e898929193 | ||
|
|
18a1c40118 | ||
|
|
a20f143acb | ||
|
|
ac248f2bb0 | ||
|
|
0904a4efaa | ||
|
|
5b11b937bb | ||
|
|
f199385204 | ||
|
|
c0c2fa4595 | ||
|
|
cfdcdd00ad | ||
|
|
03d5cb6aa6 | ||
|
|
701acd828c | ||
|
|
b28b79c370 | ||
|
|
fdd0c9f847 | ||
|
|
7ad336b4dd | ||
|
|
d70e89230b | ||
|
|
7e6d8c3bc8 | ||
|
|
92c0920c9b | ||
|
|
a8d8ac386f | ||
|
|
2c8841cc5f | ||
|
|
b344d2a70a | ||
|
|
e83f0366ef | ||
|
|
d9e8f5be82 | ||
|
|
bd0c1317ff | ||
|
|
97801287f9 | ||
|
|
d77638131b | ||
|
|
609224591c | ||
|
|
6074d19c43 | ||
|
|
7301849a7f | ||
|
|
7d481df652 | ||
|
|
5477f5436d | ||
|
|
b646ec8d00 | ||
|
|
e66d2425aa | ||
|
|
b3b65dddd8 | ||
|
|
5dbcc5b85d | ||
|
|
e819c5739e | ||
|
|
ac164789bd | ||
|
|
88ec3a5cb6 | ||
|
|
28085b5a43 | ||
|
|
5d7bd81601 | ||
|
|
5e114d867f | ||
|
|
10421465df | ||
|
|
52cce2c628 | ||
|
|
afa2a8c093 |
@@ -1,3 +1,3 @@
|
|||||||
@wrnexus:registry=https://registry.npmjs.org/
|
@wrnexus:registry=https://registry.workroot.in/repository/npm/
|
||||||
audit=true
|
audit=true
|
||||||
fund=false
|
fund=false
|
||||||
|
|||||||
@@ -272,7 +272,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@wrnexus/cli",
|
"name": "@wrnexus/cli",
|
||||||
"version": "0.8.36",
|
"version": "0.8.44",
|
||||||
"bin": {
|
"bin": {
|
||||||
"wrnexus": "src/index.ts",
|
"wrnexus": "src/index.ts",
|
||||||
},
|
},
|
||||||
@@ -287,6 +287,7 @@
|
|||||||
"@wrnexus/mcp": "workspace:*",
|
"@wrnexus/mcp": "workspace:*",
|
||||||
"@wrnexus/playground": "workspace:*",
|
"@wrnexus/playground": "workspace:*",
|
||||||
"@wrnexus/plugin": "workspace:*",
|
"@wrnexus/plugin": "workspace:*",
|
||||||
|
"@wrnexus/react": "workspace:*",
|
||||||
"@wrnexus/router": "workspace:*",
|
"@wrnexus/router": "workspace:*",
|
||||||
"@wrnexus/security": "workspace:*",
|
"@wrnexus/security": "workspace:*",
|
||||||
"@wrnexus/styles": "workspace:*",
|
"@wrnexus/styles": "workspace:*",
|
||||||
@@ -299,9 +300,8 @@
|
|||||||
},
|
},
|
||||||
"packages/compiler": {
|
"packages/compiler": {
|
||||||
"name": "@wrnexus/compiler",
|
"name": "@wrnexus/compiler",
|
||||||
"version": "0.8.10",
|
"version": "0.8.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
|
||||||
"@wrnexus/csr": "workspace:*",
|
"@wrnexus/csr": "workspace:*",
|
||||||
"@wrnexus/store": "workspace:*",
|
"@wrnexus/store": "workspace:*",
|
||||||
"@wrnexus/syntax": "workspace:*",
|
"@wrnexus/syntax": "workspace:*",
|
||||||
@@ -322,14 +322,14 @@
|
|||||||
},
|
},
|
||||||
"packages/csr": {
|
"packages/csr": {
|
||||||
"name": "@wrnexus/csr",
|
"name": "@wrnexus/csr",
|
||||||
"version": "0.8.21",
|
"version": "0.8.23",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
"@wrnexus/core": "workspace:*",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/db": {
|
"packages/db": {
|
||||||
"name": "@wrnexus/db",
|
"name": "@wrnexus/db",
|
||||||
"version": "0.8.15",
|
"version": "0.8.16",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
@@ -337,7 +337,7 @@
|
|||||||
},
|
},
|
||||||
"packages/dev-server": {
|
"packages/dev-server": {
|
||||||
"name": "@wrnexus/dev-server",
|
"name": "@wrnexus/dev-server",
|
||||||
"version": "0.8.33",
|
"version": "0.8.39",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/authz": "workspace:*",
|
"@wrnexus/authz": "workspace:*",
|
||||||
"@wrnexus/cache": "workspace:*",
|
"@wrnexus/cache": "workspace:*",
|
||||||
@@ -364,7 +364,7 @@
|
|||||||
},
|
},
|
||||||
"packages/dev-toolbar": {
|
"packages/dev-toolbar": {
|
||||||
"name": "@wrnexus/dev-toolbar",
|
"name": "@wrnexus/dev-toolbar",
|
||||||
"version": "0.8.10",
|
"version": "0.8.13",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
@@ -397,7 +397,7 @@
|
|||||||
},
|
},
|
||||||
"packages/i18n": {
|
"packages/i18n": {
|
||||||
"name": "@wrnexus/i18n",
|
"name": "@wrnexus/i18n",
|
||||||
"version": "0.8.11",
|
"version": "0.8.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
"@wrnexus/core": "workspace:*",
|
||||||
"@wrnexus/plugin": "workspace:*",
|
"@wrnexus/plugin": "workspace:*",
|
||||||
@@ -451,13 +451,14 @@
|
|||||||
},
|
},
|
||||||
"packages/language-server": {
|
"packages/language-server": {
|
||||||
"name": "@wrnexus/language-server",
|
"name": "@wrnexus/language-server",
|
||||||
"version": "0.8.9",
|
"version": "0.8.10",
|
||||||
"bin": {
|
"bin": {
|
||||||
"wrnexus-language-server": "src/server.ts",
|
"wrnexus-language-server": "src/server.ts",
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/syntax": "workspace:*",
|
"@wrnexus/syntax": "workspace:*",
|
||||||
"@wrnexus/typecheck": "workspace:*",
|
"@wrnexus/typecheck": "workspace:*",
|
||||||
|
"vscode-html-languageservice": "^5.6.2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/mcp": {
|
"packages/mcp": {
|
||||||
@@ -967,6 +968,8 @@
|
|||||||
|
|
||||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.67.0", "", { "dependencies": { "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA=="],
|
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.67.0", "", { "dependencies": { "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA=="],
|
||||||
|
|
||||||
|
"@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],
|
||||||
|
|
||||||
"@wrnexus/ai": ["@wrnexus/ai@workspace:packages/ai"],
|
"@wrnexus/ai": ["@wrnexus/ai@workspace:packages/ai"],
|
||||||
|
|
||||||
"@wrnexus/auth": ["@wrnexus/auth@workspace:packages/auth"],
|
"@wrnexus/auth": ["@wrnexus/auth@workspace:packages/auth"],
|
||||||
@@ -1387,6 +1390,14 @@
|
|||||||
|
|
||||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||||
|
|
||||||
|
"vscode-html-languageservice": ["vscode-html-languageservice@5.6.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg=="],
|
||||||
|
|
||||||
|
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
|
||||||
|
|
||||||
|
"vscode-languageserver-types": ["vscode-languageserver-types@3.18.0", "", {}, "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="],
|
||||||
|
|
||||||
|
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
||||||
|
|
||||||
"web": ["web@workspace:examples/inter-app-api-showcase/apps/web"],
|
"web": ["web@workspace:examples/inter-app-api-showcase/apps/web"],
|
||||||
|
|
||||||
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
||||||
|
|||||||
@@ -166,6 +166,10 @@ Supported view features include:
|
|||||||
- `{#each items as item, index key item.id}`, optional keys, and optional `{:empty}` branches
|
- `{#each items as item, index key item.id}`, optional keys, and optional `{:empty}` branches
|
||||||
- comments and scoped styles
|
- comments and scoped styles
|
||||||
|
|
||||||
|
`{#if}` and `{#each}` are rendered on the server for the initial response and
|
||||||
|
remain reactive after hydration. Browser state changes switch conditional
|
||||||
|
branches and rerender loop rows, including the `{:empty}` branch.
|
||||||
|
|
||||||
Output is escaped by default. Explicit raw HTML APIs must be treated as security
|
Output is escaped by default. Explicit raw HTML APIs must be treated as security
|
||||||
boundaries.
|
boundaries.
|
||||||
|
|
||||||
|
|||||||
@@ -1674,6 +1674,7 @@
|
|||||||
"@wrnexus/i18n": {
|
"@wrnexus/i18n": {
|
||||||
".": [
|
".": [
|
||||||
"ExtractedTranslationKey",
|
"ExtractedTranslationKey",
|
||||||
|
"I18N_DATA_ATTRIBUTE",
|
||||||
"I18N_JS_HREF",
|
"I18N_JS_HREF",
|
||||||
"I18N_RUNTIME",
|
"I18N_RUNTIME",
|
||||||
"I18nConfig",
|
"I18nConfig",
|
||||||
@@ -1710,6 +1711,7 @@
|
|||||||
"plural",
|
"plural",
|
||||||
"pseudoLocalize",
|
"pseudoLocalize",
|
||||||
"renderI18nData",
|
"renderI18nData",
|
||||||
|
"renderI18nDataTag",
|
||||||
"resolveI18n",
|
"resolveI18n",
|
||||||
"resolveLang",
|
"resolveLang",
|
||||||
"translateHtml",
|
"translateHtml",
|
||||||
|
|||||||
@@ -30,26 +30,26 @@
|
|||||||
|
|
||||||
**New package `packages/react/`:**
|
**New package `packages/react/`:**
|
||||||
|
|
||||||
| File | Responsibility |
|
| File | Responsibility |
|
||||||
|---|---|
|
| ------------------------ | ------------------------------------------------------------------ |
|
||||||
| `package.json` | Package manifest; optional peer deps |
|
| `package.json` | Package manifest; optional peer deps |
|
||||||
| `src/snapshot-cache.ts` | Referentially-stable snapshot + selector caching. No React import. |
|
| `src/snapshot-cache.ts` | Referentially-stable snapshot + selector caching. No React import. |
|
||||||
| `src/store-bridge.ts` | `useWrnStore` hook over `useSyncExternalStore` |
|
| `src/store-bridge.ts` | `useWrnStore` hook over `useSyncExternalStore` |
|
||||||
| `src/error-boundary.tsx` | Per-island React error boundary |
|
| `src/error-boundary.tsx` | Per-island React error boundary |
|
||||||
| `src/island-runtime.ts` | Mount/unmount, strategies, root registry |
|
| `src/island-runtime.ts` | Mount/unmount, strategies, root registry |
|
||||||
| `src/runtime-source.ts` | `getIslandRuntime()` returning browser JS (mirrors `@wrnexus/csr`) |
|
| `src/runtime-source.ts` | `getIslandRuntime()` returning browser JS (mirrors `@wrnexus/csr`) |
|
||||||
| `src/index.ts` | Public exports |
|
| `src/index.ts` | Public exports |
|
||||||
|
|
||||||
**Modified:**
|
**Modified:**
|
||||||
|
|
||||||
| File | Change |
|
| File | Change |
|
||||||
|---|---|
|
| ------------------------------------------------- | ------------------------------------------------------------- |
|
||||||
| `packages/compiler/src/import-resolver.ts` | Resolve `.tsx`; tag `kind: "island"` |
|
| `packages/compiler/src/import-resolver.ts` | Resolve `.tsx`; tag `kind: "island"` |
|
||||||
| `packages/compiler/src/island-codegen.ts` *(new)* | Marker emission, props serialization, diagnostics |
|
| `packages/compiler/src/island-codegen.ts` _(new)_ | Marker emission, props serialization, diagnostics |
|
||||||
| `packages/compiler/src/island-bundle.ts` *(new)* | Island entry generation + `Bun.build` with shared React chunk |
|
| `packages/compiler/src/island-bundle.ts` _(new)_ | Island entry generation + `Bun.build` with shared React chunk |
|
||||||
| `packages/dev-server/src/assets.ts` | Serve island routes in dev |
|
| `packages/dev-server/src/assets.ts` | Serve island routes in dev |
|
||||||
| `packages/dev-server/src/prod.ts` | Serve island routes in prod |
|
| `packages/dev-server/src/prod.ts` | Serve island routes in prod |
|
||||||
| `packages/cli/src/build.ts` | Emit island assets in static build |
|
| `packages/cli/src/build.ts` | Emit island assets in static build |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -58,11 +58,13 @@
|
|||||||
The load-bearing piece. `readonlySnapshot` in `@wrnexus/store` returns a fresh `Object.freeze(clone(state))` on every call; `useSyncExternalStore` requires a stable reference or it throws and infinite-loops. This task builds the cache with **no React dependency**, so it is testable in isolation.
|
The load-bearing piece. `readonlySnapshot` in `@wrnexus/store` returns a fresh `Object.freeze(clone(state))` on every call; `useSyncExternalStore` requires a stable reference or it throws and infinite-loops. This task builds the cache with **no React dependency**, so it is testable in isolation.
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/react/package.json`
|
- Create: `packages/react/package.json`
|
||||||
- Create: `packages/react/src/snapshot-cache.ts`
|
- Create: `packages/react/src/snapshot-cache.ts`
|
||||||
- Test: `packages/react/test/snapshot-cache.test.ts`
|
- Test: `packages/react/test/snapshot-cache.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: nothing.
|
- Consumes: nothing.
|
||||||
- Produces:
|
- Produces:
|
||||||
- `createSnapshotCache<S extends object>(source: SnapshotSource<S>): SnapshotCache<S>`
|
- `createSnapshotCache<S extends object>(source: SnapshotSource<S>): SnapshotCache<S>`
|
||||||
@@ -193,9 +195,7 @@ export interface SnapshotCache<S extends object> {
|
|||||||
* and spins if given a fresh object each call, which `@wrnexus/store`'s
|
* and spins if given a fresh object each call, which `@wrnexus/store`'s
|
||||||
* `readonlySnapshot` does by design.
|
* `readonlySnapshot` does by design.
|
||||||
*/
|
*/
|
||||||
export function createSnapshotCache<S extends object>(
|
export function createSnapshotCache<S extends object>(source: SnapshotSource<S>): SnapshotCache<S> {
|
||||||
source: SnapshotSource<S>,
|
|
||||||
): SnapshotCache<S> {
|
|
||||||
let cached: Readonly<S> | undefined;
|
let cached: Readonly<S> | undefined;
|
||||||
let dirty = true;
|
let dirty = true;
|
||||||
|
|
||||||
@@ -259,12 +259,14 @@ git commit -m "feat(react): add referentially-stable snapshot and selector cache
|
|||||||
### Task 2: Store bridge hook
|
### Task 2: Store bridge hook
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/react/src/store-bridge.ts`
|
- Create: `packages/react/src/store-bridge.ts`
|
||||||
- Create: `packages/react/src/index.ts`
|
- Create: `packages/react/src/index.ts`
|
||||||
- Modify: `package.json` (root) — add `react`, `react-dom` to devDependencies for tests
|
- Modify: `package.json` (root) — add `react`, `react-dom` to devDependencies for tests
|
||||||
- Test: `packages/react/test/store-bridge.test.ts`
|
- Test: `packages/react/test/store-bridge.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `createSnapshotCache`, `createSelectorCache` from Task 1.
|
- Consumes: `createSnapshotCache`, `createSelectorCache` from Task 1.
|
||||||
- Produces:
|
- Produces:
|
||||||
- `useWrnStore<S extends object, R = Readonly<S>>(name: string, selector?: (state: Readonly<S>) => R): R`
|
- `useWrnStore<S extends object, R = Readonly<S>>(name: string, selector?: (state: Readonly<S>) => R): R`
|
||||||
@@ -436,10 +438,12 @@ git commit -m "feat(react): add useWrnStore bridge over useSyncExternalStore"
|
|||||||
### Task 3: Island marker codegen
|
### Task 3: Island marker codegen
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/compiler/src/island-codegen.ts`
|
- Create: `packages/compiler/src/island-codegen.ts`
|
||||||
- Test: `packages/compiler/test/island-codegen.test.ts`
|
- Test: `packages/compiler/test/island-codegen.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `escapeHtml` from `@wrnexus/core` (`packages/core/src/security.ts`).
|
- Consumes: `escapeHtml` from `@wrnexus/core` (`packages/core/src/security.ts`).
|
||||||
- Produces:
|
- Produces:
|
||||||
- `type IslandStrategy = "only" | "load" | "visible" | "idle"`
|
- `type IslandStrategy = "only" | "load" | "visible" | "idle"`
|
||||||
@@ -596,10 +600,12 @@ git commit -m "feat(compiler): add island marker codegen and props contract"
|
|||||||
### Task 4: Resolve `.tsx` imports as islands
|
### Task 4: Resolve `.tsx` imports as islands
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `packages/compiler/src/import-resolver.ts`
|
- Modify: `packages/compiler/src/import-resolver.ts`
|
||||||
- Test: `packages/compiler/test/island-resolution.test.ts`
|
- Test: `packages/compiler/test/island-resolution.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: nothing from prior tasks.
|
- Consumes: nothing from prior tasks.
|
||||||
- Produces: `ResolvedImport` gains an optional `kind?: "island"` field, set when the resolved path ends in `.tsx`.
|
- Produces: `ResolvedImport` gains an optional `kind?: "island"` field, set when the resolved path ends in `.tsx`.
|
||||||
|
|
||||||
@@ -705,12 +711,12 @@ export interface ResolvedImport {
|
|||||||
Then change the success return inside `resolveWrnImport` from `return { declaration, resolved: realpathSync(found) };` to:
|
Then change the success return inside `resolveWrnImport` from `return { declaration, resolved: realpathSync(found) };` to:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
if (found) {
|
if (found) {
|
||||||
const resolved = realpathSync(found);
|
const resolved = realpathSync(found);
|
||||||
return resolved.endsWith(".tsx")
|
return resolved.endsWith(".tsx")
|
||||||
? { declaration, resolved, kind: "island" }
|
? { declaration, resolved, kind: "island" }
|
||||||
: { declaration, resolved };
|
: { declaration, resolved };
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 5: Run test to verify it passes**
|
- [ ] **Step 5: Run test to verify it passes**
|
||||||
@@ -735,10 +741,12 @@ git commit -m "feat(compiler): resolve .tsx imports and tag them as islands"
|
|||||||
### Task 5: Island error boundary
|
### Task 5: Island error boundary
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/react/src/error-boundary.tsx`
|
- Create: `packages/react/src/error-boundary.tsx`
|
||||||
- Test: `packages/react/test/error-boundary.test.tsx`
|
- Test: `packages/react/test/error-boundary.test.tsx`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: nothing from prior tasks.
|
- Consumes: nothing from prior tasks.
|
||||||
- Produces: `IslandErrorBoundary` — a React component with props `{ name: string; development: boolean; children: ReactNode }`.
|
- Produces: `IslandErrorBoundary` — a React component with props `{ name: string; development: boolean; children: ReactNode }`.
|
||||||
|
|
||||||
@@ -857,11 +865,13 @@ git commit -m "feat(react): add per-island error boundary"
|
|||||||
### Task 6: Island runtime — mount, strategies, unmount
|
### Task 6: Island runtime — mount, strategies, unmount
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/react/src/island-runtime.ts`
|
- Create: `packages/react/src/island-runtime.ts`
|
||||||
- Modify: `packages/react/src/index.ts`
|
- Modify: `packages/react/src/index.ts`
|
||||||
- Test: `packages/react/test/island-runtime.test.ts`
|
- Test: `packages/react/test/island-runtime.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `IslandErrorBoundary` (Task 5), `setStoreResolver` (Task 2).
|
- Consumes: `IslandErrorBoundary` (Task 5), `setStoreResolver` (Task 2).
|
||||||
- Produces:
|
- Produces:
|
||||||
- `mountIslands(root: ParentNode, options: MountOptions): Promise<void>`
|
- `mountIslands(root: ParentNode, options: MountOptions): Promise<void>`
|
||||||
@@ -1070,10 +1080,12 @@ git commit -m "feat(react): add island mount strategies and navigation-safe unmo
|
|||||||
### Task 7: Island bundling with a shared React chunk
|
### Task 7: Island bundling with a shared React chunk
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/compiler/src/island-bundle.ts`
|
- Create: `packages/compiler/src/island-bundle.ts`
|
||||||
- Test: `packages/compiler/test/island-bundle.test.ts`
|
- Test: `packages/compiler/test/island-bundle.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: nothing from prior tasks.
|
- Consumes: nothing from prior tasks.
|
||||||
- Produces:
|
- Produces:
|
||||||
- `generateIslandEntry(input: { name: string; sourcePath: string }): string`
|
- `generateIslandEntry(input: { name: string; sourcePath: string }): string`
|
||||||
@@ -1226,6 +1238,7 @@ git commit -m "feat(compiler): bundle islands with a shared React chunk"
|
|||||||
### Task 8: Serve island assets in dev, prod, and static build
|
### Task 8: Serve island assets in dev, prod, and static build
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/react/src/runtime-source.ts`
|
- Create: `packages/react/src/runtime-source.ts`
|
||||||
- Modify: `packages/dev-server/src/assets.ts`
|
- Modify: `packages/dev-server/src/assets.ts`
|
||||||
- Modify: `packages/dev-server/src/prod.ts`
|
- Modify: `packages/dev-server/src/prod.ts`
|
||||||
@@ -1233,6 +1246,7 @@ git commit -m "feat(compiler): bundle islands with a shared React chunk"
|
|||||||
- Test: `packages/react/test/runtime-source.test.ts`
|
- Test: `packages/react/test/runtime-source.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `mountIslands`, `unmountIslands` (Task 6).
|
- Consumes: `mountIslands`, `unmountIslands` (Task 6).
|
||||||
- Produces: `getIslandRuntime(development?: boolean): string` — browser JS served at `/__wrnexus/islands.js`, mirroring `getReactiveRuntime` in `@wrnexus/csr`.
|
- Produces: `getIslandRuntime(development?: boolean): string` — browser JS served at `/__wrnexus/islands.js`, mirroring `getReactiveRuntime` in `@wrnexus/csr`.
|
||||||
|
|
||||||
@@ -1323,15 +1337,15 @@ import { getIslandRuntime } from "@wrnexus/react/runtime";
|
|||||||
Then inside `serve(pathname)`, next to the other `/__wrnexus/*.js` lines:
|
Then inside `serve(pathname)`, next to the other `/__wrnexus/*.js` lines:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
if (pathname === "/__wrnexus/islands.js") return jsResponse(getIslandRuntime(true));
|
if (pathname === "/__wrnexus/islands.js") return jsResponse(getIslandRuntime(true));
|
||||||
```
|
```
|
||||||
|
|
||||||
The bootstrap dynamically imports `/__wrnexus/island/runtime.js` (the bundled mount runtime) and `/__wrnexus/island/<name>.js` (per-island bundles). Both live under the `/__wrnexus/island/` prefix, so add one prefix handler beside the existing `/__wrnexus/client/` handler at the top of `serve(pathname)`:
|
The bootstrap dynamically imports `/__wrnexus/island/runtime.js` (the bundled mount runtime) and `/__wrnexus/island/<name>.js` (per-island bundles). Both live under the `/__wrnexus/island/` prefix, so add one prefix handler beside the existing `/__wrnexus/client/` handler at the top of `serve(pathname)`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
if (pathname.startsWith("/__wrnexus/island/")) {
|
if (pathname.startsWith("/__wrnexus/island/")) {
|
||||||
return serveIslandArtifact(pathname) ?? new Response("Not Found", { status: 404 });
|
return serveIslandArtifact(pathname) ?? new Response("Not Found", { status: 404 });
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
where `serveIslandArtifact` reads from the island build output directory produced by `buildIslands` (Task 7), mirroring how `serveWrnBrowserArtifact` serves `/__wrnexus/client/`.
|
where `serveIslandArtifact` reads from the island build output directory produced by `buildIslands` (Task 7), mirroring how `serveWrnBrowserArtifact` serves `/__wrnexus/client/`.
|
||||||
@@ -1341,8 +1355,8 @@ where `serveIslandArtifact` reads from the island build output directory produce
|
|||||||
In `packages/dev-server/src/prod.ts`, add the same import, then beside the existing `getReactiveRuntime()` route:
|
In `packages/dev-server/src/prod.ts`, add the same import, then beside the existing `getReactiveRuntime()` route:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
if (pathname === "/__wrnexus/islands.js")
|
if (pathname === "/__wrnexus/islands.js")
|
||||||
return new Response(getIslandRuntime(), { headers: JS_HEADERS });
|
return new Response(getIslandRuntime(), { headers: JS_HEADERS });
|
||||||
```
|
```
|
||||||
|
|
||||||
Add the same `/__wrnexus/island/` prefix handler here, serving the built island assets from the production output directory.
|
Add the same `/__wrnexus/island/` prefix handler here, serving the built island assets from the production output directory.
|
||||||
@@ -1375,10 +1389,12 @@ git commit -m "feat(islands): serve the island runtime in dev, prod, and static
|
|||||||
A route containing an island is no longer zero-JS static — it is static-interactive. Without this, the framework's own performance reporting is wrong.
|
A route containing an island is no longer zero-JS static — it is static-interactive. Without this, the framework's own performance reporting is wrong.
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `packages/compiler/src/analysis.ts`
|
- Modify: `packages/compiler/src/analysis.ts`
|
||||||
- Test: `packages/compiler/test/island-classification.test.ts`
|
- Test: `packages/compiler/test/island-classification.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `ResolvedImport.kind` (Task 4).
|
- Consumes: `ResolvedImport.kind` (Task 4).
|
||||||
- Produces: `routeNeedsIslands(imports: ResolvedImport[]): boolean`, exported from `analysis.ts`.
|
- Produces: `routeNeedsIslands(imports: ResolvedImport[]): boolean`, exported from `analysis.ts`.
|
||||||
|
|
||||||
@@ -1457,10 +1473,12 @@ git commit -m "feat(compiler): classify island routes as static-interactive"
|
|||||||
Two tests protecting the project's core promise. These must fail loudly if a future change regresses them.
|
Two tests protecting the project's core promise. These must fail loudly if a future change regresses them.
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `examples/basic-app/app/islands/Counter.tsx`
|
- Create: `examples/basic-app/app/islands/Counter.tsx`
|
||||||
- Test: `packages/compiler/test/island-integration.test.ts`
|
- Test: `packages/compiler/test/island-integration.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `buildIslands` (Task 7), `renderIslandMarker` (Task 3), `routeNeedsIslands` (Task 9).
|
- Consumes: `buildIslands` (Task 7), `renderIslandMarker` (Task 3), `routeNeedsIslands` (Task 9).
|
||||||
- Produces: nothing consumed downstream.
|
- Produces: nothing consumed downstream.
|
||||||
|
|
||||||
@@ -1493,10 +1511,7 @@ import { join, resolve } from "node:path";
|
|||||||
import { buildIslands } from "../src/island-bundle.ts";
|
import { buildIslands } from "../src/island-bundle.ts";
|
||||||
import { routeNeedsIslands } from "../src/analysis.ts";
|
import { routeNeedsIslands } from "../src/analysis.ts";
|
||||||
|
|
||||||
const COUNTER = resolve(
|
const COUNTER = resolve(import.meta.dir, "../../../examples/basic-app/app/islands/Counter.tsx");
|
||||||
import.meta.dir,
|
|
||||||
"../../../examples/basic-app/app/islands/Counter.tsx",
|
|
||||||
);
|
|
||||||
|
|
||||||
test("a route with no islands ships zero framework JavaScript", async () => {
|
test("a route with no islands ships zero framework JavaScript", async () => {
|
||||||
const outDir = mkdtempSync(join(tmpdir(), "wrnexus-nojs-"));
|
const outDir = mkdtempSync(join(tmpdir(), "wrnexus-nojs-"));
|
||||||
@@ -1522,8 +1537,8 @@ test("a page with multiple islands ships React exactly once", async () => {
|
|||||||
.filter((file) => file.endsWith(".js"))
|
.filter((file) => file.endsWith(".js"))
|
||||||
.map((file) => readFileSync(join(outDir, file), "utf8"));
|
.map((file) => readFileSync(join(outDir, file), "utf8"));
|
||||||
|
|
||||||
const withReactInternals = bundles.filter((source) =>
|
const withReactInternals = bundles.filter(
|
||||||
source.includes("react.development") || source.includes("REACT_ELEMENT_TYPE"),
|
(source) => source.includes("react.development") || source.includes("REACT_ELEMENT_TYPE"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.assets).toHaveLength(2);
|
expect(result.assets).toHaveLength(2);
|
||||||
@@ -1564,12 +1579,14 @@ git commit -m "test(islands): guard zero-JS routes and single-React bundling"
|
|||||||
The spec's one author-facing rule — writes only from handlers or effects — is enforced in dev, not merely documented. React's own warning for this is too generic to diagnose quickly.
|
The spec's one author-facing rule — writes only from handlers or effects — is enforced in dev, not merely documented. React's own warning for this is too generic to diagnose quickly.
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `packages/react/src/render-phase.ts`
|
- Create: `packages/react/src/render-phase.ts`
|
||||||
- Modify: `packages/react/src/store-bridge.ts` (wrap actions in `useWrnActions`)
|
- Modify: `packages/react/src/store-bridge.ts` (wrap actions in `useWrnActions`)
|
||||||
- Modify: `packages/react/src/index.ts`
|
- Modify: `packages/react/src/index.ts`
|
||||||
- Test: `packages/react/test/render-phase.test.ts`
|
- Test: `packages/react/test/render-phase.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `useWrnActions` (Task 2).
|
- Consumes: `useWrnActions` (Task 2).
|
||||||
- Produces:
|
- Produces:
|
||||||
- `beginRenderPhase(): void`
|
- `beginRenderPhase(): void`
|
||||||
@@ -1730,12 +1747,14 @@ git commit -m "feat(react): fail loudly on store writes during island render"
|
|||||||
On island source change, unmount the root and re-mount with the new bundle. Component state resets on edit; that is the accepted v1 trade-off, and the concrete trigger for reconsidering Fast Refresh later.
|
On island source change, unmount the root and re-mount with the new bundle. Component state resets on edit; that is the accepted v1 trade-off, and the concrete trigger for reconsidering Fast Refresh later.
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `packages/react/src/island-runtime.ts`
|
- Modify: `packages/react/src/island-runtime.ts`
|
||||||
- Modify: `packages/react/src/runtime-source.ts`
|
- Modify: `packages/react/src/runtime-source.ts`
|
||||||
- Modify: `packages/react/src/index.ts`
|
- Modify: `packages/react/src/index.ts`
|
||||||
- Test: `packages/react/test/island-hmr.test.ts`
|
- Test: `packages/react/test/island-hmr.test.ts`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
|
|
||||||
- Consumes: `mountIslands`, `unmountIslands` (Task 6).
|
- Consumes: `mountIslands`, `unmountIslands` (Task 6).
|
||||||
- Produces: `remountIslands(root: ParentNode, options: MountOptions): Promise<void>`
|
- Produces: `remountIslands(root: ParentNode, options: MountOptions): Promise<void>`
|
||||||
|
|
||||||
@@ -1751,8 +1770,7 @@ import { islandRootCount, mountIslands, remountIslands } from "../src/island-run
|
|||||||
|
|
||||||
test("remount replaces island output without leaking roots", async () => {
|
test("remount replaces island output without leaking roots", async () => {
|
||||||
const window = new Window();
|
const window = new Window();
|
||||||
window.document.body.innerHTML =
|
window.document.body.innerHTML = `<div data-wrn-island="Chart" data-wrn-island-strategy="only"
|
||||||
`<div data-wrn-island="Chart" data-wrn-island-strategy="only"
|
|
||||||
data-wrn-island-props='{}'></div>`;
|
data-wrn-island-props='{}'></div>`;
|
||||||
(globalThis as any).window = window;
|
(globalThis as any).window = window;
|
||||||
(globalThis as any).document = window.document;
|
(globalThis as any).document = window.document;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -28,14 +28,14 @@ byte-for-byte unchanged, and a route with no islands must ship no React.
|
|||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
| Question | Decision |
|
| Question | Decision |
|
||||||
|---|---|
|
| ---------------- | ---------------------------------------------------------------------------------------- |
|
||||||
| Purpose | npm ecosystem access |
|
| Purpose | npm ecosystem access |
|
||||||
| Server rendering | Client-only by default; SSR opt-in deferred to v2 |
|
| Server rendering | Client-only by default; SSR opt-in deferred to v2 |
|
||||||
| Authoring | `import Chart from "./Chart.tsx"` in `.wrn` frontmatter, used as `<Chart client:only />` |
|
| Authoring | `import Chart from "./Chart.tsx"` in `.wrn` frontmatter, used as `<Chart client:only />` |
|
||||||
| Data flow | Two-way store access via `useSyncExternalStore` (read + write through actions) |
|
| Data flow | Two-way store access via `useSyncExternalStore` (read + write through actions) |
|
||||||
| Bundling | Extend the existing Bun pipeline |
|
| Bundling | Extend the existing Bun pipeline |
|
||||||
| Packaging | New isolated package `@wrnexus/react` |
|
| Packaging | New isolated package `@wrnexus/react` |
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ separate island hydration channel is introduced.
|
|||||||
`readonlySnapshot` in `packages/store/src/index.ts` returns `Object.freeze(clone(state))` — a
|
`readonlySnapshot` in `packages/store/src/index.ts` returns `Object.freeze(clone(state))` — a
|
||||||
**new reference on every call**. `useSyncExternalStore` requires `getSnapshot()` to return a
|
**new reference on every call**. `useSyncExternalStore` requires `getSnapshot()` to return a
|
||||||
referentially identical value when nothing has changed; otherwise React throws
|
referentially identical value when nothing has changed; otherwise React throws
|
||||||
*"The result of getSnapshot should be cached to avoid an infinite loop"* and spins.
|
_"The result of getSnapshot should be cached to avoid an infinite loop"_ and spins.
|
||||||
|
|
||||||
**The cache lives in the `@wrnexus/react` adapter, not in `@wrnexus/store`.** The adapter holds
|
**The cache lives in the `@wrnexus/react` adapter, not in `@wrnexus/store`.** The adapter holds
|
||||||
one cached snapshot per store instance, returns the same reference until the store's `subscribe`
|
one cached snapshot per store instance, returns the same reference until the store's `subscribe`
|
||||||
@@ -182,15 +182,15 @@ point.
|
|||||||
|
|
||||||
## Error handling
|
## Error handling
|
||||||
|
|
||||||
| Condition | Behavior |
|
| Condition | Behavior |
|
||||||
|---|---|
|
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `react`/`react-dom` not installed | Compiler diagnostic `WRN-ISLAND-REACT-MISSING`, naming the install command — not a raw module-resolution failure |
|
| `react`/`react-dom` not installed | Compiler diagnostic `WRN-ISLAND-REACT-MISSING`, naming the install command — not a raw module-resolution failure |
|
||||||
| Island throws during render | Per-island error boundary. Dev: render error in place with component name and stack. Prod: log, render nothing, leave surrounding server HTML intact |
|
| Island throws during render | Per-island error boundary. Dev: render error in place with component name and stack. Prod: log, render nothing, leave surrounding server HTML intact |
|
||||||
| Island bundle fails to load | Placeholder remains, warning logged; page stays functional because everything else was server-rendered |
|
| Island bundle fails to load | Placeholder remains, warning logged; page stays functional because everything else was server-rendered |
|
||||||
| Non-serializable props | Compile-time `WRN-ISLAND-PROPS` |
|
| Non-serializable props | Compile-time `WRN-ISLAND-PROPS` |
|
||||||
| Unknown store name | Dev: throw, listing available store names. Prod: warn, return undefined |
|
| Unknown store name | Dev: throw, listing available store names. Prod: warn, return undefined |
|
||||||
| Action fired during render | Left to React. See "Dropped: the write-during-render guard" below. |
|
| Action fired during render | Left to React. See "Dropped: the write-during-render guard" below. |
|
||||||
| Cleanup throws on unmount | Caught and logged; navigation must not break |
|
| Cleanup throws on unmount | Caught and logged; navigation must not break |
|
||||||
|
|
||||||
Islands failing **locally** is the most valuable property of this model: a crashed chart leaves
|
Islands failing **locally** is the most valuable property of this model: a crashed chart leaves
|
||||||
the rest of the page working.
|
the rest of the page working.
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
# HTML editing support for `.wrn` files — Design
|
||||||
|
|
||||||
|
**Date:** 2026-08-18
|
||||||
|
**Status:** Approved for implementation
|
||||||
|
**Scope:** HTML autocomplete, tag closing, hover, Emmet, and folding inside `view { }` blocks.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Writing markup in a `.wrn` file should feel like writing HTML. Today it does not: there is
|
||||||
|
syntax highlighting but no tag completion, no attribute completion, no tag closing, and no
|
||||||
|
tag-level folding.
|
||||||
|
|
||||||
|
The grammar already declares `embeddedLanguages` (`meta.embedded.block.html` → `html`), which is
|
||||||
|
why markup _highlights_. That mapping only affects tokenization — VS Code's HTML language
|
||||||
|
service does not run on `.wrn` documents, so none of the editing behaviour follows from it.
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- **HTML formatting.** See "Formatting is deliberately excluded" below.
|
||||||
|
- Editor support outside VS Code beyond what standard LSP gives for free.
|
||||||
|
- Changing `.wrn` syntax or the compiler.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
| ------------------- | ---------------------------------------------------------------------------- |
|
||||||
|
| Features | Tag/attribute completion, auto-close and rename tags, hover + Emmet, folding |
|
||||||
|
| Placement | Shared language server; only auto-close-on-type is VS Code-specific |
|
||||||
|
| Completion strategy | One merged list, WRNexus entries ranked above HTML |
|
||||||
|
| Region detection | Tolerant scanner over a virtual document, not the AST |
|
||||||
|
| HTML knowledge | `vscode-html-languageservice` |
|
||||||
|
| Formatting | Excluded — `formatWrn` already owns markup formatting |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Virtual HTML document
|
||||||
|
|
||||||
|
New module: `packages/language-server/src/html-regions.ts`, exporting
|
||||||
|
`virtualHtmlDocument(document)`.
|
||||||
|
|
||||||
|
Everything outside a `view { }` block is replaced by whitespace of **identical length**, with
|
||||||
|
newlines preserved. The virtual document therefore has the same size and the same line/column
|
||||||
|
geometry as the source, so a position in the source _is_ the position in the virtual document.
|
||||||
|
No mapping table and no translation layer.
|
||||||
|
|
||||||
|
This is deliberately **not** the same shape as the existing `virtualTypeScriptDocument`, which
|
||||||
|
compacts code and carries line mappings back to source. Compaction is necessary there because
|
||||||
|
the output must be valid TypeScript. HTML has no such requirement, so the simpler
|
||||||
|
offset-preserving form applies, and the class of off-by-one bugs that mapping tables produce
|
||||||
|
does not arise.
|
||||||
|
|
||||||
|
**The load-bearing invariant:** `virtualHtmlDocument(doc).text.length === doc.text.length`, with
|
||||||
|
newlines at identical offsets. If this breaks, every feature reports positions off by some
|
||||||
|
amount rather than failing loudly.
|
||||||
|
|
||||||
|
### Region detection
|
||||||
|
|
||||||
|
Region detection is a tolerant scanner, **not** the `@wrnexus/syntax` parser. Completion fires
|
||||||
|
while the document is being typed, which is exactly when it does not parse. The scanner finds
|
||||||
|
`view` followed by `{` and tracks brace depth to the matching close.
|
||||||
|
|
||||||
|
Two hazards it must handle, both of which defeat a naive implementation:
|
||||||
|
|
||||||
|
- **Apostrophes in text content.** `<p>it's fine</p>` — a scanner treating `'` as a string
|
||||||
|
delimiter anywhere will consider the rest of the file one open string and lose every later
|
||||||
|
region. Quotes are tracked only inside attribute values, never in text nodes.
|
||||||
|
- **Nested braces from interpolation.** `class={cond ? "a" : "b"}` and `{{ a: 1 }}` nest, so
|
||||||
|
depth must be counted rather than scanning for the next `}`.
|
||||||
|
|
||||||
|
WRNexus-specific syntax (`@click`, `client:visible`, `{expr}`) is **not** blanked. The HTML
|
||||||
|
service tolerates unknown attributes, and blanking would cost region fidelity for no gain.
|
||||||
|
|
||||||
|
**Caching** is keyed on document URI and version, so a burst of requests from one keystroke
|
||||||
|
costs a single scan.
|
||||||
|
|
||||||
|
## Completion
|
||||||
|
|
||||||
|
### The server becomes the single authority inside view blocks
|
||||||
|
|
||||||
|
`textDocument/completion` gains a context check: a position is "in HTML" exactly when the
|
||||||
|
virtual document is non-blank there, which costs one character lookup.
|
||||||
|
|
||||||
|
**Inside a view block**, one list is assembled from two sources:
|
||||||
|
|
||||||
|
| Source | `sortText` prefix | Content |
|
||||||
|
| ------- | ----------------- | ------------------------------------------------------------------------ |
|
||||||
|
| WRNexus | `0` | Components, their props/outputs/slots, directives (`@click`, `client:*`) |
|
||||||
|
| HTML | `1` | Tags, attributes, attribute values |
|
||||||
|
|
||||||
|
`sortText` drives ordering independently of the label, so components rank above HTML tags
|
||||||
|
without filtering anything out. **Outside a view block**, behaviour is unchanged: WRN keywords
|
||||||
|
plus workspace items.
|
||||||
|
|
||||||
|
The server already indexes components, props, outputs, and slots
|
||||||
|
(`buildWorkspaceCompletionItems` in `packages/language-server/src/workspace.ts`), so both halves
|
||||||
|
of the merge are already available to it.
|
||||||
|
|
||||||
|
**Deduplication on exact label match, WRNexus wins.** A component named `Table` and the HTML
|
||||||
|
`table` differ in case and both survive; a component that genuinely shadows an HTML tag name
|
||||||
|
resolves to the component.
|
||||||
|
|
||||||
|
### Trigger characters
|
||||||
|
|
||||||
|
The server currently declares `["<", "@", ":", "."]`. Attributes and values additionally need
|
||||||
|
`" "`, `"="`, `"\""`, and `"/"`.
|
||||||
|
|
||||||
|
### This fixes an existing bug
|
||||||
|
|
||||||
|
The extension's `completion.js` registers its own provider with `<` among its trigger
|
||||||
|
characters, and the language server answers `textDocument/completion` as well. VS Code
|
||||||
|
concatenates both today, producing duplicate entries and unpredictable ordering before HTML is
|
||||||
|
involved at all.
|
||||||
|
|
||||||
|
As part of this work the extension's provider returns nothing when the position is inside a view
|
||||||
|
block, and keeps its current behaviour elsewhere. One owner per context.
|
||||||
|
|
||||||
|
**Consequence to accept knowingly:** the server becomes authoritative for the richest completion
|
||||||
|
context, so future component-intelligence work belongs in the server rather than in
|
||||||
|
`completion.js`.
|
||||||
|
|
||||||
|
## Hover
|
||||||
|
|
||||||
|
`textDocument/hover` answers from the HTML service over the virtual document when the position
|
||||||
|
is inside a view region, giving MDN documentation for tags and attributes. Outside a view
|
||||||
|
region, existing hover behaviour is unchanged.
|
||||||
|
|
||||||
|
Where a position resolves to a WRNexus component or prop, the component's own detail wins over
|
||||||
|
any HTML entry of the same name, matching the completion precedence rule above.
|
||||||
|
|
||||||
|
## Tag handling
|
||||||
|
|
||||||
|
### Linked editing is standard LSP
|
||||||
|
|
||||||
|
Renaming `<div>` and having `</div>` follow is `textDocument/linkedEditingRange` (LSP 3.16), so
|
||||||
|
it lives in the shared server like everything else.
|
||||||
|
|
||||||
|
### Auto-close on type is the one client-side piece
|
||||||
|
|
||||||
|
LSP has no request for "close this tag as I type". VS Code's own HTML extension implements it
|
||||||
|
client-side, and this follows the same shape:
|
||||||
|
|
||||||
|
1. The extension subscribes to `onDidChangeTextDocument`, filtered to `wrn` documents.
|
||||||
|
2. When the typed character is `>` or `/`, it sends a custom request, `wrn/tagComplete`.
|
||||||
|
3. The server runs the HTML service's `doTagComplete` against the virtual document and returns a
|
||||||
|
snippet or `null`.
|
||||||
|
4. The client inserts it with `insertSnippet`, so the cursor lands between the tags.
|
||||||
|
|
||||||
|
The decision stays server-side because it needs parse knowledge: void elements (`<br>`, `<img>`,
|
||||||
|
`<input>`) must not be closed, and an already-closed tag must not be closed twice. Returning
|
||||||
|
`null` outside a view region is what stops it firing inside `functions { }` or `style { }`.
|
||||||
|
|
||||||
|
Component tags come along for free: `<Card>` closes to `</Card>` because the HTML service closes
|
||||||
|
unknown tags like any other, and `<Card /` completes to `<Card />` through the same `/` path.
|
||||||
|
|
||||||
|
**New setting:** `wrnexus.html.autoClosingTags`, default `true`, following the existing
|
||||||
|
`wrnexus.*` naming.
|
||||||
|
|
||||||
|
### Emmet
|
||||||
|
|
||||||
|
A manifest change: `emmet.includeLanguages: { "wrn": "html" }` in `contributes.configurationDefaults`.
|
||||||
|
|
||||||
|
**Known limitation:** `emmet.includeLanguages` is per-language, not per-region, so Emmet is also
|
||||||
|
live inside `functions { }` and `style { }` blocks. VS Code offers no way to scope it to a
|
||||||
|
region. Emmet only expands on Tab against an abbreviation pattern, so misfires are rare, but the
|
||||||
|
edge is real.
|
||||||
|
|
||||||
|
## Folding
|
||||||
|
|
||||||
|
`textDocument/foldingRange` in the server returns tag-level ranges from the HTML service over
|
||||||
|
the virtual document, filtered to view regions.
|
||||||
|
|
||||||
|
Today folding comes only from `language-configuration.json` markers, which work at block level
|
||||||
|
(`page`, `component`, `view`, braces). Markup does not fold, so a long `<table>` cannot be
|
||||||
|
collapsed. VS Code merges marker-based folding with provider ranges, so block folding continues
|
||||||
|
to work unchanged and tag folding appears inside markup.
|
||||||
|
|
||||||
|
**One rule:** return ranges only where the virtual document is non-blank. A range spanning
|
||||||
|
outside a view region would let a fold swallow a brace boundary.
|
||||||
|
|
||||||
|
## Formatting is deliberately excluded
|
||||||
|
|
||||||
|
`formatWrn` (`packages/syntax/src/formatter.ts`) is 927 lines, iterates to a fixed point with
|
||||||
|
cycle detection, and already handles tags, attribute wrapping, `multilineAttributes`, and
|
||||||
|
`printWidth`. It is a markup formatter that understands WRNexus syntax.
|
||||||
|
|
||||||
|
Adding HTML formatting would do two harmful things:
|
||||||
|
|
||||||
|
- **Two formatters would fight.** Output would depend on which ran last.
|
||||||
|
- **It would mangle syntax it does not model.** `@click={handler}` and `client:visible` are not
|
||||||
|
HTML attributes, and an HTML formatter is free to rewrite spacing inside them.
|
||||||
|
|
||||||
|
If markup formatting is unsatisfying, the fix is improving `formatWrn`. That is separate work.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
`vscode-html-languageservice` becomes a dependency of **both** `packages/language-server` and
|
||||||
|
`editors/vscode`.
|
||||||
|
|
||||||
|
The editor bundler (`scripts/build-editor-language-server.mjs`) bundles only workspace sources
|
||||||
|
and passes other `require`s through to Node, so the package must be resolvable at runtime from
|
||||||
|
the extension. `editors/vscode` currently ships exactly one runtime dependency
|
||||||
|
(`vscode-languageclient`); this adds the second.
|
||||||
|
|
||||||
|
`check:editor-language-server` already verifies the bundled `.cjs` starts under Node, so a
|
||||||
|
missing or unresolvable dependency fails the gate rather than shipping a broken VSIX.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Region scanner (`packages/language-server/test/`)
|
||||||
|
|
||||||
|
- **The invariant**, property-style across fixtures: virtual text length equals source length and
|
||||||
|
newlines sit at identical offsets.
|
||||||
|
- **Apostrophes in text**: `<p>it's fine</p>` followed by a second view block — both regions
|
||||||
|
found.
|
||||||
|
- **Nested interpolation**: `class={cond ? "a" : "b"}` and `{{ a: 1 }}` do not end the region.
|
||||||
|
- **Broken markup**: `<div class="` mid-typing still yields a region. This is the normal case for
|
||||||
|
completion, not an edge case.
|
||||||
|
- **Multiple view blocks**, and files with none.
|
||||||
|
|
||||||
|
### Completion
|
||||||
|
|
||||||
|
- Inside a view block: both sources present, WRNexus `sortText` ordering first.
|
||||||
|
- Outside a view block: response identical to current behaviour — the guard proving non-markup
|
||||||
|
contexts are undisturbed.
|
||||||
|
- Collision: a component named `Table` yields one entry, the component.
|
||||||
|
|
||||||
|
### Tag handling
|
||||||
|
|
||||||
|
- `<div>` → `</div>`; `<br>` → nothing; `<Card /` → `/>`; outside a view region → `null`.
|
||||||
|
- Linked editing returns ranges covering both the opening and closing tag names.
|
||||||
|
|
||||||
|
### Hover
|
||||||
|
|
||||||
|
- Inside a view region, a known tag returns HTML documentation.
|
||||||
|
- A component name returns the component detail, not an HTML entry of the same name.
|
||||||
|
|
||||||
|
### Folding
|
||||||
|
|
||||||
|
- Every returned range lies inside a view region.
|
||||||
|
- Block-level marker folding still works.
|
||||||
|
|
||||||
|
### Toolchain guards
|
||||||
|
|
||||||
|
- `check:editor-language-server` passes with the new dependency (bundle starts under Node).
|
||||||
|
- Manifest assertion that `emmet.includeLanguages` maps `wrn` → `html`, alongside the existing
|
||||||
|
marketplace checks in `editors/vscode/test`.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- HTML formatting — see above; improve `formatWrn` instead.
|
||||||
|
- Moving the remaining `completion.js` component intelligence into the server. This design only
|
||||||
|
requires it to stand down inside view blocks; relocating the rest is follow-up work.
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.8.8
|
||||||
|
|
||||||
|
- Added HTML tag and attribute completions inside WRN `view` blocks, while preserving WRNexus
|
||||||
|
component completion priority and suppressing HTML suggestions outside markup regions.
|
||||||
|
- Added HTML hover documentation, folding ranges, linked tag editing, and automatic closing tags.
|
||||||
|
- Added Emmet expansion support for WRN documents and kept void elements from receiving closing tags.
|
||||||
|
- Hardened completion and auto-close handling against quoted attribute values, replaced selections,
|
||||||
|
stale asynchronous edits, and duplicate client-side suggestions.
|
||||||
|
|
||||||
## 0.8.3
|
## 0.8.3
|
||||||
|
|
||||||
- Rebuilt the embedded WRN compiler with the 0.8.3 SSR, computed-value, Async-scope, and typed loop-prop fixes.
|
- Rebuilt the embedded WRN compiler with the 0.8.3 SSR, computed-value, Async-scope, and typed loop-prop fixes.
|
||||||
@@ -16,8 +25,8 @@
|
|||||||
- Kept component prop/event intelligence active while the shared language server is enabled.
|
- Kept component prop/event intelligence active while the shared language server is enabled.
|
||||||
- Suppressed unavailable TypeScript standard-library and unmapped virtual-document implementation
|
- Suppressed unavailable TypeScript standard-library and unmapped virtual-document implementation
|
||||||
diagnostics in packaged extension environments.
|
diagnostics in packaged extension environments.
|
||||||
- Fixed false `unknown`/index-access diagnostics in valid dynamic event forwarding handlers such as
|
- Fixed false `unknown`/index-access diagnostics in valid dynamic event forwarding handlers that
|
||||||
`output[type](payload)` by preserving JavaScript semantics for omitted parameter types.
|
dispatch a payload by event type, preserving JavaScript semantics for omitted parameter types.
|
||||||
- Resolved TypeScript standard libraries from the active workspace so semantic diagnostics run
|
- Resolved TypeScript standard libraries from the active workspace so semantic diagnostics run
|
||||||
consistently in the repository and extension development environment.
|
consistently in the repository and extension development environment.
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,11 @@
|
|||||||
"maximum": 240,
|
"maximum": 240,
|
||||||
"scope": "resource",
|
"scope": "resource",
|
||||||
"description": "Preferred WRNexus formatter line width before long tags are expanded."
|
"description": "Preferred WRNexus formatter line width before long tags are expanded."
|
||||||
|
},
|
||||||
|
"wrnexus.html.autoClosingTags": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Automatically close HTML tags inside .wrn view blocks."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -141,6 +146,9 @@
|
|||||||
"files.associations": {
|
"files.associations": {
|
||||||
"*.wrn": "wrn"
|
"*.wrn": "wrn"
|
||||||
},
|
},
|
||||||
|
"emmet.includeLanguages": {
|
||||||
|
"wrn": "html"
|
||||||
|
},
|
||||||
"[wrn]": {
|
"[wrn]": {
|
||||||
"editor.defaultFormatter": "wrnexus.wrnexus",
|
"editor.defaultFormatter": "wrnexus.wrnexus",
|
||||||
"editor.formatOnSave": false,
|
"editor.formatOnSave": false,
|
||||||
@@ -276,13 +284,14 @@
|
|||||||
"check": "bun run build && bun run test && bun run validate",
|
"check": "bun run build && bun run test && bun run validate",
|
||||||
"vscode:prepublish": "bun run check",
|
"vscode:prepublish": "bun run check",
|
||||||
"package": "vsce package --no-dependencies --no-rewrite-relative-links",
|
"package": "vsce package --no-dependencies --no-rewrite-relative-links",
|
||||||
"publish": "vsce publish --no-dependencies --no-rewrite-relative-links",
|
"publish": "vsce publish --no-dependencies",
|
||||||
"publish:azure": "vsce publish --no-dependencies --no-rewrite-relative-links --azure-credential"
|
"publish:azure": "vsce publish --no-dependencies --azure-credential"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vscode/vsce": "^3.9.2"
|
"@vscode/vsce": "^3.9.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vscode-languageclient": "^10.1.0"
|
"vscode-languageclient": "^10.1.0",
|
||||||
|
"vscode-html-languageservice": "^5.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const vscode = require("vscode");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-close tags as they are typed.
|
||||||
|
*
|
||||||
|
* LSP has no request for this, so the client watches document changes and asks
|
||||||
|
* the server whether the tag should close. The server owns the decision because
|
||||||
|
* void elements and already-closed tags must not be closed.
|
||||||
|
*/
|
||||||
|
function registerAutoCloseTags(context, client) {
|
||||||
|
const listener = vscode.workspace.onDidChangeTextDocument(async (event) => {
|
||||||
|
if (event.document.languageId !== "wrn") return;
|
||||||
|
if (!vscode.workspace.getConfiguration("wrnexus.html").get("autoClosingTags", true)) return;
|
||||||
|
|
||||||
|
const changes = event.contentChanges;
|
||||||
|
if (!changes.length) return;
|
||||||
|
|
||||||
|
const typed = changes[0].text;
|
||||||
|
if (typed !== ">" && typed !== "/") return;
|
||||||
|
// Every cursor must have typed the same trigger. A replaced selection
|
||||||
|
// (overtype, or select-and-type) is declined rather than guessed at.
|
||||||
|
if (!changes.every((change) => change.text === typed && change.rangeLength === 0)) return;
|
||||||
|
|
||||||
|
const editor = vscode.window.activeTextEditor;
|
||||||
|
if (!editor || editor.document !== event.document) return;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Positions come from the editor's selections, not from the changes.
|
||||||
|
*
|
||||||
|
* A change's `range` is in coordinates from before the whole event, so with
|
||||||
|
* several cursors on one line every range after the first is short by the
|
||||||
|
* insertions preceding it. The selections have already been adjusted for
|
||||||
|
* the edit, so they are where the carets actually are.
|
||||||
|
*/
|
||||||
|
const positions = editor.selections.map((selection) => selection.active);
|
||||||
|
if (positions.length !== changes.length) return;
|
||||||
|
if (!editor.selections.every((selection) => selection.isEmpty)) return;
|
||||||
|
|
||||||
|
const documentVersion = event.document.version;
|
||||||
|
const snippets = await Promise.all(
|
||||||
|
positions.map((position) =>
|
||||||
|
client.sendRequest("wrn/tagComplete", {
|
||||||
|
textDocument: { uri: event.document.uri.toString() },
|
||||||
|
position: { line: position.line, character: position.character },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!snippets.every((snippet) => typeof snippet === "string" && snippet)) return;
|
||||||
|
/*
|
||||||
|
* One insertSnippet call carries one snippet, and it is the only form that
|
||||||
|
* keeps every caret: inserting sequentially would collapse the selection to
|
||||||
|
* the first snippet and invalidate the remaining positions. Cursors that
|
||||||
|
* want different closing tags are therefore declined rather than
|
||||||
|
* half-applied -- multi-cursor editing of matching lines, which is what
|
||||||
|
* this is for, produces one snippet for all of them.
|
||||||
|
*/
|
||||||
|
if (!snippets.every((snippet) => snippet === snippets[0])) return;
|
||||||
|
|
||||||
|
// The user may have kept typing during the round-trip; re-validate everything the
|
||||||
|
// insertion depends on before touching the document, since a stale offset would
|
||||||
|
// silently corrupt it.
|
||||||
|
if (vscode.window.activeTextEditor !== editor) return;
|
||||||
|
if (editor.document !== event.document) return;
|
||||||
|
if (editor.document.version !== documentVersion) return;
|
||||||
|
if (editor.selections.length !== positions.length) return;
|
||||||
|
if (
|
||||||
|
!editor.selections.every(
|
||||||
|
(selection, index) => selection.isEmpty && selection.active.isEqual(positions[index]),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await editor.insertSnippet(new vscode.SnippetString(snippets[0]), positions);
|
||||||
|
});
|
||||||
|
|
||||||
|
context.subscriptions.push(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerAutoCloseTags };
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
||||||
// WRN editor compiler source hash: fd183ab8c54df72c779d099d7625ce0068e49bea458052335c77cbf31ccf9179
|
// WRN editor compiler source hash: 27f13fbc79aedf4736913f268ab4af1f03a236ea44960cffb7caff225d157faf
|
||||||
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
||||||
// Generated with TypeScript: 6.0.3
|
// Generated with TypeScript: 6.0.3
|
||||||
const __nodeRequire = require;
|
const __nodeRequire = require;
|
||||||
@@ -1261,7 +1261,21 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
|
|||||||
// templateEscape, swapped for its real `${…}` code after escaping.
|
// templateEscape, swapped for its real `${…}` code after escaping.
|
||||||
if (node.type === "each" || node.type === "if") {
|
if (node.type === "each" || node.type === "if") {
|
||||||
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
|
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
|
||||||
return `\x00WRNEACH${loops.length - 1}\x00`;
|
const definition = node.type === "each"
|
||||||
|
? {
|
||||||
|
list: node.list,
|
||||||
|
item: node.item,
|
||||||
|
index: node.index,
|
||||||
|
key: node.key,
|
||||||
|
body: renderClientControlTemplate(node.body),
|
||||||
|
empty: renderClientControlTemplate(node.empty),
|
||||||
|
}
|
||||||
|
: node.branches.map((branch) => ({
|
||||||
|
cond: branch.cond,
|
||||||
|
body: renderClientControlTemplate(branch.body),
|
||||||
|
}));
|
||||||
|
const attribute = node.type === "each" ? "data-wrn-each" : "data-wrn-if";
|
||||||
|
return `<template ${attribute}="${encodeClientControl(definition)}"></template>\x00WRNEACH${loops.length - 1}\x00<template data-wrn-control-end></template>`;
|
||||||
}
|
}
|
||||||
if (node.tag === "Static" || node.tag === "Dynamic") {
|
if (node.tag === "Static" || node.tag === "Dynamic") {
|
||||||
const inner = node.children
|
const inner = node.children
|
||||||
@@ -2425,6 +2439,76 @@ function compileAttrValue(raw, ctx) {
|
|||||||
}
|
}
|
||||||
return out + escLit(attrEscape(raw.slice(last)));
|
return out + escLit(attrEscape(raw.slice(last)));
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Serialize a control-block body as inert browser-side template markup.
|
||||||
|
* Values deliberately remain as mustaches: the CSR runtime evaluates them
|
||||||
|
* against the component scope (and `{#each}` locals) when it materializes the
|
||||||
|
* template. The string is base64 encoded before it is placed in HTML.
|
||||||
|
*/
|
||||||
|
function renderClientControlTemplate(nodes) {
|
||||||
|
const render = (node) => {
|
||||||
|
if (node.type === "text") {
|
||||||
|
return node.value.replace(/\{([^{}]+)\}/g, (whole, rawExpression) => {
|
||||||
|
const expression = rawExpression.trim();
|
||||||
|
return expression.startsWith("t:")
|
||||||
|
? `<span data-t="${attrEscape(expression.slice(2).trim())}"></span>`
|
||||||
|
: `<span data-text="${attrEscape(expression)}">${whole}</span>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (node.type === "each") {
|
||||||
|
return `<template data-wrn-each="${attrEscape(encodeClientControl({
|
||||||
|
list: node.list,
|
||||||
|
item: node.item,
|
||||||
|
index: node.index,
|
||||||
|
key: node.key,
|
||||||
|
body: renderClientControlTemplate(node.body),
|
||||||
|
empty: renderClientControlTemplate(node.empty),
|
||||||
|
}))}"></template><template data-wrn-control-end></template>`;
|
||||||
|
}
|
||||||
|
if (node.type === "if") {
|
||||||
|
return `<template data-wrn-if="${attrEscape(encodeClientControl(node.branches.map((branch) => ({
|
||||||
|
cond: branch.cond,
|
||||||
|
body: renderClientControlTemplate(branch.body),
|
||||||
|
}))))}"></template><template data-wrn-control-end></template>`;
|
||||||
|
}
|
||||||
|
const componentTag = isComponentTag(node.tag);
|
||||||
|
let bindIndex = 0;
|
||||||
|
const attrs = node.attrs
|
||||||
|
.map((attribute) => {
|
||||||
|
const name = attribute.event
|
||||||
|
? componentTag
|
||||||
|
? componentEventAttribute(attribute.name)
|
||||||
|
: eventAttribute(attribute.name)
|
||||||
|
: attribute.name;
|
||||||
|
if (attribute.boolean)
|
||||||
|
return ` ${name}`;
|
||||||
|
if (attribute.name.startsWith("class:")) {
|
||||||
|
const expression = unwrapDirectiveExpression(attribute.value);
|
||||||
|
return ` data-wrn-class-${bindIndex++}="${attrEscape(JSON.stringify([attribute.name.slice("class:".length), expression]))}"`;
|
||||||
|
}
|
||||||
|
if (attribute.name === "data-show") {
|
||||||
|
return ` data-show="${attrEscape(unwrapDirectiveExpression(attribute.value))}"`;
|
||||||
|
}
|
||||||
|
const rendered = ` ${name}="${attrEscape(attribute.value)}"`;
|
||||||
|
return attribute.value.includes("{")
|
||||||
|
? `${rendered} data-wrn-bind-${bindIndex++}="${attrEscape(JSON.stringify([name, attribute.value]))}"`
|
||||||
|
: rendered;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
const children = node.children.map(render).join("");
|
||||||
|
if (node.tag === "Static")
|
||||||
|
return children;
|
||||||
|
if (componentTag)
|
||||||
|
return `<div data-component="${attrEscape(node.tag)}"${attrs}>${children}</div>`;
|
||||||
|
if (parser_ts_1.VOID_ELEMENTS.has(node.tag.toLowerCase()))
|
||||||
|
return `<${node.tag}${attrs}>`;
|
||||||
|
return `<${node.tag}${attrs}>${children}</${node.tag}>`;
|
||||||
|
};
|
||||||
|
return nodes.map(render).join("");
|
||||||
|
}
|
||||||
|
function encodeClientControl(value) {
|
||||||
|
return node_buffer_1.Buffer.from(JSON.stringify(value), "utf8").toString("base64");
|
||||||
|
}
|
||||||
function renderComponentIfNode(node, ctx) {
|
function renderComponentIfNode(node, ctx) {
|
||||||
let expression = "``";
|
let expression = "``";
|
||||||
for (let index = node.branches.length - 1; index >= 0; index--) {
|
for (let index = node.branches.length - 1; index >= 0; index--) {
|
||||||
@@ -2436,7 +2520,11 @@ function renderComponentIfNode(node, ctx) {
|
|||||||
? bodyExpression
|
? bodyExpression
|
||||||
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
|
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
|
||||||
}
|
}
|
||||||
return "${" + expression + "}";
|
const definition = encodeClientControl(node.branches.map((branch) => ({
|
||||||
|
cond: branch.cond,
|
||||||
|
body: renderClientControlTemplate(branch.body),
|
||||||
|
})));
|
||||||
|
return `<template data-wrn-if="${definition}"></template>${"${" + expression + "}"}<template data-wrn-control-end></template>`;
|
||||||
}
|
}
|
||||||
function renderComponentEachNode(node, ctx) {
|
function renderComponentEachNode(node, ctx) {
|
||||||
const item = node.item;
|
const item = node.item;
|
||||||
@@ -2448,7 +2536,7 @@ function renderComponentEachNode(node, ctx) {
|
|||||||
};
|
};
|
||||||
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
|
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
|
||||||
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
|
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
|
||||||
return ("${(() => { const __wl = Array.isArray(" +
|
const serverBody = "${(() => { const __wl = Array.isArray(" +
|
||||||
list +
|
list +
|
||||||
") ? (" +
|
") ? (" +
|
||||||
list +
|
list +
|
||||||
@@ -2460,7 +2548,16 @@ function renderComponentEachNode(node, ctx) {
|
|||||||
body +
|
body +
|
||||||
'`).join("") : `' +
|
'`).join("") : `' +
|
||||||
empty +
|
empty +
|
||||||
"`; })()}");
|
"`; })()}";
|
||||||
|
const definition = encodeClientControl({
|
||||||
|
list: node.list,
|
||||||
|
item: node.item,
|
||||||
|
index: node.index,
|
||||||
|
key: node.key,
|
||||||
|
body: renderClientControlTemplate(node.body),
|
||||||
|
empty: renderClientControlTemplate(node.empty),
|
||||||
|
});
|
||||||
|
return `<template data-wrn-each="${definition}"></template>${serverBody}<template data-wrn-control-end></template>`;
|
||||||
}
|
}
|
||||||
function serverLoopLocalsAttribute(ctx) {
|
function serverLoopLocalsAttribute(ctx) {
|
||||||
const locals = [...(ctx.serverLocals ?? [])];
|
const locals = [...(ctx.serverLocals ?? [])];
|
||||||
|
|||||||
@@ -570,6 +570,41 @@ function isInsideWatch(document, position) {
|
|||||||
return depth > 0;
|
return depth > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an offset sits inside a `view { }` block.
|
||||||
|
*
|
||||||
|
* The language server owns completion there and returns a merged list, so this
|
||||||
|
* provider stands down to avoid VS Code concatenating two independent lists.
|
||||||
|
* Quotes are only tracked inside a tag: `<p>it's</p>` would otherwise open a
|
||||||
|
* string that never closes.
|
||||||
|
*/
|
||||||
|
function isInsideViewBlock(text, offset) {
|
||||||
|
const pattern = /\bview\s*\{/g;
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(text))) {
|
||||||
|
const start = match.index + match[0].length;
|
||||||
|
let depth = 1;
|
||||||
|
let inTag = false;
|
||||||
|
let quote = null;
|
||||||
|
let index = start;
|
||||||
|
for (; index < text.length && depth > 0; index += 1) {
|
||||||
|
const char = text[index];
|
||||||
|
if (quote) {
|
||||||
|
if (char === quote) quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inTag && (char === '"' || char === "'")) quote = char;
|
||||||
|
else if (char === "<") inTag = true;
|
||||||
|
else if (char === ">") inTag = false;
|
||||||
|
else if (char === "{") depth += 1;
|
||||||
|
else if (char === "}") depth -= 1;
|
||||||
|
}
|
||||||
|
if (offset >= start && offset <= index) return true;
|
||||||
|
pattern.lastIndex = index;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function isAfterWatchKeyword(document, position) {
|
function isAfterWatchKeyword(document, position) {
|
||||||
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
||||||
|
|
||||||
@@ -634,6 +669,8 @@ function addFunctionCompletions(items, document) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function provideCompletionItems(document, position) {
|
function provideCompletionItems(document, position) {
|
||||||
|
if (isInsideViewBlock(document.getText(), document.offsetAt(position))) return [];
|
||||||
|
|
||||||
const items = [];
|
const items = [];
|
||||||
|
|
||||||
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
||||||
@@ -707,6 +744,7 @@ module.exports = {
|
|||||||
extractProps,
|
extractProps,
|
||||||
extractRouteParams,
|
extractRouteParams,
|
||||||
extractStates,
|
extractStates,
|
||||||
|
isInsideViewBlock,
|
||||||
provideCompletionItems,
|
provideCompletionItems,
|
||||||
registerCompletionProvider,
|
registerCompletionProvider,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// WRN editor extension source hash: c9938fa5f643ca435ead2c8dd5b545c6906c37c0024cf3ea788666236c28ddb6
|
// WRN editor extension source hash: 63bce75e2686c7586a3a08b8811ebc681d2265fdfe54e984630614e3dcef21f5
|
||||||
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
||||||
"use strict";
|
"use strict";
|
||||||
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||||||
@@ -22701,10 +22701,63 @@ var require_main5 = __commonJS((exports2) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// editors/vscode/src/auto-close-tags.js
|
||||||
|
var require_auto_close_tags = __commonJS((exports2, module2) => {
|
||||||
|
var vscode = require("vscode");
|
||||||
|
function registerAutoCloseTags(context, client) {
|
||||||
|
const listener = vscode.workspace.onDidChangeTextDocument(async (event) => {
|
||||||
|
if (event.document.languageId !== "wrn")
|
||||||
|
return;
|
||||||
|
if (!vscode.workspace.getConfiguration("wrnexus.html").get("autoClosingTags", true))
|
||||||
|
return;
|
||||||
|
const changes = event.contentChanges;
|
||||||
|
if (!changes.length)
|
||||||
|
return;
|
||||||
|
const typed = changes[0].text;
|
||||||
|
if (typed !== ">" && typed !== "/")
|
||||||
|
return;
|
||||||
|
if (!changes.every((change) => change.text === typed && change.rangeLength === 0))
|
||||||
|
return;
|
||||||
|
const editor = vscode.window.activeTextEditor;
|
||||||
|
if (!editor || editor.document !== event.document)
|
||||||
|
return;
|
||||||
|
const positions = editor.selections.map((selection) => selection.active);
|
||||||
|
if (positions.length !== changes.length)
|
||||||
|
return;
|
||||||
|
if (!editor.selections.every((selection) => selection.isEmpty))
|
||||||
|
return;
|
||||||
|
const documentVersion = event.document.version;
|
||||||
|
const snippets = await Promise.all(positions.map((position) => client.sendRequest("wrn/tagComplete", {
|
||||||
|
textDocument: { uri: event.document.uri.toString() },
|
||||||
|
position: { line: position.line, character: position.character }
|
||||||
|
})));
|
||||||
|
if (!snippets.every((snippet) => typeof snippet === "string" && snippet))
|
||||||
|
return;
|
||||||
|
if (!snippets.every((snippet) => snippet === snippets[0]))
|
||||||
|
return;
|
||||||
|
if (vscode.window.activeTextEditor !== editor)
|
||||||
|
return;
|
||||||
|
if (editor.document !== event.document)
|
||||||
|
return;
|
||||||
|
if (editor.document.version !== documentVersion)
|
||||||
|
return;
|
||||||
|
if (editor.selections.length !== positions.length)
|
||||||
|
return;
|
||||||
|
if (!editor.selections.every((selection, index) => selection.isEmpty && selection.active.isEqual(positions[index]))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await editor.insertSnippet(new vscode.SnippetString(snippets[0]), positions);
|
||||||
|
});
|
||||||
|
context.subscriptions.push(listener);
|
||||||
|
}
|
||||||
|
module2.exports = { registerAutoCloseTags };
|
||||||
|
});
|
||||||
|
|
||||||
// editors/vscode/src/extension.js
|
// editors/vscode/src/extension.js
|
||||||
var path = require("node:path");
|
var path = require("node:path");
|
||||||
var vscode = require("vscode");
|
var vscode = require("vscode");
|
||||||
var { LanguageClient, TransportKind } = require_main5();
|
var { LanguageClient, TransportKind } = require_main5();
|
||||||
|
var { registerAutoCloseTags } = require_auto_close_tags();
|
||||||
var WRN_LANGUAGE_ID = "wrn";
|
var WRN_LANGUAGE_ID = "wrn";
|
||||||
var client;
|
var client;
|
||||||
async function recoverWrnLanguage(document) {
|
async function recoverWrnLanguage(document) {
|
||||||
@@ -22730,6 +22783,7 @@ async function activate(context) {
|
|||||||
debug: { module: module2, transport: TransportKind.stdio, options: { execArgv: ["--nolazy"] } }
|
debug: { module: module2, transport: TransportKind.stdio, options: { execArgv: ["--nolazy"] } }
|
||||||
}, { documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] });
|
}, { documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] });
|
||||||
await client.start();
|
await client.start();
|
||||||
|
registerAutoCloseTags(context, client);
|
||||||
}
|
}
|
||||||
async function deactivate() {
|
async function deactivate() {
|
||||||
const running = client;
|
const running = client;
|
||||||
@@ -22737,4 +22791,4 @@ async function deactivate() {
|
|||||||
if (running)
|
if (running)
|
||||||
await running.stop();
|
await running.stop();
|
||||||
}
|
}
|
||||||
module.exports = { activate, deactivate, recoverWrnLanguage };
|
module.exports = { activate, deactivate, recoverWrnLanguage, registerAutoCloseTags };
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const vscode = require("vscode");
|
const vscode = require("vscode");
|
||||||
const { LanguageClient, TransportKind } = require("vscode-languageclient/node");
|
const { LanguageClient, TransportKind } = require("vscode-languageclient/node");
|
||||||
|
const { registerAutoCloseTags } = require("./auto-close-tags.js");
|
||||||
|
|
||||||
const WRN_LANGUAGE_ID = "wrn";
|
const WRN_LANGUAGE_ID = "wrn";
|
||||||
/** @type {LanguageClient | undefined} */
|
/** @type {LanguageClient | undefined} */
|
||||||
@@ -43,6 +44,7 @@ async function activate(context) {
|
|||||||
{ documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] },
|
{ documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] },
|
||||||
);
|
);
|
||||||
await client.start();
|
await client.start();
|
||||||
|
registerAutoCloseTags(context, client);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deactivate() {
|
async function deactivate() {
|
||||||
@@ -51,4 +53,4 @@ async function deactivate() {
|
|||||||
if (running) await running.stop();
|
if (running) await running.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { activate, deactivate, recoverWrnLanguage };
|
module.exports = { activate, deactivate, recoverWrnLanguage, registerAutoCloseTags };
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,178 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { test } = require("node:test");
|
||||||
|
const { installVsCodeHost } = require("./vscode-host.js");
|
||||||
|
|
||||||
|
class Position {
|
||||||
|
constructor(line, character) {
|
||||||
|
this.line = line;
|
||||||
|
this.character = character;
|
||||||
|
}
|
||||||
|
translate(lineDelta, characterDelta) {
|
||||||
|
return new Position(this.line + lineDelta, this.character + characterDelta);
|
||||||
|
}
|
||||||
|
isEqual(other) {
|
||||||
|
return this.line === other.line && this.character === other.character;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Selection {
|
||||||
|
constructor(active) {
|
||||||
|
this.active = active;
|
||||||
|
this.anchor = active;
|
||||||
|
this.isEmpty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SnippetString {
|
||||||
|
constructor(value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let changeListener = null;
|
||||||
|
const host = {
|
||||||
|
Position,
|
||||||
|
Selection,
|
||||||
|
SnippetString,
|
||||||
|
workspace: {
|
||||||
|
onDidChangeTextDocument(listener) {
|
||||||
|
changeListener = listener;
|
||||||
|
return { dispose() {} };
|
||||||
|
},
|
||||||
|
getConfiguration() {
|
||||||
|
return { get: (_key, fallback) => fallback };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
window: { activeTextEditor: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreHost = installVsCodeHost(host);
|
||||||
|
const { registerAutoCloseTags } = require("../src/auto-close-tags.js");
|
||||||
|
restoreHost();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive the handler the way VS Code does: the document has already been
|
||||||
|
* updated and the carets moved by the time the change event fires.
|
||||||
|
*/
|
||||||
|
function scenario({ carets, snippetFor, typed = ">" }) {
|
||||||
|
const inserted = [];
|
||||||
|
const asked = [];
|
||||||
|
const document = { languageId: "wrn", version: 1, uri: { toString: () => "file:///a.wrn" } };
|
||||||
|
const editor = {
|
||||||
|
document,
|
||||||
|
selections: carets.map((caret) => new Selection(caret)),
|
||||||
|
insertSnippet(snippet, positions) {
|
||||||
|
inserted.push({ value: snippet.value, positions });
|
||||||
|
return Promise.resolve(true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
editor.selection = editor.selections[0];
|
||||||
|
host.window.activeTextEditor = editor;
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
sendRequest(_method, params) {
|
||||||
|
asked.push(params.position);
|
||||||
|
return Promise.resolve(snippetFor(params.position));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
registerAutoCloseTags({ subscriptions: [] }, client);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inserted,
|
||||||
|
asked,
|
||||||
|
fire: () =>
|
||||||
|
changeListener({
|
||||||
|
document,
|
||||||
|
// Pre-edit coordinates, deliberately not usable as caret positions.
|
||||||
|
contentChanges: carets.map(() => ({
|
||||||
|
text: typed,
|
||||||
|
rangeLength: 0,
|
||||||
|
range: { start: new Position(0, 0) },
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("closes the tag at a single caret", async () => {
|
||||||
|
const run = scenario({ carets: [new Position(1, 8)], snippetFor: () => "$0</div>" });
|
||||||
|
await run.fire();
|
||||||
|
|
||||||
|
assert.equal(run.inserted.length, 1);
|
||||||
|
assert.equal(run.inserted[0].value, "$0</div>");
|
||||||
|
assert.deepEqual(
|
||||||
|
run.inserted[0].positions.map((p) => [p.line, p.character]),
|
||||||
|
[[1, 8]],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closes the tag at every caret in one insertion", async () => {
|
||||||
|
// One insertSnippet call is what keeps all the carets alive: inserting
|
||||||
|
// sequentially would collapse the selection to the first snippet.
|
||||||
|
const run = scenario({
|
||||||
|
carets: [new Position(1, 8), new Position(2, 8), new Position(3, 8)],
|
||||||
|
snippetFor: () => "$0</div>",
|
||||||
|
});
|
||||||
|
await run.fire();
|
||||||
|
|
||||||
|
assert.equal(run.asked.length, 3);
|
||||||
|
assert.equal(run.inserted.length, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
run.inserted[0].positions.map((p) => [p.line, p.character]),
|
||||||
|
[
|
||||||
|
[1, 8],
|
||||||
|
[2, 8],
|
||||||
|
[3, 8],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asks about each caret's own position rather than the change ranges", async () => {
|
||||||
|
// Every contentChange above reports (0, 0). Using those would query and
|
||||||
|
// insert at the wrong offsets once more than one caret is on a line.
|
||||||
|
const run = scenario({
|
||||||
|
carets: [new Position(4, 12), new Position(9, 3)],
|
||||||
|
snippetFor: () => "$0</p>",
|
||||||
|
});
|
||||||
|
await run.fire();
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
run.asked.map((p) => [p.line, p.character]),
|
||||||
|
[
|
||||||
|
[4, 12],
|
||||||
|
[9, 3],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declines when the carets want different closing tags", async () => {
|
||||||
|
const run = scenario({
|
||||||
|
carets: [new Position(1, 8), new Position(2, 8)],
|
||||||
|
snippetFor: (position) => (position.line === 1 ? "$0</div>" : "$0</span>"),
|
||||||
|
});
|
||||||
|
await run.fire();
|
||||||
|
|
||||||
|
assert.equal(run.inserted.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declines when any caret has no tag to close", async () => {
|
||||||
|
const run = scenario({
|
||||||
|
carets: [new Position(1, 8), new Position(2, 8)],
|
||||||
|
snippetFor: (position) => (position.line === 1 ? "$0</br>" : null),
|
||||||
|
});
|
||||||
|
await run.fire();
|
||||||
|
|
||||||
|
assert.equal(run.inserted.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declines a replaced selection", async () => {
|
||||||
|
const run = scenario({ carets: [new Position(1, 8)], snippetFor: () => "$0</div>" });
|
||||||
|
await changeListener({
|
||||||
|
document: { languageId: "wrn", version: 1, uri: { toString: () => "file:///a.wrn" } },
|
||||||
|
contentChanges: [{ text: ">", rangeLength: 3, range: { start: new Position(1, 5) } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(run.inserted.length, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { installVsCodeHost } = require("./vscode-host.js");
|
||||||
|
|
||||||
|
const restoreHost = installVsCodeHost({
|
||||||
|
Position: class Position {
|
||||||
|
constructor(line, character) {
|
||||||
|
this.line = line;
|
||||||
|
this.character = character;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Range: class Range {
|
||||||
|
constructor(start, end) {
|
||||||
|
this.start = start;
|
||||||
|
this.end = end;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
CompletionItem: class CompletionItem {
|
||||||
|
constructor(label, kind) {
|
||||||
|
this.label = label;
|
||||||
|
this.kind = kind;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
CompletionItemKind: {
|
||||||
|
Event: 23,
|
||||||
|
Property: 10,
|
||||||
|
Function: 12,
|
||||||
|
Keyword: 14,
|
||||||
|
Variable: 13,
|
||||||
|
},
|
||||||
|
SnippetString: class SnippetString {
|
||||||
|
constructor(text) {
|
||||||
|
this.value = text;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MarkdownString: class MarkdownString {
|
||||||
|
constructor(text) {
|
||||||
|
this.value = text;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { isInsideViewBlock, provideCompletionItems } = require("../src/completion.js");
|
||||||
|
restoreHost();
|
||||||
|
|
||||||
|
const PAGE = `page Home {
|
||||||
|
view {
|
||||||
|
<div>hello</div>
|
||||||
|
}
|
||||||
|
functions {
|
||||||
|
function go() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
test("a markup offset is inside a view block", () => {
|
||||||
|
assert.equal(isInsideViewBlock(PAGE, PAGE.indexOf("<div")), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a functions-block offset is not inside a view block", () => {
|
||||||
|
assert.equal(isInsideViewBlock(PAGE, PAGE.indexOf("function go")), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("provideCompletionItems returns empty array when inside view block", () => {
|
||||||
|
const document = {
|
||||||
|
getText() {
|
||||||
|
return PAGE;
|
||||||
|
},
|
||||||
|
offsetAt() {
|
||||||
|
return PAGE.indexOf("<div");
|
||||||
|
},
|
||||||
|
lineAt() {
|
||||||
|
return { text: "<div>hello</div>" };
|
||||||
|
},
|
||||||
|
fileName: "test.wrn",
|
||||||
|
};
|
||||||
|
const position = { line: 2, character: 4 };
|
||||||
|
|
||||||
|
const result = provideCompletionItems(document, position);
|
||||||
|
assert.equal(Array.isArray(result), true);
|
||||||
|
assert.equal(result.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("provideCompletionItems returns non-empty array when inside functions block", () => {
|
||||||
|
const document = {
|
||||||
|
getText() {
|
||||||
|
return PAGE;
|
||||||
|
},
|
||||||
|
offsetAt() {
|
||||||
|
return PAGE.indexOf("function go");
|
||||||
|
},
|
||||||
|
lineAt() {
|
||||||
|
return { text: " function go() {}" };
|
||||||
|
},
|
||||||
|
fileName: "test.wrn",
|
||||||
|
};
|
||||||
|
const position = { line: 5, character: 4 };
|
||||||
|
|
||||||
|
const result = provideCompletionItems(document, position);
|
||||||
|
assert.equal(Array.isArray(result), true);
|
||||||
|
assert(result.length > 0, "should return non-empty completions outside view block");
|
||||||
|
});
|
||||||
@@ -2,17 +2,13 @@
|
|||||||
|
|
||||||
const assert = require("node:assert");
|
const assert = require("node:assert");
|
||||||
const { test } = require("node:test");
|
const { test } = require("node:test");
|
||||||
const Module = require("node:module");
|
const { installVsCodeHost } = require("./vscode-host.js");
|
||||||
|
|
||||||
// These extraction helpers are pure, but their module also registers VS Code
|
// These extraction helpers are pure, but their module also registers VS Code
|
||||||
// providers at runtime. Supply a minimal host shim for unit tests.
|
// providers at runtime. Supply a minimal host shim for unit tests.
|
||||||
const originalLoad = Module._load;
|
const restoreHost = installVsCodeHost({});
|
||||||
Module._load = function load(request, parent, isMain) {
|
|
||||||
if (request === "vscode") return {};
|
|
||||||
return originalLoad.call(this, request, parent, isMain);
|
|
||||||
};
|
|
||||||
const { extractRouteParams, extractStates } = require("../src/completion");
|
const { extractRouteParams, extractStates } = require("../src/completion");
|
||||||
Module._load = originalLoad;
|
restoreHost();
|
||||||
|
|
||||||
test("extracts dynamic route params from filename", () => {
|
test("extracts dynamic route params from filename", () => {
|
||||||
const document = {
|
const document = {
|
||||||
|
|||||||
@@ -2,30 +2,24 @@
|
|||||||
|
|
||||||
const assert = require("node:assert");
|
const assert = require("node:assert");
|
||||||
const { test } = require("node:test");
|
const { test } = require("node:test");
|
||||||
const Module = require("node:module");
|
const { installVsCodeHost } = require("./vscode-host.js");
|
||||||
|
|
||||||
const originalLoad = Module._load;
|
const restoreHost = installVsCodeHost({
|
||||||
Module._load = function load(request, parent, isMain) {
|
Diagnostic: class Diagnostic {
|
||||||
if (request === "vscode") {
|
constructor(range, message, severity) {
|
||||||
return {
|
this.range = range;
|
||||||
Diagnostic: class Diagnostic {
|
this.message = message;
|
||||||
constructor(range, message, severity) {
|
this.severity = severity;
|
||||||
this.range = range;
|
}
|
||||||
this.message = message;
|
},
|
||||||
this.severity = severity;
|
DiagnosticSeverity: { Error: 0, Warning: 1 },
|
||||||
}
|
Range: class Range {
|
||||||
},
|
constructor(start, end) {
|
||||||
DiagnosticSeverity: { Error: 0, Warning: 1 },
|
this.start = start;
|
||||||
Range: class Range {
|
this.end = end;
|
||||||
constructor(start, end) {
|
}
|
||||||
this.start = start;
|
},
|
||||||
this.end = end;
|
});
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return originalLoad.call(this, request, parent, isMain);
|
|
||||||
};
|
|
||||||
const {
|
const {
|
||||||
findTopLevelDeclaration,
|
findTopLevelDeclaration,
|
||||||
maskLeadingTrivia,
|
maskLeadingTrivia,
|
||||||
@@ -34,7 +28,7 @@ const {
|
|||||||
validateLayoutUsage,
|
validateLayoutUsage,
|
||||||
validateRootMembers,
|
validateRootMembers,
|
||||||
} = require("../src/diagnostics");
|
} = require("../src/diagnostics");
|
||||||
Module._load = originalLoad;
|
restoreHost();
|
||||||
|
|
||||||
function mockDocument() {
|
function mockDocument() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -113,6 +113,21 @@ try {
|
|||||||
readFileSync(join(root, rel), "utf8");
|
readFileSync(join(root, rel), "utf8");
|
||||||
ok(`Marketplace document exists: ${rel}`);
|
ok(`Marketplace document exists: ${rel}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emmetLanguages = manifest.contributes?.configurationDefaults?.["emmet.includeLanguages"];
|
||||||
|
emmetLanguages?.wrn === "html"
|
||||||
|
? ok("Emmet is mapped for wrn documents")
|
||||||
|
: bad("Emmet is mapped for wrn documents", `got ${JSON.stringify(emmetLanguages)}`);
|
||||||
|
|
||||||
|
const autoClose =
|
||||||
|
manifest.contributes?.configuration?.properties?.["wrnexus.html.autoClosingTags"];
|
||||||
|
autoClose?.type === "boolean" && autoClose?.default === true
|
||||||
|
? ok("auto-closing tags setting is contributed")
|
||||||
|
: bad("auto-closing tags setting is contributed", `got ${JSON.stringify(autoClose)}`);
|
||||||
|
|
||||||
|
manifest.dependencies?.["vscode-html-languageservice"]
|
||||||
|
? ok("HTML language service ships as a runtime dependency")
|
||||||
|
: bad("HTML language service ships as a runtime dependency");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
bad("Marketplace metadata", e.message);
|
bad("Marketplace metadata", e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supply a stub `vscode` host so extension sources can be unit tested.
|
||||||
|
*
|
||||||
|
* These files run under `node --test` (see the package's test script), where
|
||||||
|
* patching `Module._load` is enough. A bare `bun test` from the repository
|
||||||
|
* root also picks them up by filename, and Bun resolves `require` through its
|
||||||
|
* own resolver without consulting `Module._load` -- so under Bun the same
|
||||||
|
* files failed with "Cannot find package 'vscode'". Registering a virtual
|
||||||
|
* module covers that case, leaving one shim that works under both runners.
|
||||||
|
*
|
||||||
|
* Returns a function restoring the original loader.
|
||||||
|
*/
|
||||||
|
function installVsCodeHost(stub) {
|
||||||
|
const Module = require("node:module");
|
||||||
|
|
||||||
|
if (typeof Bun !== "undefined") {
|
||||||
|
require("bun").plugin({
|
||||||
|
name: "vscode-host-stub",
|
||||||
|
setup(build) {
|
||||||
|
build.module("vscode", () => ({ exports: stub, loader: "object" }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalLoad = Module._load;
|
||||||
|
Module._load = function load(request, parent, isMain) {
|
||||||
|
if (request === "vscode") return stub;
|
||||||
|
return originalLoad.call(this, request, parent, isMain);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
Module._load = originalLoad;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { installVsCodeHost };
|
||||||
@@ -19,10 +19,10 @@
|
|||||||
"@wrnexus/validation": "workspace:*"
|
"@wrnexus/validation": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.0",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.0.0",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// AUTO-GENERATED by `wrnexus db generate` — do not edit.
|
// AUTO-GENERATED by `wrnexus db generate` (dialect: sqlite) — do not edit.
|
||||||
import type { Db, ExecResult } from "@wrnexus/db";
|
import type { Db, ExecResult } from "@wrnexus/db";
|
||||||
import { users } from "./schema.ts";
|
import { users } from "./schema.ts";
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@wrnexus/test": "workspace:*",
|
"@wrnexus/test": "workspace:*",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@iconify-json/lucide": "^1.2.118",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.2.3",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.1",
|
||||||
"prettier": "^3.9.4",
|
"prettier": "^3.9.6",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript-eslint": "^8.65.0"
|
"typescript-eslint": "^8.67.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
"@wrnexus/validation": "workspace:*"
|
"@wrnexus/validation": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.0",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.0.0",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,10 @@
|
|||||||
"@wrnexus/ui": "workspace:*"
|
"@wrnexus/ui": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.118",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.2.3",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.5.0"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
"@wrnexus/ui": "workspace:*"
|
"@wrnexus/ui": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.118",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.2.3",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,40 +24,40 @@
|
|||||||
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/ai": "0.8.6",
|
"@wrnexus/ai": "^0.8.9",
|
||||||
"@wrnexus/auth": "0.8.6",
|
"@wrnexus/auth": "^0.8.12",
|
||||||
"@wrnexus/captcha": "0.8.6",
|
"@wrnexus/captcha": "^0.8.11",
|
||||||
"@wrnexus/core": "0.8.6",
|
"@wrnexus/core": "^0.8.9",
|
||||||
"@wrnexus/csr": "0.8.6",
|
"@wrnexus/csr": "^0.8.21",
|
||||||
"@wrnexus/db": "0.8.6",
|
"@wrnexus/db": "^0.8.15",
|
||||||
"@wrnexus/dev-server": "0.8.6",
|
"@wrnexus/dev-server": "^0.8.33",
|
||||||
"@wrnexus/encryption": "0.8.6",
|
"@wrnexus/encryption": "^0.8.9",
|
||||||
"@wrnexus/helpers": "0.8.6",
|
"@wrnexus/helpers": "^0.8.8",
|
||||||
"@wrnexus/i18n": "0.8.6",
|
"@wrnexus/i18n": "^0.8.11",
|
||||||
"@wrnexus/image": "0.8.6",
|
"@wrnexus/image": "^0.8.10",
|
||||||
"@wrnexus/jwt": "0.8.6",
|
"@wrnexus/jwt": "^0.8.9",
|
||||||
"@wrnexus/observability": "0.8.6",
|
"@wrnexus/observability": "^0.8.8",
|
||||||
"@wrnexus/realtime": "0.8.6",
|
"@wrnexus/realtime": "^0.8.10",
|
||||||
"@wrnexus/security": "0.8.6",
|
"@wrnexus/security": "^0.8.8",
|
||||||
"@wrnexus/store": "0.8.6",
|
"@wrnexus/store": "^0.8.8",
|
||||||
"@wrnexus/styles": "0.8.6",
|
"@wrnexus/styles": "^0.8.15",
|
||||||
"@wrnexus/tracking": "0.8.6",
|
"@wrnexus/tracking": "^0.8.8",
|
||||||
"@wrnexus/ui": "0.8.6",
|
"@wrnexus/ui": "^0.8.19",
|
||||||
"@wrnexus/uploader": "0.8.6",
|
"@wrnexus/uploader": "^0.8.10",
|
||||||
"@wrnexus/validation": "0.8.6",
|
"@wrnexus/validation": "^0.8.10",
|
||||||
"@wrnexus/authz": "0.8.6"
|
"@wrnexus/authz": "^0.8.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@wrnexus/cli": "0.8.6",
|
"@wrnexus/cli": "^0.8.36",
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"@iconify-json/lucide": "^1.2.118",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.2.3",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^10.8.1",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.65.0"
|
"typescript-eslint": "^8.67.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,40 +24,40 @@
|
|||||||
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/ai": "0.8.6",
|
"@wrnexus/ai": "^0.8.9",
|
||||||
"@wrnexus/auth": "0.8.6",
|
"@wrnexus/auth": "^0.8.12",
|
||||||
"@wrnexus/captcha": "0.8.6",
|
"@wrnexus/captcha": "^0.8.11",
|
||||||
"@wrnexus/core": "0.8.6",
|
"@wrnexus/core": "^0.8.9",
|
||||||
"@wrnexus/csr": "0.8.6",
|
"@wrnexus/csr": "^0.8.21",
|
||||||
"@wrnexus/db": "0.8.6",
|
"@wrnexus/db": "^0.8.15",
|
||||||
"@wrnexus/dev-server": "0.8.6",
|
"@wrnexus/dev-server": "^0.8.33",
|
||||||
"@wrnexus/encryption": "0.8.6",
|
"@wrnexus/encryption": "^0.8.9",
|
||||||
"@wrnexus/helpers": "0.8.6",
|
"@wrnexus/helpers": "^0.8.8",
|
||||||
"@wrnexus/i18n": "0.8.6",
|
"@wrnexus/i18n": "^0.8.11",
|
||||||
"@wrnexus/image": "0.8.6",
|
"@wrnexus/image": "^0.8.10",
|
||||||
"@wrnexus/jwt": "0.8.6",
|
"@wrnexus/jwt": "^0.8.9",
|
||||||
"@wrnexus/observability": "0.8.6",
|
"@wrnexus/observability": "^0.8.8",
|
||||||
"@wrnexus/realtime": "0.8.6",
|
"@wrnexus/realtime": "^0.8.10",
|
||||||
"@wrnexus/security": "0.8.6",
|
"@wrnexus/security": "^0.8.8",
|
||||||
"@wrnexus/store": "0.8.6",
|
"@wrnexus/store": "^0.8.8",
|
||||||
"@wrnexus/styles": "0.8.6",
|
"@wrnexus/styles": "^0.8.15",
|
||||||
"@wrnexus/tracking": "0.8.6",
|
"@wrnexus/tracking": "^0.8.8",
|
||||||
"@wrnexus/ui": "0.8.6",
|
"@wrnexus/ui": "^0.8.19",
|
||||||
"@wrnexus/uploader": "0.8.6",
|
"@wrnexus/uploader": "^0.8.10",
|
||||||
"@wrnexus/validation": "0.8.6",
|
"@wrnexus/validation": "^0.8.10",
|
||||||
"@wrnexus/authz": "0.8.6"
|
"@wrnexus/authz": "^0.8.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@wrnexus/cli": "0.8.6",
|
"@wrnexus/cli": "^0.8.36",
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"@iconify-json/lucide": "^1.2.118",
|
"@iconify-json/lucide": "^1.2.123",
|
||||||
"@iconify/tailwind4": "^1.2.3",
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
"@tailwindcss/cli": "^4.0.0",
|
"@tailwindcss/cli": "^4.3.3",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^10.8.1",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.65.0"
|
"typescript-eslint": "^8.67.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,12 @@
|
|||||||
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@wrnexus/cli": "0.8.6",
|
"@wrnexus/cli": "^0.8.36",
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^10.8.1",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.65.0"
|
"typescript-eslint": "^8.67.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,6 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/pubsub": "0.8.6"
|
"@wrnexus/pubsub": "^0.8.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/auth",
|
"name": "@wrnexus/auth",
|
||||||
"version": "0.8.11",
|
"version": "0.8.12",
|
||||||
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"@wrnexus/syntax": "workspace:*",
|
"@wrnexus/syntax": "workspace:*",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"wrnexus": {
|
"wrnexus": {
|
||||||
"plugin": {
|
"plugin": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/captcha",
|
"name": "@wrnexus/captcha",
|
||||||
"version": "0.8.10",
|
"version": "0.8.11",
|
||||||
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
|
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^6.0.3",
|
||||||
"@wrnexus/syntax": "workspace:*"
|
"@wrnexus/syntax": "workspace:*"
|
||||||
},
|
},
|
||||||
"wrnexus": {
|
"wrnexus": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/cli",
|
"name": "@wrnexus/cli",
|
||||||
"version": "0.8.36",
|
"version": "0.8.44",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"@wrnexus/db": "workspace:*",
|
"@wrnexus/db": "workspace:*",
|
||||||
"@wrnexus/authz": "workspace:*",
|
"@wrnexus/authz": "workspace:*",
|
||||||
"@wrnexus/plugin": "workspace:*",
|
"@wrnexus/plugin": "workspace:*",
|
||||||
|
"@wrnexus/react": "workspace:*",
|
||||||
"@wrnexus/syntax": "workspace:*",
|
"@wrnexus/syntax": "workspace:*",
|
||||||
"@wrnexus/typecheck": "workspace:*",
|
"@wrnexus/typecheck": "workspace:*",
|
||||||
"@wrnexus/security": "workspace:*",
|
"@wrnexus/security": "workspace:*",
|
||||||
|
|||||||
@@ -620,10 +620,14 @@ export async function runBuild(appRoot: string): Promise<void> {
|
|||||||
appDir,
|
appDir,
|
||||||
appRoot: root,
|
appRoot: root,
|
||||||
mode: "production",
|
mode: "production",
|
||||||
sources: [
|
// Component discovery is not style discovery. Built-in and plugin
|
||||||
...componentDirs,
|
// components own their CSS; scanning every available component makes
|
||||||
...pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
|
// Tailwind/Iconify generate rules for packages and components the app
|
||||||
],
|
// never renders. Packages that intentionally use app utilities opt in
|
||||||
|
// through an explicit styles.source contribution.
|
||||||
|
sources: pluginContributions.styles.flatMap((style) =>
|
||||||
|
style.source ? [style.source] : [],
|
||||||
|
),
|
||||||
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
|
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
|
||||||
},
|
},
|
||||||
config.styles,
|
config.styles,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/compiler",
|
"name": "@wrnexus/compiler",
|
||||||
"version": "0.8.10",
|
"version": "0.8.12",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -593,7 +593,22 @@ function renderNode(
|
|||||||
// templateEscape, swapped for its real `${…}` code after escaping.
|
// templateEscape, swapped for its real `${…}` code after escaping.
|
||||||
if (node.type === "each" || node.type === "if") {
|
if (node.type === "each" || node.type === "if") {
|
||||||
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
|
loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node));
|
||||||
return `\x00WRNEACH${loops.length - 1}\x00`;
|
const definition =
|
||||||
|
node.type === "each"
|
||||||
|
? {
|
||||||
|
list: node.list,
|
||||||
|
item: node.item,
|
||||||
|
index: node.index,
|
||||||
|
key: node.key,
|
||||||
|
body: renderClientControlTemplate(node.body),
|
||||||
|
empty: renderClientControlTemplate(node.empty),
|
||||||
|
}
|
||||||
|
: node.branches.map((branch) => ({
|
||||||
|
cond: branch.cond,
|
||||||
|
body: renderClientControlTemplate(branch.body),
|
||||||
|
}));
|
||||||
|
const attribute = node.type === "each" ? "data-wrn-each" : "data-wrn-if";
|
||||||
|
return `<template ${attribute}="${encodeClientControl(definition)}"></template>\x00WRNEACH${loops.length - 1}\x00<template data-wrn-control-end></template>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.tag === "Static" || node.tag === "Dynamic") {
|
if (node.tag === "Static" || node.tag === "Dynamic") {
|
||||||
@@ -2037,6 +2052,87 @@ function compileAttrValue(raw: string, ctx: CompCtx): string {
|
|||||||
return out + escLit(attrEscape(raw.slice(last)));
|
return out + escLit(attrEscape(raw.slice(last)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a control-block body as inert browser-side template markup.
|
||||||
|
* Values deliberately remain as mustaches: the CSR runtime evaluates them
|
||||||
|
* against the component scope (and `{#each}` locals) when it materializes the
|
||||||
|
* template. The string is base64 encoded before it is placed in HTML.
|
||||||
|
*/
|
||||||
|
function renderClientControlTemplate(nodes: ViewNode[]): string {
|
||||||
|
const render = (node: ViewNode): string => {
|
||||||
|
if (node.type === "text") {
|
||||||
|
return node.value.replace(/\{([^{}]+)\}/g, (whole, rawExpression: string) => {
|
||||||
|
const expression = rawExpression.trim();
|
||||||
|
return expression.startsWith("t:")
|
||||||
|
? `<span data-t="${attrEscape(expression.slice(2).trim())}"></span>`
|
||||||
|
: `<span data-text="${attrEscape(expression)}">${whole}</span>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (node.type === "each") {
|
||||||
|
return `<template data-wrn-each="${attrEscape(
|
||||||
|
encodeClientControl({
|
||||||
|
list: node.list,
|
||||||
|
item: node.item,
|
||||||
|
index: node.index,
|
||||||
|
key: node.key,
|
||||||
|
body: renderClientControlTemplate(node.body),
|
||||||
|
empty: renderClientControlTemplate(node.empty),
|
||||||
|
}),
|
||||||
|
)}"></template><template data-wrn-control-end></template>`;
|
||||||
|
}
|
||||||
|
if (node.type === "if") {
|
||||||
|
return `<template data-wrn-if="${attrEscape(
|
||||||
|
encodeClientControl(
|
||||||
|
node.branches.map((branch) => ({
|
||||||
|
cond: branch.cond,
|
||||||
|
body: renderClientControlTemplate(branch.body),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)}"></template><template data-wrn-control-end></template>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentTag = isComponentTag(node.tag);
|
||||||
|
let bindIndex = 0;
|
||||||
|
const attrs = node.attrs
|
||||||
|
.map((attribute) => {
|
||||||
|
const name = attribute.event
|
||||||
|
? componentTag
|
||||||
|
? componentEventAttribute(attribute.name)
|
||||||
|
: eventAttribute(attribute.name)
|
||||||
|
: attribute.name;
|
||||||
|
if (attribute.boolean) return ` ${name}`;
|
||||||
|
if (attribute.name.startsWith("class:")) {
|
||||||
|
const expression = unwrapDirectiveExpression(attribute.value);
|
||||||
|
return ` data-wrn-class-${bindIndex++}="${attrEscape(
|
||||||
|
JSON.stringify([attribute.name.slice("class:".length), expression]),
|
||||||
|
)}"`;
|
||||||
|
}
|
||||||
|
if (attribute.name === "data-show") {
|
||||||
|
return ` data-show="${attrEscape(unwrapDirectiveExpression(attribute.value))}"`;
|
||||||
|
}
|
||||||
|
const rendered = ` ${name}="${attrEscape(attribute.value)}"`;
|
||||||
|
return attribute.value.includes("{")
|
||||||
|
? `${rendered} data-wrn-bind-${bindIndex++}="${attrEscape(
|
||||||
|
JSON.stringify([name, attribute.value]),
|
||||||
|
)}"`
|
||||||
|
: rendered;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
const children = node.children.map(render).join("");
|
||||||
|
if (node.tag === "Static") return children;
|
||||||
|
if (componentTag)
|
||||||
|
return `<div data-component="${attrEscape(node.tag)}"${attrs}>${children}</div>`;
|
||||||
|
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) return `<${node.tag}${attrs}>`;
|
||||||
|
return `<${node.tag}${attrs}>${children}</${node.tag}>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return nodes.map(render).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeClientControl(value: unknown): string {
|
||||||
|
return Buffer.from(JSON.stringify(value), "utf8").toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
|
function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
|
||||||
let expression = "``";
|
let expression = "``";
|
||||||
|
|
||||||
@@ -2051,7 +2147,14 @@ function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
|
|||||||
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
|
: `(${ctx.resolveExpr(branch.cond)}) ? ${bodyExpression} : ${expression}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return "${" + expression + "}";
|
const definition = encodeClientControl(
|
||||||
|
node.branches.map((branch) => ({
|
||||||
|
cond: branch.cond,
|
||||||
|
body: renderClientControlTemplate(branch.body),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return `<template data-wrn-if="${definition}"></template>${"${" + expression + "}"}<template data-wrn-control-end></template>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
|
function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
|
||||||
@@ -2067,7 +2170,7 @@ function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
|
|||||||
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
|
const body = node.body.map((child) => renderComponentNode(child, childCtx)).join("");
|
||||||
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
|
const empty = node.empty.map((child) => renderComponentNode(child, ctx)).join("");
|
||||||
|
|
||||||
return (
|
const serverBody =
|
||||||
"${(() => { const __wl = Array.isArray(" +
|
"${(() => { const __wl = Array.isArray(" +
|
||||||
list +
|
list +
|
||||||
") ? (" +
|
") ? (" +
|
||||||
@@ -2080,8 +2183,18 @@ function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
|
|||||||
body +
|
body +
|
||||||
'`).join("") : `' +
|
'`).join("") : `' +
|
||||||
empty +
|
empty +
|
||||||
"`; })()}"
|
"`; })()}";
|
||||||
);
|
|
||||||
|
const definition = encodeClientControl({
|
||||||
|
list: node.list,
|
||||||
|
item: node.item,
|
||||||
|
index: node.index,
|
||||||
|
key: node.key,
|
||||||
|
body: renderClientControlTemplate(node.body),
|
||||||
|
empty: renderClientControlTemplate(node.empty),
|
||||||
|
});
|
||||||
|
|
||||||
|
return `<template data-wrn-each="${definition}"></template>${serverBody}<template data-wrn-control-end></template>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function serverLoopLocalsAttribute(ctx: CompCtx): string {
|
function serverLoopLocalsAttribute(ctx: CompCtx): string {
|
||||||
|
|||||||
@@ -1053,6 +1053,23 @@ component Banner {
|
|||||||
expect(output).toContain("Visible");
|
expect(output).toContain("Visible");
|
||||||
expect(output).toContain("Hidden");
|
expect(output).toContain("Hidden");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("if and each blocks emit browser control metadata while preserving SSR", () => {
|
||||||
|
const output = generate(
|
||||||
|
parse(`component ClientBlocks {
|
||||||
|
state open = false
|
||||||
|
state items = ["a"]
|
||||||
|
view {
|
||||||
|
{#if open}<p>Open</p>{:else}<p>Closed</p>{/if}
|
||||||
|
{#each items as item}<span>{item}</span>{:empty}<i>Empty</i>{/each}
|
||||||
|
}
|
||||||
|
}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(output).toContain("data-wrn-if=");
|
||||||
|
expect(output).toContain("data-wrn-each=");
|
||||||
|
expect(output).toContain("Array.isArray(items)");
|
||||||
|
});
|
||||||
test("component array props support each blocks", () => {
|
test("component array props support each blocks", () => {
|
||||||
const output = generate(
|
const output = generate(
|
||||||
parse(`
|
parse(`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/content",
|
"name": "@wrnexus/content",
|
||||||
"version": "0.8.8",
|
"version": "0.8.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Typed Markdown content collections, loaders, indexes, feeds, and preview workflows for WRNexusJS.",
|
"description": "Typed Markdown content collections, loaders, indexes, feeds, and preview workflows for WRNexusJS.",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
@@ -14,6 +14,6 @@
|
|||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/csr",
|
"name": "@wrnexus/csr",
|
||||||
"version": "0.8.21",
|
"version": "0.8.23",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -127,6 +127,14 @@ export function getComponentControllerRuntime(development = false): string {
|
|||||||
var emitPinInputEvent = bridge.emitPinInputEvent;
|
var emitPinInputEvent = bridge.emitPinInputEvent;
|
||||||
var parseScopeDecl = bridge.parseScopeDecl;
|
var parseScopeDecl = bridge.parseScopeDecl;
|
||||||
var warnOnce = bridge.warn || function () {};
|
var warnOnce = bridge.warn || function () {};
|
||||||
|
// The extracted sections use these the same way the core runtime does, and
|
||||||
|
// this bundle is a separate IIFE, so it needs its own copies.
|
||||||
|
function hasOwn(target, key) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(target, key);
|
||||||
|
}
|
||||||
|
function toArray(value) {
|
||||||
|
return Array.prototype.slice.call(value);
|
||||||
|
}
|
||||||
${sections}
|
${sections}
|
||||||
function hydrate(root) {
|
function hydrate(root) {
|
||||||
var host = root || document;
|
var host = root || document;
|
||||||
|
|||||||
@@ -368,15 +368,10 @@ export const NAV_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncI18n(nextDocument) {
|
function syncI18n(nextDocument) {
|
||||||
var script = Array.prototype.find.call(
|
var script = nextDocument.querySelector('script[type="application/json"][data-wrn-i18n]');
|
||||||
nextDocument.querySelectorAll("script:not([src])"),
|
|
||||||
function (node) { return /^window\.__wrnI18n=/.test(String(node.textContent || "").trim()); },
|
|
||||||
);
|
|
||||||
if (!script) return;
|
if (!script) return;
|
||||||
var match = /^window\.__wrnI18n=([\s\S]*);\s*$/.exec(String(script.textContent || "").trim());
|
|
||||||
if (!match) return;
|
|
||||||
try {
|
try {
|
||||||
var incoming = JSON.parse(match[1]);
|
var incoming = JSON.parse(String(script.textContent || "{}"));
|
||||||
var current = window.__wrnI18n || {};
|
var current = window.__wrnI18n || {};
|
||||||
var translator = current.t;
|
var translator = current.t;
|
||||||
var setter = current.set;
|
var setter = current.set;
|
||||||
|
|||||||
@@ -26,58 +26,78 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
var behaviorObserver;
|
var behaviorObserver;
|
||||||
var clientModuleCache = new Map();
|
var clientModuleCache = new Map();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Two builtin chains the runtime reaches for constantly. Aliasing them is
|
||||||
|
* not only shorter: hasOwn keeps prototype keys from reading as data, and
|
||||||
|
* toArray is needed because a NodeList is not an Array.
|
||||||
|
*/
|
||||||
|
function hasOwn(target, key) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(target, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArray(value) {
|
||||||
|
return Array.prototype.slice.call(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* data-wrn-class-* and data-wrn-bind-* both carry a JSON ["name","expression"]
|
||||||
|
* pair. Malformed markup yields null so every caller bails the same way
|
||||||
|
* rather than each repeating the parse and the shape check.
|
||||||
|
*/
|
||||||
|
function pairBinding(value) {
|
||||||
|
var parsed;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(value);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed && parsed.length === 2 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Globals the expression engine resolves for client code. Kept as explicit
|
* Globals the expression engine resolves for client code. Kept as explicit
|
||||||
* tables rather than falling through to window[name]: an implicit fallback
|
* lists rather than falling through to window[name]: an implicit fallback
|
||||||
* would let any expression reach every global on the page (and would make a
|
* would let any expression reach every global on the page (and would make a
|
||||||
* typo silently resolve to some unrelated window property) -- these lists
|
* typo silently resolve to some unrelated window property) -- these lists
|
||||||
* say exactly what client code may reach.
|
* say exactly what client code may reach.
|
||||||
*
|
*
|
||||||
* dialogGlobals must be bound to window or the browser throws
|
* Prototype-less so a name like "toString" or "constructor" is a miss
|
||||||
* "Illegal invocation" when they are called detached.
|
* rather than a hit on Object.prototype.
|
||||||
*/
|
*/
|
||||||
var dialogGlobals = {
|
function nameSet(names) {
|
||||||
alert: 1,
|
var set = Object.create(null);
|
||||||
confirm: 1,
|
|
||||||
prompt: 1,
|
|
||||||
fetch: 1,
|
|
||||||
print: 1,
|
|
||||||
open: 1,
|
|
||||||
scrollTo: 1,
|
|
||||||
scrollBy: 1,
|
|
||||||
matchMedia: 1,
|
|
||||||
getComputedStyle: 1,
|
|
||||||
structuredClone: 1,
|
|
||||||
queueMicrotask: 1,
|
|
||||||
btoa: 1,
|
|
||||||
atob: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Language builtins. Wrapped in thunks so referencing one that a given
|
names.split(" ").forEach(function (name) {
|
||||||
// engine lacks cannot throw at table-definition time.
|
set[name] = 1;
|
||||||
var jsGlobals = {
|
});
|
||||||
Object: function () { return Object; },
|
|
||||||
Boolean: function () { return Boolean; },
|
return set;
|
||||||
RegExp: function () { return RegExp; },
|
}
|
||||||
Promise: function () { return typeof Promise === "undefined" ? undefined : Promise; },
|
|
||||||
Set: function () { return typeof Set === "undefined" ? undefined : Set; },
|
/*
|
||||||
Map: function () { return typeof Map === "undefined" ? undefined : Map; },
|
* Called with window as the receiver. Detached, the browser throws
|
||||||
Error: function () { return Error; },
|
* "Illegal invocation" for these.
|
||||||
Symbol: function () { return typeof Symbol === "undefined" ? undefined : Symbol; },
|
*/
|
||||||
BigInt: function () { return typeof BigInt === "undefined" ? undefined : BigInt; },
|
var boundWindowGlobals = nameSet(
|
||||||
Intl: function () { return typeof Intl === "undefined" ? undefined : Intl; },
|
"alert confirm prompt fetch print open scrollTo scrollBy matchMedia" +
|
||||||
parseInt: function () { return parseInt; },
|
" getComputedStyle structuredClone queueMicrotask btoa atob" +
|
||||||
parseFloat: function () { return parseFloat; },
|
" setTimeout clearTimeout setInterval clearInterval" +
|
||||||
isNaN: function () { return isNaN; },
|
" requestAnimationFrame cancelAnimationFrame",
|
||||||
isFinite: function () { return isFinite; },
|
);
|
||||||
encodeURIComponent: function () { return encodeURIComponent; },
|
|
||||||
decodeURIComponent: function () { return decodeURIComponent; },
|
/*
|
||||||
encodeURI: function () { return encodeURI; },
|
* Language builtins and other realm globals, read off globalThis. Naming
|
||||||
decodeURI: function () { return decodeURI; },
|
* them rather than referencing them directly means one an engine lacks
|
||||||
NaN: function () { return NaN; },
|
* resolves to undefined instead of throwing where the table is defined.
|
||||||
Infinity: function () { return Infinity; },
|
*/
|
||||||
undefined: function () { return undefined; },
|
var ambientGlobals = nameSet(
|
||||||
};
|
"Object Boolean RegExp Promise Set Map Error Symbol BigInt Intl parseInt" +
|
||||||
|
" parseFloat isNaN isFinite encodeURIComponent decodeURIComponent" +
|
||||||
|
" encodeURI decodeURI NaN Infinity undefined Array Number String Math" +
|
||||||
|
" JSON Date URL",
|
||||||
|
);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* toast(...) -- raise a notification from any client expression.
|
* toast(...) -- raise a notification from any client expression.
|
||||||
@@ -154,27 +174,12 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (!window.toast) window.toast = toastApi;
|
if (!window.toast) window.toast = toastApi;
|
||||||
|
|
||||||
// Read straight off window, no binding needed (objects, not functions).
|
// Read straight off window, no binding needed (objects, not functions).
|
||||||
var windowGlobals = {
|
var windowGlobals = nameSet(
|
||||||
localStorage: 1,
|
"localStorage sessionStorage screen performance crypto CustomEvent Event" +
|
||||||
sessionStorage: 1,
|
" FormData URLSearchParams AbortController Notification" +
|
||||||
screen: 1,
|
" IntersectionObserver ResizeObserver MutationObserver devicePixelRatio" +
|
||||||
performance: 1,
|
" innerWidth innerHeight scrollX scrollY location history navigator",
|
||||||
crypto: 1,
|
);
|
||||||
CustomEvent: 1,
|
|
||||||
Event: 1,
|
|
||||||
FormData: 1,
|
|
||||||
URLSearchParams: 1,
|
|
||||||
AbortController: 1,
|
|
||||||
Notification: 1,
|
|
||||||
IntersectionObserver: 1,
|
|
||||||
ResizeObserver: 1,
|
|
||||||
MutationObserver: 1,
|
|
||||||
devicePixelRatio: 1,
|
|
||||||
innerWidth: 1,
|
|
||||||
innerHeight: 1,
|
|
||||||
scrollX: 1,
|
|
||||||
scrollY: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
function reportDiagnostic(code, message, element, detail) {
|
function reportDiagnostic(code, message, element, detail) {
|
||||||
var payload = {
|
var payload = {
|
||||||
@@ -1071,7 +1076,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
var serverProxy = new Proxy({}, {
|
var serverProxy = new Proxy({}, {
|
||||||
get: function (_target, property) {
|
get: function (_target, property) {
|
||||||
return function () {
|
return function () {
|
||||||
return callServerFunction(componentRpcName, String(property), Array.prototype.slice.call(arguments));
|
return callServerFunction(componentRpcName, String(property), toArray(arguments));
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1111,7 +1116,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (name === "server") return serverProxy;
|
if (name === "server") return serverProxy;
|
||||||
if (name === "props") return propsProxy;
|
if (name === "props") return propsProxy;
|
||||||
if (name === "refs") return refsProxy;
|
if (name === "refs") return refsProxy;
|
||||||
if (Object.prototype.hasOwnProperty.call(moduleBindings, name)) return moduleBindings[name];
|
if (hasOwn(moduleBindings, name)) return moduleBindings[name];
|
||||||
if (name === "$emit") {
|
if (name === "$emit") {
|
||||||
return function (eventName, detail) {
|
return function (eventName, detail) {
|
||||||
return dispatchComponentEvent(componentEventTarget, eventName, detail);
|
return dispatchComponentEvent(componentEventTarget, eventName, detail);
|
||||||
@@ -1120,26 +1125,10 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (name === "window") return window;
|
if (name === "window") return window;
|
||||||
if (name === "document") return document;
|
if (name === "document") return document;
|
||||||
if (name === "console") return console;
|
if (name === "console") return console;
|
||||||
if (name === "Array") return Array;
|
|
||||||
if (name === "Number") return Number;
|
|
||||||
if (name === "String") return String;
|
|
||||||
if (name === "Math") return Math;
|
|
||||||
if (name === "JSON") return JSON;
|
|
||||||
if (name === "Date") return Date;
|
|
||||||
if (name === "URL") return URL;
|
|
||||||
if (name === "location") return window.location;
|
|
||||||
if (name === "history") return window.history;
|
|
||||||
if (name === "navigator") return window.navigator;
|
|
||||||
if (name === "$route" || name === "route") {
|
if (name === "$route" || name === "route") {
|
||||||
if (currentRenderer) routeValue.subscribe(currentRenderer);
|
if (currentRenderer) routeValue.subscribe(currentRenderer);
|
||||||
return routeValue.get();
|
return routeValue.get();
|
||||||
}
|
}
|
||||||
if (name === "setTimeout") return window.setTimeout.bind(window);
|
|
||||||
if (name === "clearTimeout") return window.clearTimeout.bind(window);
|
|
||||||
if (name === "setInterval") return window.setInterval.bind(window);
|
|
||||||
if (name === "clearInterval") return window.clearInterval.bind(window);
|
|
||||||
if (name === "requestAnimationFrame") return window.requestAnimationFrame.bind(window);
|
|
||||||
if (name === "cancelAnimationFrame") return window.cancelAnimationFrame.bind(window);
|
|
||||||
/*
|
/*
|
||||||
* Ordinary browser and language globals.
|
* Ordinary browser and language globals.
|
||||||
*
|
*
|
||||||
@@ -1156,11 +1145,11 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
* lacks one of these does not break the rest.
|
* lacks one of these does not break the rest.
|
||||||
*/
|
*/
|
||||||
if (name === "toast") return toastApi;
|
if (name === "toast") return toastApi;
|
||||||
if (dialogGlobals[name] && typeof window[name] === "function") {
|
if (boundWindowGlobals[name] && typeof window[name] === "function") {
|
||||||
return window[name].bind(window);
|
return window[name].bind(window);
|
||||||
}
|
}
|
||||||
if (jsGlobals[name]) {
|
if (ambientGlobals[name]) {
|
||||||
var builtin = jsGlobals[name]();
|
var builtin = globalThis[name];
|
||||||
if (builtin !== undefined) return builtin;
|
if (builtin !== undefined) return builtin;
|
||||||
}
|
}
|
||||||
if (windowGlobals[name]) {
|
if (windowGlobals[name]) {
|
||||||
@@ -1174,7 +1163,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readScope(name) {
|
function readScope(name) {
|
||||||
if (Object.prototype.hasOwnProperty.call(computedDefinitions, name)) {
|
if (hasOwn(computedDefinitions, name)) {
|
||||||
if (computing.has(name)) {
|
if (computing.has(name)) {
|
||||||
reportDiagnostic("WRN-COMPUTED-CYCLE", "Computed value '" + name + "' has a dependency cycle.", el);
|
reportDiagnostic("WRN-COMPUTED-CYCLE", "Computed value '" + name + "' has a dependency cycle.", el);
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -1191,18 +1180,18 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (currentRenderer) sig.subscribe(currentRenderer);
|
if (currentRenderer) sig.subscribe(currentRenderer);
|
||||||
return sig.get();
|
return sig.get();
|
||||||
}
|
}
|
||||||
if (Object.prototype.hasOwnProperty.call(behaviorFunctions, name)) {
|
if (hasOwn(behaviorFunctions, name)) {
|
||||||
return behaviorFunctions[name];
|
return behaviorFunctions[name];
|
||||||
}
|
}
|
||||||
return readGlobal(name);
|
return readGlobal(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
function peekScope(name) {
|
function peekScope(name) {
|
||||||
if (Object.prototype.hasOwnProperty.call(computedDefinitions, name)) {
|
if (hasOwn(computedDefinitions, name)) {
|
||||||
return readScope(name);
|
return readScope(name);
|
||||||
}
|
}
|
||||||
if (signals[name]) return signals[name].get();
|
if (signals[name]) return signals[name].get();
|
||||||
if (Object.prototype.hasOwnProperty.call(behaviorFunctions, name)) {
|
if (hasOwn(behaviorFunctions, name)) {
|
||||||
return behaviorFunctions[name];
|
return behaviorFunctions[name];
|
||||||
}
|
}
|
||||||
return readGlobal(name);
|
return readGlobal(name);
|
||||||
@@ -1265,7 +1254,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
function evalExpr(expr, locals) {
|
function evalExpr(expr, locals) {
|
||||||
return evaluateExpression(expr, function (name) {
|
return evaluateExpression(expr, function (name) {
|
||||||
if (locals && Object.prototype.hasOwnProperty.call(locals, name)) {
|
if (locals && hasOwn(locals, name)) {
|
||||||
return locals[name];
|
return locals[name];
|
||||||
}
|
}
|
||||||
return readScope(name);
|
return readScope(name);
|
||||||
@@ -1276,6 +1265,8 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
source,
|
source,
|
||||||
locals,
|
locals,
|
||||||
) {
|
) {
|
||||||
|
locals = locals || Object.create(null);
|
||||||
|
|
||||||
return batchUpdates(function () {
|
return batchUpdates(function () {
|
||||||
var statements =
|
var statements =
|
||||||
splitStatements(source);
|
splitStatements(source);
|
||||||
@@ -1297,8 +1288,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
function (name) {
|
function (name) {
|
||||||
if (
|
if (
|
||||||
locals &&
|
locals &&
|
||||||
Object.prototype
|
hasOwn(
|
||||||
.hasOwnProperty.call(
|
|
||||||
locals,
|
locals,
|
||||||
name,
|
name,
|
||||||
)
|
)
|
||||||
@@ -1311,8 +1301,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
function (name, value) {
|
function (name, value) {
|
||||||
if (
|
if (
|
||||||
locals &&
|
locals &&
|
||||||
Object.prototype
|
hasOwn(
|
||||||
.hasOwnProperty.call(
|
|
||||||
locals,
|
locals,
|
||||||
name,
|
name,
|
||||||
)
|
)
|
||||||
@@ -1328,6 +1317,9 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
locals,
|
locals,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
function (name, value) {
|
||||||
|
locals[name] = value;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.returned) {
|
if (result.returned) {
|
||||||
@@ -1501,19 +1493,13 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
return type + ":" + String(value);
|
return type + ":" + String(value);
|
||||||
}
|
}
|
||||||
function fillMustache(str, itemEval) {
|
|
||||||
return str.replace(/\{\{\s*([^}]+?)\s*\}\}|\{([^{}]+)\}/g, function (_, d, s) {
|
|
||||||
var e = (d || s).trim();
|
|
||||||
try { return String(itemEval(e)); } catch (err) { return ""; }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function hydrateItem(
|
function hydrateItem(
|
||||||
root,
|
root,
|
||||||
locals,
|
locals,
|
||||||
) {
|
) {
|
||||||
function localRead(name) {
|
function localRead(name) {
|
||||||
if (
|
if (
|
||||||
Object.prototype.hasOwnProperty.call(
|
hasOwn(
|
||||||
locals,
|
locals,
|
||||||
name,
|
name,
|
||||||
)
|
)
|
||||||
@@ -1597,7 +1583,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (node !== root && insideNestedLoop(node)) return;
|
if (node !== root && insideNestedLoop(node)) return;
|
||||||
|
|
||||||
var attributes =
|
var attributes =
|
||||||
Array.prototype.slice.call(
|
toArray(
|
||||||
node.attributes,
|
node.attributes,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1611,24 +1597,21 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
if (
|
if (
|
||||||
attribute.name === "data-text"
|
attribute.name === "data-text"
|
||||||
) {
|
) {
|
||||||
try {
|
(function (textNode, textExpression) {
|
||||||
var textValue =
|
var runText = reactive(function () {
|
||||||
itemEval(attribute.value);
|
try {
|
||||||
|
var textValue = itemEval(textExpression);
|
||||||
node.textContent =
|
textNode.textContent = textValue == null ? "" : String(textValue);
|
||||||
textValue == null
|
} catch (error) {
|
||||||
? ""
|
console.error(
|
||||||
: String(textValue);
|
"[wrnexus] data-for text binding failed for '" + textExpression + "'",
|
||||||
} catch (error) {
|
error,
|
||||||
console.error(
|
);
|
||||||
"[wrnexus] data-for text binding failed for '" +
|
textNode.textContent = "";
|
||||||
attribute.value +
|
}
|
||||||
"'",
|
});
|
||||||
error,
|
runText();
|
||||||
);
|
})(node, attribute.value);
|
||||||
|
|
||||||
node.textContent = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1642,28 +1625,12 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
"data-wrn-class-",
|
"data-wrn-class-",
|
||||||
) === 0
|
) === 0
|
||||||
) {
|
) {
|
||||||
var classBinding;
|
var classBinding = pairBinding(attribute.value);
|
||||||
|
|
||||||
try {
|
if (!classBinding) return;
|
||||||
classBinding = JSON.parse(
|
|
||||||
attribute.value,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
var className = classBinding[0];
|
||||||
!classBinding ||
|
var classExpression = classBinding[1];
|
||||||
classBinding.length !== 2
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var className =
|
|
||||||
classBinding[0];
|
|
||||||
|
|
||||||
var classExpression =
|
|
||||||
classBinding[1];
|
|
||||||
|
|
||||||
var classEnabled = false;
|
var classEnabled = false;
|
||||||
|
|
||||||
@@ -1693,28 +1660,13 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
) === 0
|
) === 0
|
||||||
) {
|
) {
|
||||||
node.removeAttribute(attribute.name);
|
node.removeAttribute(attribute.name);
|
||||||
var binding;
|
|
||||||
|
|
||||||
try {
|
var binding = pairBinding(attribute.value);
|
||||||
binding = JSON.parse(
|
|
||||||
attribute.value,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (!binding) return;
|
||||||
!binding ||
|
|
||||||
binding.length !== 2
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var attributeName =
|
var attributeName = binding[0];
|
||||||
binding[0];
|
var attributeTemplate = binding[1];
|
||||||
|
|
||||||
var attributeTemplate =
|
|
||||||
binding[1];
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Reactive, not resolved once. The expression can read component
|
* Reactive, not resolved once. The expression can read component
|
||||||
@@ -1782,7 +1734,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
eventLocals.event = event;
|
eventLocals.event = event;
|
||||||
eventLocals.$event = event;
|
eventLocals.$event = event;
|
||||||
eventLocals.payload = event && Object.prototype.hasOwnProperty.call(event, "detail") ? event.detail : undefined;
|
eventLocals.payload = event && hasOwn(event, "detail") ? event.detail : undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
runStmt(
|
runStmt(
|
||||||
@@ -1934,8 +1886,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
// Hand every nested loop its own renderer, with this item in scope.
|
// Hand every nested loop its own renderer, with this item in scope.
|
||||||
if (root.querySelectorAll) {
|
if (root.querySelectorAll) {
|
||||||
Array.prototype.slice
|
toArray(root.querySelectorAll("[data-for]"))
|
||||||
.call(root.querySelectorAll("[data-for]"))
|
|
||||||
.forEach(function (nested) {
|
.forEach(function (nested) {
|
||||||
// Only the outermost nested templates: deeper ones are set up by
|
// Only the outermost nested templates: deeper ones are set up by
|
||||||
// their own parent when it renders.
|
// their own parent when it renders.
|
||||||
@@ -1954,6 +1905,155 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compiled if/each blocks keep their SSR result in the custom
|
||||||
|
// element and carry an inert, base64-encoded template for later browser
|
||||||
|
// updates. The first reactive pass only subscribes to dependencies, so
|
||||||
|
// hydration does not throw away server DOM. Subsequent state changes
|
||||||
|
// materialize the appropriate branch/rows and hydrate their bindings.
|
||||||
|
function decodeControlDefinition(value) {
|
||||||
|
try {
|
||||||
|
var binary = window.atob(value || "");
|
||||||
|
var bytes = new Uint8Array(binary.length);
|
||||||
|
for (var index = 0; index < binary.length; index++) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
return JSON.parse(new TextDecoder("utf-8").decode(bytes));
|
||||||
|
} catch (error) {
|
||||||
|
reportDiagnostic("WRN-CONTROL-DECODE", "Failed to decode a client control block.", el, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupControlBlock(block, outerLocals) {
|
||||||
|
if (!block || block.__wrnexusControl || (!outerLocals && !owns(block))) return;
|
||||||
|
block.__wrnexusControl = true;
|
||||||
|
var inherited = outerLocals || decodeLoopLocals(block);
|
||||||
|
var rangeEnd = null;
|
||||||
|
if (block.tagName && block.tagName.toLowerCase() === "template") {
|
||||||
|
var depth = 0;
|
||||||
|
for (var sibling = block.nextSibling; sibling; sibling = sibling.nextSibling) {
|
||||||
|
if (sibling.nodeType !== 1 || sibling.tagName.toLowerCase() !== "template") continue;
|
||||||
|
if (sibling.hasAttribute("data-wrn-if") || sibling.hasAttribute("data-wrn-each")) depth++;
|
||||||
|
if (sibling.hasAttribute("data-wrn-control-end")) {
|
||||||
|
if (depth === 0) { rangeEnd = sibling; break; }
|
||||||
|
depth--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rangeEnd) return;
|
||||||
|
}
|
||||||
|
var ifDefinition = block.hasAttribute("data-wrn-if")
|
||||||
|
? decodeControlDefinition(block.getAttribute("data-wrn-if"))
|
||||||
|
: null;
|
||||||
|
var eachDefinition = block.hasAttribute("data-wrn-each")
|
||||||
|
? decodeControlDefinition(block.getAttribute("data-wrn-each"))
|
||||||
|
: null;
|
||||||
|
/*
|
||||||
|
* Skip the first reactive pass only when hydrating server DOM.
|
||||||
|
*
|
||||||
|
* A block that arrived with the server HTML is already rendered, so
|
||||||
|
* redrawing on the first pass would discard it. A block created later by
|
||||||
|
* an outer block's rerender has no server DOM — outerLocals is how it
|
||||||
|
* receives its enclosing loop's scope, and is only ever set on that path.
|
||||||
|
* Skipping its first pass leaves it permanently empty, because its
|
||||||
|
* dependencies never change again to trigger a second one.
|
||||||
|
*/
|
||||||
|
var firstRun = !outerLocals;
|
||||||
|
|
||||||
|
function controlRead(name) {
|
||||||
|
return hasOwn(inherited, name) ? inherited[name] : readScope(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function controlEval(expression, locals) {
|
||||||
|
return evaluateExpression(expression, function (name) {
|
||||||
|
return locals && hasOwn(locals, name)
|
||||||
|
? locals[name]
|
||||||
|
: controlRead(name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearControlContent() {
|
||||||
|
if (!rangeEnd) { block.innerHTML = ""; return; }
|
||||||
|
while (block.nextSibling && block.nextSibling !== rangeEnd) {
|
||||||
|
block.parentNode.removeChild(block.nextSibling);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendControlContent(markup, locals) {
|
||||||
|
var template = document.createElement("template");
|
||||||
|
template.innerHTML = markup || "";
|
||||||
|
var fragment = template.content;
|
||||||
|
var elements = toArray(fragment.childNodes).filter(function (node) {
|
||||||
|
return node.nodeType === 1;
|
||||||
|
});
|
||||||
|
if (rangeEnd) block.parentNode.insertBefore(fragment, rangeEnd);
|
||||||
|
else block.appendChild(fragment);
|
||||||
|
elements.forEach(function (node) { hydrateItem(node, locals || inherited); });
|
||||||
|
elements.forEach(function (node) {
|
||||||
|
var controls = [];
|
||||||
|
if (node.matches && node.matches("[data-wrn-if],[data-wrn-each]")) controls.push(node);
|
||||||
|
if (node.querySelectorAll) Array.prototype.push.apply(controls, node.querySelectorAll("[data-wrn-if],[data-wrn-each]"));
|
||||||
|
controls.forEach(function (nested) {
|
||||||
|
if (nested.__wrnexusControl) return;
|
||||||
|
/*
|
||||||
|
* Run the new block now rather than waiting for a sweep.
|
||||||
|
*
|
||||||
|
* reactive() only registers an effect; effects execute when
|
||||||
|
* renderAll sweeps the list. A state change runs just the affected
|
||||||
|
* effects, so a block registered during that rerender is queued and
|
||||||
|
* never invoked -- it would stay empty for the life of the page.
|
||||||
|
*/
|
||||||
|
var runNested = setupControlBlock(nested, locals || inherited);
|
||||||
|
if (runNested) runNested();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return reactive(function () {
|
||||||
|
/*
|
||||||
|
* A block removed from the DOM keeps its effect in the renderers list,
|
||||||
|
* so a later sweep would run it against a detached node and throw --
|
||||||
|
* aborting the sweep, leaving every later effect unrendered. Skip it.
|
||||||
|
*/
|
||||||
|
if (!block.parentNode) return;
|
||||||
|
if (ifDefinition) {
|
||||||
|
var selected = null;
|
||||||
|
for (var branchIndex = 0; branchIndex < ifDefinition.length; branchIndex++) {
|
||||||
|
var branch = ifDefinition[branchIndex];
|
||||||
|
if (branch.cond === null || !!controlEval(branch.cond, inherited)) {
|
||||||
|
selected = branch;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (firstRun) { firstRun = false; return; }
|
||||||
|
clearControlContent();
|
||||||
|
appendControlContent(selected ? selected.body : "", inherited);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!eachDefinition) return;
|
||||||
|
var list = controlEval(eachDefinition.list, inherited);
|
||||||
|
if (!Array.isArray(list)) list = [];
|
||||||
|
if (firstRun) { firstRun = false; return; }
|
||||||
|
clearControlContent();
|
||||||
|
if (list.length === 0) {
|
||||||
|
appendControlContent(eachDefinition.empty || "", inherited);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (var itemIndex = 0; itemIndex < list.length; itemIndex++) {
|
||||||
|
var rowLocals = {};
|
||||||
|
Object.keys(inherited).forEach(function (name) { rowLocals[name] = inherited[name]; });
|
||||||
|
rowLocals[eachDefinition.item] = list[itemIndex];
|
||||||
|
if (eachDefinition.index) rowLocals[eachDefinition.index] = itemIndex;
|
||||||
|
appendControlContent(eachDefinition.body || "", rowLocals);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toArray(el.querySelectorAll("[data-wrn-if],[data-wrn-each]")).forEach(function (block) {
|
||||||
|
if (block.parentElement && block.parentElement.closest("[data-wrn-if],[data-wrn-each]")) return;
|
||||||
|
setupControlBlock(block, null);
|
||||||
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Set up one [data-for] template. Extracted from an inline forEach so it
|
* Set up one [data-for] template. Extracted from an inline forEach so it
|
||||||
* can recurse: hydrateItem calls it for every loop nested inside a rendered
|
* can recurse: hydrateItem calls it for every loop nested inside a rendered
|
||||||
@@ -1981,7 +2081,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loopRead(name) {
|
function loopRead(name) {
|
||||||
if (Object.prototype.hasOwnProperty.call(inherited, name)) {
|
if (hasOwn(inherited, name)) {
|
||||||
return inherited[name];
|
return inherited[name];
|
||||||
}
|
}
|
||||||
return readScope(name);
|
return readScope(name);
|
||||||
@@ -2154,7 +2254,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
rawKey = evaluateExpression(
|
rawKey = evaluateExpression(
|
||||||
keyExpression,
|
keyExpression,
|
||||||
function (name) {
|
function (name) {
|
||||||
return Object.prototype.hasOwnProperty.call(keyedLocals, name)
|
return hasOwn(keyedLocals, name)
|
||||||
? keyedLocals[name]
|
? keyedLocals[name]
|
||||||
: loopRead(name);
|
: loopRead(name);
|
||||||
},
|
},
|
||||||
@@ -2227,8 +2327,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Array.prototype.slice
|
toArray(el.querySelectorAll("[data-for]"))
|
||||||
.call(el.querySelectorAll("[data-for]"))
|
|
||||||
.forEach(function (tpl) {
|
.forEach(function (tpl) {
|
||||||
// Only top-level templates here; nested ones are connected by the item
|
// Only top-level templates here; nested ones are connected by the item
|
||||||
// that contains them, once it has values to give them.
|
// that contains them, once it has values to give them.
|
||||||
@@ -2324,24 +2423,18 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
// Conditional class bindings emitted as:
|
// Conditional class bindings emitted as:
|
||||||
// data-wrn-class-*='["class-name","expression"]'
|
// data-wrn-class-*='["class-name","expression"]'
|
||||||
var classBindNodes = [el].concat(
|
var classBindNodes = [el].concat(
|
||||||
Array.prototype.slice.call(el.querySelectorAll("*")),
|
toArray(el.querySelectorAll("*")),
|
||||||
);
|
);
|
||||||
|
|
||||||
classBindNodes.forEach(function (node) {
|
classBindNodes.forEach(function (node) {
|
||||||
if (!owns(node)) return;
|
if (!owns(node)) return;
|
||||||
|
|
||||||
Array.prototype.slice.call(node.attributes).forEach(function (marker) {
|
toArray(node.attributes).forEach(function (marker) {
|
||||||
if (marker.name.indexOf("data-wrn-class-") !== 0) return;
|
if (marker.name.indexOf("data-wrn-class-") !== 0) return;
|
||||||
|
|
||||||
var binding;
|
var binding = pairBinding(marker.value);
|
||||||
|
|
||||||
try {
|
if (!binding) return;
|
||||||
binding = JSON.parse(marker.value);
|
|
||||||
} catch (e) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!binding || binding.length !== 2) return;
|
|
||||||
|
|
||||||
var className = binding[0];
|
var className = binding[0];
|
||||||
var expression = binding[1];
|
var expression = binding[1];
|
||||||
@@ -2370,7 +2463,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
// [attributeName, originalTemplate], preserving an SSR value while allowing
|
// [attributeName, originalTemplate], preserving an SSR value while allowing
|
||||||
// state changes to update type, aria-*, class, href, and other attributes.
|
// state changes to update type, aria-*, class, href, and other attributes.
|
||||||
var bindNodes = [el].concat(
|
var bindNodes = [el].concat(
|
||||||
Array.prototype.slice.call(
|
toArray(
|
||||||
el.querySelectorAll("*"),
|
el.querySelectorAll("*"),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -2378,8 +2471,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
bindNodes.forEach(function (node) {
|
bindNodes.forEach(function (node) {
|
||||||
if (!owns(node)) return;
|
if (!owns(node)) return;
|
||||||
|
|
||||||
Array.prototype.slice
|
toArray(node.attributes)
|
||||||
.call(node.attributes)
|
|
||||||
.forEach(function (marker) {
|
.forEach(function (marker) {
|
||||||
if (
|
if (
|
||||||
marker.name.indexOf(
|
marker.name.indexOf(
|
||||||
@@ -2390,22 +2482,10 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
node.removeAttribute(marker.name);
|
node.removeAttribute(marker.name);
|
||||||
var binding;
|
|
||||||
|
|
||||||
try {
|
var binding = pairBinding(marker.value);
|
||||||
binding = JSON.parse(
|
|
||||||
marker.value,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (!binding) return;
|
||||||
!binding ||
|
|
||||||
binding.length !== 2
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var name = binding[0];
|
var name = binding[0];
|
||||||
var template = binding[1];
|
var template = binding[1];
|
||||||
@@ -2497,10 +2577,10 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Event handlers on elements, window, and document.
|
// Event handlers on elements, window, and document.
|
||||||
var nodes = [el].concat(Array.prototype.slice.call(el.querySelectorAll("*")));
|
var nodes = [el].concat(toArray(el.querySelectorAll("*")));
|
||||||
nodes.forEach(function (node) {
|
nodes.forEach(function (node) {
|
||||||
if (!owns(node)) return;
|
if (!owns(node)) return;
|
||||||
Array.prototype.slice.call(node.attributes).forEach(function (attr) {
|
toArray(node.attributes).forEach(function (attr) {
|
||||||
if (attr.name.indexOf("data-on-") !== 0) return;
|
if (attr.name.indexOf("data-on-") !== 0) return;
|
||||||
|
|
||||||
var rawName = attr.name.slice("data-on-".length);
|
var rawName = attr.name.slice("data-on-".length);
|
||||||
@@ -2527,7 +2607,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
locals.event = event;
|
locals.event = event;
|
||||||
locals.$event = event;
|
locals.$event = event;
|
||||||
locals.payload = event && Object.prototype.hasOwnProperty.call(event, "detail") ? event.detail : undefined;
|
locals.payload = event && hasOwn(event, "detail") ? event.detail : undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
runStmt(
|
runStmt(
|
||||||
@@ -2576,8 +2656,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
// function only exists out here. The compiler emits these as data-wrn-out-* so the
|
// function only exists out here. The compiler emits these as data-wrn-out-* so the
|
||||||
// two cases stay distinguishable, and this scope claims every one that
|
// two cases stay distinguishable, and this scope claims every one that
|
||||||
// sits on a component it directly mounts.
|
// sits on a component it directly mounts.
|
||||||
Array.prototype.slice
|
toArray(el.querySelectorAll("[data-wrn-events]"))
|
||||||
.call(el.querySelectorAll("[data-wrn-events]"))
|
|
||||||
.forEach(function (node) {
|
.forEach(function (node) {
|
||||||
var componentRoot = closestScope(node);
|
var componentRoot = closestScope(node);
|
||||||
if (!componentRoot || componentRoot === el) return;
|
if (!componentRoot || componentRoot === el) return;
|
||||||
@@ -2594,7 +2673,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
node.__wrnexusOutputHandlers ||
|
node.__wrnexusOutputHandlers ||
|
||||||
(node.__wrnexusOutputHandlers = {});
|
(node.__wrnexusOutputHandlers = {});
|
||||||
|
|
||||||
Array.prototype.slice.call(node.attributes).forEach(function (attr) {
|
toArray(node.attributes).forEach(function (attr) {
|
||||||
if (attr.name.indexOf("data-wrn-out-") !== 0) return;
|
if (attr.name.indexOf("data-wrn-out-") !== 0) return;
|
||||||
|
|
||||||
var outName = attr.name.slice("data-wrn-out-".length);
|
var outName = attr.name.slice("data-wrn-out-".length);
|
||||||
@@ -2632,7 +2711,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
locals.event = event;
|
locals.event = event;
|
||||||
locals.$event = event;
|
locals.$event = event;
|
||||||
locals.payload =
|
locals.payload =
|
||||||
event && Object.prototype.hasOwnProperty.call(event, "detail")
|
event && hasOwn(event, "detail")
|
||||||
? event.detail
|
? event.detail
|
||||||
: undefined;
|
: undefined;
|
||||||
try {
|
try {
|
||||||
@@ -2654,16 +2733,14 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
// Prop expressions belong to the parent that mounted the component. The
|
// Prop expressions belong to the parent that mounted the component. The
|
||||||
// server forwards these markers onto the rendered child root; evaluate
|
// server forwards these markers onto the rendered child root; evaluate
|
||||||
// them here and write changes into the child's prop signals.
|
// them here and write changes into the child's prop signals.
|
||||||
Array.prototype.slice
|
toArray(el.querySelectorAll("*"))
|
||||||
.call(el.querySelectorAll("*"))
|
|
||||||
.filter(isScopeRoot)
|
.filter(isScopeRoot)
|
||||||
.forEach(function (node) {
|
.forEach(function (node) {
|
||||||
if (!node.parentNode || ownerScope(node.parentNode) !== el) return;
|
if (!node.parentNode || ownerScope(node.parentNode) !== el) return;
|
||||||
Array.prototype.slice.call(node.attributes).forEach(function (attr) {
|
toArray(node.attributes).forEach(function (attr) {
|
||||||
if (attr.name.indexOf("data-wrn-prop-bind-") !== 0) return;
|
if (attr.name.indexOf("data-wrn-prop-bind-") !== 0) return;
|
||||||
var binding;
|
var binding = pairBinding(attr.value);
|
||||||
try { binding = JSON.parse(attr.value); } catch (_) { return; }
|
if (!binding) return;
|
||||||
if (!binding || binding.length !== 2) return;
|
|
||||||
var propName = binding[0];
|
var propName = binding[0];
|
||||||
var template = binding[1];
|
var template = binding[1];
|
||||||
reactive(function () {
|
reactive(function () {
|
||||||
@@ -3017,8 +3094,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
var host = root && root.querySelectorAll ? root : document;
|
var host = root && root.querySelectorAll ? root : document;
|
||||||
anchoredWriting = true;
|
anchoredWriting = true;
|
||||||
try {
|
try {
|
||||||
Array.prototype.slice
|
toArray(host.querySelectorAll(ANCHORED_SELECTOR))
|
||||||
.call(host.querySelectorAll(ANCHORED_SELECTOR))
|
|
||||||
.forEach(clampAnchored);
|
.forEach(clampAnchored);
|
||||||
} finally {
|
} finally {
|
||||||
// Released on a timer, not requestAnimationFrame. rAF does not fire in
|
// Released on a timer, not requestAnimationFrame. rAF does not fire in
|
||||||
@@ -3552,8 +3628,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
// therefore no client-side binding to retain; consume its compiler markers
|
// therefore no client-side binding to retain; consume its compiler markers
|
||||||
// separately from component hydration.
|
// separately from component hydration.
|
||||||
if (host === document || host === document.documentElement) {
|
if (host === document || host === document.documentElement) {
|
||||||
Array.prototype.slice
|
toArray(document.documentElement.attributes)
|
||||||
.call(document.documentElement.attributes)
|
|
||||||
.forEach(function (attribute) {
|
.forEach(function (attribute) {
|
||||||
if (attribute.name.indexOf("data-wrn-bind-") === 0) {
|
if (attribute.name.indexOf("data-wrn-bind-") === 0) {
|
||||||
document.documentElement.removeAttribute(attribute.name);
|
document.documentElement.removeAttribute(attribute.name);
|
||||||
@@ -4195,7 +4270,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
|
|
||||||
function emitPinInputEvent(root, name, extra) {
|
function emitPinInputEvent(root, name, extra) {
|
||||||
var hidden = root.querySelector("[data-pin-value]");
|
var hidden = root.querySelector("[data-pin-value]");
|
||||||
var cells = Array.prototype.slice.call(root.querySelectorAll("[data-pin-cell]"));
|
var cells = toArray(root.querySelectorAll("[data-pin-cell]"));
|
||||||
var value = hidden ? hidden.value : "";
|
var value = hidden ? hidden.value : "";
|
||||||
var detail = {
|
var detail = {
|
||||||
component: "PinInput",
|
component: "PinInput",
|
||||||
@@ -4217,7 +4292,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
/*__WRNEXUS_CONTROLLERS_PIN_START__*/
|
/*__WRNEXUS_CONTROLLERS_PIN_START__*/
|
||||||
function setupPinInputController(root) {
|
function setupPinInputController(root) {
|
||||||
if (!root || root.__wrnexusPinInputController) return;
|
if (!root || root.__wrnexusPinInputController) return;
|
||||||
var cells = Array.prototype.slice.call(root.querySelectorAll("[data-pin-cell]"));
|
var cells = toArray(root.querySelectorAll("[data-pin-cell]"));
|
||||||
var hidden = root.querySelector("[data-pin-value]");
|
var hidden = root.querySelector("[data-pin-value]");
|
||||||
var clearButton = root.querySelector("[data-pin-clear]");
|
var clearButton = root.querySelector("[data-pin-clear]");
|
||||||
var patternSource = root.getAttribute("data-pattern") || "[0-9]";
|
var patternSource = root.getAttribute("data-pattern") || "[0-9]";
|
||||||
@@ -4651,12 +4726,68 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Parse a while or for statement into its parts.
|
||||||
|
*
|
||||||
|
* Returns null for anything else so the caller falls through to the other
|
||||||
|
* statement forms. A for header is split on top-level semicolons only, so a
|
||||||
|
* semicolon inside a call argument or a string does not break it.
|
||||||
|
*/
|
||||||
|
function parseLoopStatement(source) {
|
||||||
|
source = String(source || "").trim();
|
||||||
|
|
||||||
|
var kind = null;
|
||||||
|
|
||||||
|
if (source.slice(0, 5) === "while" && !/[A-Za-z0-9_$]/.test(source.charAt(5))) {
|
||||||
|
kind = "while";
|
||||||
|
} else if (source.slice(0, 3) === "for" && !/[A-Za-z0-9_$]/.test(source.charAt(3))) {
|
||||||
|
kind = "for";
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var index = skipStatementWhitespace(source, kind === "while" ? 5 : 3);
|
||||||
|
|
||||||
|
if (source.charAt(index) !== "(") return null;
|
||||||
|
|
||||||
|
var headerEnd = findClosingDelimiter(source, index, "(", ")");
|
||||||
|
|
||||||
|
if (headerEnd < 0) throw new Error("Unclosed " + kind + " header");
|
||||||
|
|
||||||
|
var header = source.slice(index + 1, headerEnd);
|
||||||
|
|
||||||
|
index = skipStatementWhitespace(source, headerEnd + 1);
|
||||||
|
|
||||||
|
if (source.charAt(index) !== "{") throw new Error("Expected a block after " + kind);
|
||||||
|
|
||||||
|
var bodyEnd = findClosingDelimiter(source, index, "{", "}");
|
||||||
|
|
||||||
|
if (bodyEnd < 0) throw new Error("Unclosed " + kind + " body");
|
||||||
|
|
||||||
|
var body = source.slice(index + 1, bodyEnd);
|
||||||
|
|
||||||
|
if (kind === "while") {
|
||||||
|
return { init: null, condition: header.trim(), step: null, body: body };
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = splitTopLevel(header, ";");
|
||||||
|
|
||||||
|
if (parts.length !== 3) throw new Error("A for header needs three parts");
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: parts[0].trim(),
|
||||||
|
condition: parts[1].trim(),
|
||||||
|
step: parts[2].trim(),
|
||||||
|
body: body,
|
||||||
|
};
|
||||||
|
}
|
||||||
function runStatement(
|
function runStatement(
|
||||||
stmt,
|
stmt,
|
||||||
evalExpr,
|
evalExpr,
|
||||||
read,
|
read,
|
||||||
write,
|
write,
|
||||||
runBlock,
|
runBlock,
|
||||||
|
declare,
|
||||||
) {
|
) {
|
||||||
stmt = String(stmt || "").trim();
|
stmt = String(stmt || "").trim();
|
||||||
|
|
||||||
@@ -4707,6 +4838,60 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A loop body is author-written and runs in the browser, so a mistaken
|
||||||
|
* condition would freeze the tab. The cap keeps a runaway loop from
|
||||||
|
* hanging the page; it is far above any list a view renders.
|
||||||
|
*/
|
||||||
|
var loop = parseLoopStatement(stmt);
|
||||||
|
|
||||||
|
if (loop) {
|
||||||
|
var guard = 0;
|
||||||
|
|
||||||
|
if (loop.init) {
|
||||||
|
runStatement(loop.init, evalExpr, read, write, runBlock, declare);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!loop.condition || !!evalExpr(loop.condition)) {
|
||||||
|
if (++guard > 100000) break;
|
||||||
|
|
||||||
|
var outcome = runBlock(loop.body);
|
||||||
|
|
||||||
|
if (outcome && outcome.returned) return outcome;
|
||||||
|
|
||||||
|
if (loop.step) {
|
||||||
|
runStatement(loop.step, evalExpr, read, write, runBlock, declare);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { returned: false, value: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A declaration binds a local, then falls through to the assignment
|
||||||
|
* branch below.
|
||||||
|
*
|
||||||
|
* Declaring first is what makes it local: an unknown name reaching
|
||||||
|
* writeScope becomes a signal and triggers a render sweep, so a var
|
||||||
|
* inside a shared function called during a render would loop forever.
|
||||||
|
* Once the name exists in locals, read and write both stay there.
|
||||||
|
*/
|
||||||
|
var declaration = stmt.match(
|
||||||
|
/^(?:var|let|const)\s+([A-Za-z_$][A-Za-z0-9_$]*[\s\S]*)$/,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (declaration) {
|
||||||
|
stmt = declaration[1].trim();
|
||||||
|
|
||||||
|
var declaredName = /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(stmt)[0];
|
||||||
|
|
||||||
|
if (declare) declare(declaredName, undefined);
|
||||||
|
|
||||||
|
if (!/=/.test(stmt)) {
|
||||||
|
return { returned: false, value: undefined };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var increment = stmt.match(
|
var increment = stmt.match(
|
||||||
/^([A-Za-z_$][A-Za-z0-9_$]*)\s*(\+\+|--)$/,
|
/^([A-Za-z_$][A-Za-z0-9_$]*)\s*(\+\+|--)$/,
|
||||||
);
|
);
|
||||||
@@ -5699,7 +5884,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
|||||||
host.querySelectorAll("[data-wrn-dynamic-component]").forEach(function (element) {
|
host.querySelectorAll("[data-wrn-dynamic-component]").forEach(function (element) {
|
||||||
if (element.__wrnDynamicMounted) return;
|
if (element.__wrnDynamicMounted) return;
|
||||||
element.__wrnDynamicMounted = true;
|
element.__wrnDynamicMounted = true;
|
||||||
var cases = Array.prototype.slice.call(element.children).filter(function (candidate) {
|
var cases = toArray(element.children).filter(function (candidate) {
|
||||||
return candidate.hasAttribute("data-component-case");
|
return candidate.hasAttribute("data-component-case");
|
||||||
}).map(function (candidate) {
|
}).map(function (candidate) {
|
||||||
var marker = document.createComment("wrnexus-component-case:" + (candidate.getAttribute("data-component-case") || ""));
|
var marker = document.createComment("wrnexus-component-case:" + (candidate.getAttribute("data-component-case") || ""));
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { afterAll } from "bun:test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore globals a suite replaces, once the suite is done.
|
||||||
|
*
|
||||||
|
* These suites install a happy-dom window over the real globals and delete
|
||||||
|
* them before each test so every test starts clean. bun test loads and runs
|
||||||
|
* one file at a time rather than importing them all up front, so anything left
|
||||||
|
* deleted is still missing when the next suite runs -- which is how `bun test`
|
||||||
|
* with no argument came to fail unrelated files with "fetch is not a
|
||||||
|
* function". Names absent at capture time are deleted again rather than being
|
||||||
|
* restored as undefined, so a global that never existed does not gain a key.
|
||||||
|
*/
|
||||||
|
export function restoreGlobalsAfterAll(names: readonly string[]): void {
|
||||||
|
const captured = new Map<string, unknown>(
|
||||||
|
names.map((name) => [name, (globalThis as Record<string, unknown>)[name]]),
|
||||||
|
);
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
for (const [name, value] of captured) {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete (globalThis as Record<string, unknown>)[name];
|
||||||
|
} else {
|
||||||
|
(globalThis as Record<string, unknown>)[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test, expect, beforeEach } from "bun:test";
|
import { test, expect, beforeEach } from "bun:test";
|
||||||
import { Window } from "happy-dom";
|
import { Window } from "happy-dom";
|
||||||
import { NAV_RUNTIME } from "../src/nav-runtime.ts";
|
import { NAV_RUNTIME } from "../src/nav-runtime.ts";
|
||||||
|
import { restoreGlobalsAfterAll } from "./global-restore.ts";
|
||||||
|
|
||||||
let win: any;
|
let win: any;
|
||||||
let fetchCalls: { url: string; opts: any }[];
|
let fetchCalls: { url: string; opts: any }[];
|
||||||
@@ -36,18 +37,22 @@ function install(bodyHtml: string): void {
|
|||||||
|
|
||||||
const flush = () => new Promise((r) => setTimeout(r, 0));
|
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
const REPLACED_GLOBALS = [
|
||||||
|
"window",
|
||||||
|
"document",
|
||||||
|
"history",
|
||||||
|
"location",
|
||||||
|
"DOMParser",
|
||||||
|
"CustomEvent",
|
||||||
|
"Event",
|
||||||
|
"fetch",
|
||||||
|
];
|
||||||
|
|
||||||
|
restoreGlobalsAfterAll(REPLACED_GLOBALS);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const g = globalThis as any;
|
const g = globalThis as any;
|
||||||
for (const k of [
|
for (const k of REPLACED_GLOBALS) {
|
||||||
"window",
|
|
||||||
"document",
|
|
||||||
"history",
|
|
||||||
"location",
|
|
||||||
"DOMParser",
|
|
||||||
"CustomEvent",
|
|
||||||
"Event",
|
|
||||||
"fetch",
|
|
||||||
]) {
|
|
||||||
delete g[k];
|
delete g[k];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -148,7 +153,7 @@ test("synchronizes and rebinds i18n data during client navigation", async () =>
|
|||||||
win.__wrnLang = { bind: (root: unknown) => (boundRoot = root) };
|
win.__wrnLang = { bind: (root: unknown) => (boundRoot = root) };
|
||||||
nextHtml =
|
nextHtml =
|
||||||
`<html lang="mr"><body><div id="app"><p data-t="home.title">नवीन</p></div>` +
|
`<html lang="mr"><body><div id="app"><p data-t="home.title">नवीन</p></div>` +
|
||||||
`<script>window.__wrnI18n={"lang":"mr","messages":{"home":{"title":"नवीन"}},"fallbackMessages":{}};</script>` +
|
`<script type="application/json" data-wrn-i18n>{"lang":"mr","messages":{"home":{"title":"नवीन"}},"fallbackMessages":{}}</script>` +
|
||||||
`</body></html>`;
|
`</body></html>`;
|
||||||
|
|
||||||
win.document.getElementById("lnk").click();
|
win.document.getElementById("lnk").click();
|
||||||
@@ -180,7 +185,7 @@ test("preserves same-language translations when an incoming navigation catalog i
|
|||||||
};
|
};
|
||||||
nextHtml =
|
nextHtml =
|
||||||
`<html lang="en"><body><div id="app"><p data-t="navigation.home">navigation.home</p></div>` +
|
`<html lang="en"><body><div id="app"><p data-t="navigation.home">navigation.home</p></div>` +
|
||||||
`<script>window.__wrnI18n={"lang":"en","messages":{},"fallbackMessages":{}};</script>` +
|
`<script type="application/json" data-wrn-i18n>{"lang":"en","messages":{},"fallbackMessages":{}}</script>` +
|
||||||
`</body></html>`;
|
`</body></html>`;
|
||||||
|
|
||||||
win.document.getElementById("lnk").click();
|
win.document.getElementById("lnk").click();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Window } from "happy-dom";
|
|||||||
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
|
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
|
||||||
import { getComponentControllerRuntime, getReactiveRuntime } from "../src/index.ts";
|
import { getComponentControllerRuntime, getReactiveRuntime } from "../src/index.ts";
|
||||||
import { mountHtml } from "@wrnexus/test";
|
import { mountHtml } from "@wrnexus/test";
|
||||||
|
import { restoreGlobalsAfterAll } from "./global-restore.ts";
|
||||||
|
|
||||||
// Fresh DOM per test, with the runtime's globals bound.
|
// Fresh DOM per test, with the runtime's globals bound.
|
||||||
function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Window {
|
function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Window {
|
||||||
@@ -34,12 +35,14 @@ function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Wind
|
|||||||
return win as unknown as Window;
|
return win as unknown as Window;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REPLACED_GLOBALS = ["window", "document", "location", "fetch", "MutationObserver"];
|
||||||
|
|
||||||
|
restoreGlobalsAfterAll(REPLACED_GLOBALS);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
delete (globalThis as Record<string, unknown>).window;
|
for (const name of REPLACED_GLOBALS) {
|
||||||
delete (globalThis as Record<string, unknown>).document;
|
delete (globalThis as Record<string, unknown>)[name];
|
||||||
delete (globalThis as Record<string, unknown>).location;
|
}
|
||||||
delete (globalThis as Record<string, unknown>).fetch;
|
|
||||||
delete (globalThis as Record<string, unknown>).MutationObserver;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("split runtime hydrates a controller only from the controller asset", () => {
|
test("split runtime hydrates a controller only from the controller asset", () => {
|
||||||
@@ -117,6 +120,55 @@ test("@event (data-on-click) mutates a signal and re-renders", () => {
|
|||||||
expect(btn.textContent).toBe("2");
|
expect(btn.textContent).toBe("2");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("compiled if blocks switch branches after hydration", () => {
|
||||||
|
const definition = Buffer.from(
|
||||||
|
JSON.stringify([
|
||||||
|
{ cond: "open", body: '<p class="open">Open <span data-text="count">{count}</span></p>' },
|
||||||
|
{ cond: null, body: '<p class="closed">Closed</p>' },
|
||||||
|
]),
|
||||||
|
).toString("base64");
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="open: false, count: 2">` +
|
||||||
|
`<button data-on-click="open = !open">toggle</button>` +
|
||||||
|
`<button data-on-click="count++">increment</button>` +
|
||||||
|
`<template data-wrn-if="${definition}"></template><p class="closed">Closed</p><template data-wrn-control-end></template>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
expect(win.document.querySelector(".closed")).toBeNull();
|
||||||
|
expect(win.document.querySelector(".open")?.textContent).toBe("Open 2");
|
||||||
|
win.document.querySelectorAll("button")[1]!.click();
|
||||||
|
expect(win.document.querySelector(".open")?.textContent).toBe("Open 3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compiled each blocks rerender rows and their empty branch", () => {
|
||||||
|
const definition = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
list: "items",
|
||||||
|
item: "item",
|
||||||
|
index: "index",
|
||||||
|
body: '<p class="row">{index}:{item}</p>',
|
||||||
|
empty: '<p class="empty">Empty</p>',
|
||||||
|
}),
|
||||||
|
).toString("base64");
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="items: ['a']">` +
|
||||||
|
`<button data-on-click="items = ['b', 'c']">more</button>` +
|
||||||
|
`<button data-on-click="items = []">clear</button>` +
|
||||||
|
`<template data-wrn-each="${definition}"></template><p class="row">0:a</p><template data-wrn-control-end></template>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
win.document.querySelectorAll("button")[0]!.click();
|
||||||
|
expect(Array.from(win.document.querySelectorAll(".row")).map((node) => node.textContent)).toEqual(
|
||||||
|
["0:b", "1:c"],
|
||||||
|
);
|
||||||
|
win.document.querySelectorAll("button")[1]!.click();
|
||||||
|
expect(win.document.querySelector(".row")).toBeNull();
|
||||||
|
expect(win.document.querySelector(".empty")?.textContent).toBe("Empty");
|
||||||
|
});
|
||||||
|
|
||||||
test("component functions support formatted multiline assignments and ternaries", () => {
|
test("component functions support formatted multiline assignments and ternaries", () => {
|
||||||
const behavior = Buffer.from(
|
const behavior = Buffer.from(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -1582,3 +1634,150 @@ test("splitter announces its new size for the component to re-emit", () => {
|
|||||||
);
|
);
|
||||||
expect(seen).toEqual([60]);
|
expect(seen).toEqual([60]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("control blocks created by a client rerender render their own content", () => {
|
||||||
|
// A nested block that arrives with the server HTML is hydrated: its first
|
||||||
|
// reactive pass must NOT redraw, or it would throw away server DOM. A nested
|
||||||
|
// block created later by an outer rerender has no server DOM, so skipping its
|
||||||
|
// first pass leaves it permanently empty — its dependencies never change
|
||||||
|
// again to trigger a second one.
|
||||||
|
const inner = Buffer.from(
|
||||||
|
JSON.stringify([
|
||||||
|
{ cond: "g.rows.length > 0", body: '<p class="has-rows">HAS</p>' },
|
||||||
|
{ cond: null, body: '<p class="no-rows">NONE</p>' },
|
||||||
|
]),
|
||||||
|
).toString("base64");
|
||||||
|
const outer = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
list: "groups",
|
||||||
|
item: "g",
|
||||||
|
body:
|
||||||
|
`<section class="group"><span data-text="g.name">{g.name}</span>` +
|
||||||
|
`<template data-wrn-if="${inner}"></template><template data-wrn-control-end></template>` +
|
||||||
|
`</section>`,
|
||||||
|
empty: "",
|
||||||
|
}),
|
||||||
|
).toString("base64");
|
||||||
|
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="groups: [{ name: 'g1', rows: ['a'] }]">` +
|
||||||
|
`<button data-on-click="groups = [{ name: 'g2', rows: [] }]">swap</button>` +
|
||||||
|
`<template data-wrn-each="${outer}"></template>` +
|
||||||
|
`<section class="group"><span data-text="g.name">g1</span>` +
|
||||||
|
`<template data-wrn-if="${inner}"></template><p class="has-rows">HAS</p>` +
|
||||||
|
`<template data-wrn-control-end></template></section>` +
|
||||||
|
`<template data-wrn-control-end></template>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
|
||||||
|
// The outer each rerendered: the new group's heading is present.
|
||||||
|
expect(win.document.querySelector(".group span")?.textContent).toBe("g2");
|
||||||
|
// The nested if inside that new row must have rendered its else branch.
|
||||||
|
expect(win.document.querySelector(".no-rows")?.textContent).toBe("NONE");
|
||||||
|
expect(win.document.querySelector(".has-rows")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a for loop with a declaration initialiser runs in a handler", () => {
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="total: 0">` +
|
||||||
|
`<button data-on-click="for (var i = 1; i <= 3; i += 1) { total = total + i }">go</button>` +
|
||||||
|
`<span data-text="total">0</span>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
expect(win.document.querySelector("span")?.textContent).toBe("6");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a while loop runs in a handler", () => {
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="n: 1">` +
|
||||||
|
`<button data-on-click="while (n < 10) { n = n * 2 }">go</button>` +
|
||||||
|
`<span data-text="n">1</span>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
expect(win.document.querySelector("span")?.textContent).toBe("16");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a declaration stays local instead of becoming reactive state", () => {
|
||||||
|
// An unknown name reaching writeScope becomes a signal and triggers a render
|
||||||
|
// sweep. A var inside a function called during a render would then loop
|
||||||
|
// forever, so declarations must bind locally.
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="out: 0">` +
|
||||||
|
`<button data-on-click="var step = 5; out = step + 1">go</button>` +
|
||||||
|
`<span class="out" data-text="out">0</span>` +
|
||||||
|
`<span class="leak" data-text="step"></span>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
expect(win.document.querySelector(".out")?.textContent).toBe("6");
|
||||||
|
expect(win.document.querySelector(".leak")?.textContent).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a control block removed from the DOM does not abort later renders", () => {
|
||||||
|
// Its effect stays in the renderers list. Running it against a detached node
|
||||||
|
// throws, which would abort the sweep and leave every later effect stale.
|
||||||
|
const definition = Buffer.from(
|
||||||
|
JSON.stringify([{ cond: "n < 100", body: '<i class="gone"></i>' }]),
|
||||||
|
).toString("base64");
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="n: 0">` +
|
||||||
|
`<template data-wrn-if="${definition}"></template><i class="gone"></i><template data-wrn-control-end></template>` +
|
||||||
|
`<button data-on-click="n = n + 1">go</button>` +
|
||||||
|
`<span data-text="n">0</span>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
const block = win.document.querySelector("[data-wrn-if]")!;
|
||||||
|
block.parentNode!.removeChild(block);
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
expect(win.document.querySelector("span")?.textContent).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a declaration statement assigns into scope", () => {
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="out: 0">` +
|
||||||
|
`<button data-on-click="var step = 5; out = step + 1">go</button>` +
|
||||||
|
`<span data-text="out">0</span>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
expect(win.document.querySelector("span")?.textContent).toBe("6");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unbounded loop stops instead of hanging the page", () => {
|
||||||
|
// Handler source is author-controlled and runs in the browser. Without a cap
|
||||||
|
// a mistaken condition freezes the tab with no way back.
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="n: 0">` +
|
||||||
|
`<button data-on-click="while (true) { n = n + 1 }">go</button>` +
|
||||||
|
`<span data-text="n">0</span>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
const value = Number(win.document.querySelector("span")?.textContent);
|
||||||
|
expect(value).toBeGreaterThan(0);
|
||||||
|
expect(Number.isFinite(value)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a class binding inside data-for follows state the row never mentions", () => {
|
||||||
|
// The row's own array is untouched, so nothing rebuilds the list. The
|
||||||
|
// binding has to be reactive in its own right to keep up.
|
||||||
|
const binding = JSON.stringify(["is-active", "selected === row.id"]);
|
||||||
|
const win = mount(
|
||||||
|
`<div data-scope="rows: [{"id":1},{"id":2}], selected: 1">` +
|
||||||
|
`<button data-on-click="selected = 2">pick</button>` +
|
||||||
|
`<ul><li data-for="row in rows" data-wrn-class-active='${binding}'></li></ul>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
const items = () => Array.from(win.document.querySelectorAll("li"));
|
||||||
|
expect(items()[0]?.classList.contains("is-active")).toBe(true);
|
||||||
|
expect(items()[1]?.classList.contains("is-active")).toBe(false);
|
||||||
|
|
||||||
|
win.document.querySelector("button")!.click();
|
||||||
|
|
||||||
|
expect(items()[0]?.classList.contains("is-active")).toBe(false);
|
||||||
|
expect(items()[1]?.classList.contains("is-active")).toBe(true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test, expect, beforeEach } from "bun:test";
|
import { test, expect, beforeEach } from "bun:test";
|
||||||
import { Window } from "happy-dom";
|
import { Window } from "happy-dom";
|
||||||
import { REALTIME_RUNTIME } from "../src/realtime-runtime.ts";
|
import { REALTIME_RUNTIME } from "../src/realtime-runtime.ts";
|
||||||
|
import { restoreGlobalsAfterAll } from "./global-restore.ts";
|
||||||
|
|
||||||
/* A fake WebSocket that records instances + sent frames and lets tests drive events. */
|
/* A fake WebSocket that records instances + sent frames and lets tests drive events. */
|
||||||
let sockets: FakeWS[];
|
let sockets: FakeWS[];
|
||||||
@@ -45,8 +46,12 @@ function boot(bodyHtml: string) {
|
|||||||
return win as unknown as Window;
|
return win as unknown as Window;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REPLACED_GLOBALS = ["window", "document", "location", "WebSocket"];
|
||||||
|
|
||||||
|
restoreGlobalsAfterAll(REPLACED_GLOBALS);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
for (const k of ["window", "document", "location", "WebSocket"]) {
|
for (const k of REPLACED_GLOBALS) {
|
||||||
delete (globalThis as Record<string, unknown>)[k];
|
delete (globalThis as Record<string, unknown>)[k];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/db",
|
"name": "@wrnexus/db",
|
||||||
"version": "0.8.14",
|
"version": "0.8.16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
@@ -26,6 +26,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,11 +226,16 @@ export function generateQueriesFile(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const imports = [
|
const imports = [`import type { Db${usesExecResult ? ", ExecResult" : ""} } from "@wrnexus/db";`];
|
||||||
`import type { Db${usesExecResult ? ", ExecResult" : ""} } from "@wrnexus/db";`,
|
|
||||||
];
|
|
||||||
if (usedModels.size > 0) {
|
if (usedModels.size > 0) {
|
||||||
imports.push(`import { ${[...usedModels].sort().join(", ")} } from "./schema.ts";`);
|
imports.push(`import { ${[...usedModels].sort().join(", ")} } from "./schema.ts";`);
|
||||||
}
|
}
|
||||||
return `// AUTO-GENERATED by \`wrnexus db generate\` — do not edit.\n${imports.join("\n")}\n\n${blocks.join("\n\n")}\n`;
|
// The dialect is stamped into the header because it changes the emitted SQL:
|
||||||
|
// postgres uses $1 placeholders where sqlite and mysql use ?. Regenerating
|
||||||
|
// under a different profile therefore rewrites this committed file, and
|
||||||
|
// without the stamp the diff looks like unexplained churn.
|
||||||
|
return (
|
||||||
|
`// AUTO-GENERATED by \`wrnexus db generate\` (dialect: ${dialect}) — do not edit.\n` +
|
||||||
|
`${imports.join("\n")}\n\n${blocks.join("\n\n")}\n`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,9 +91,10 @@ function additiveColumnTarget(sql: string): { table: string; column: string } |
|
|||||||
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
||||||
.replace(/--[^\r\n]*/g, " ")
|
.replace(/--[^\r\n]*/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
const match = /^ALTER\s+TABLE\s+([A-Za-z_][A-Za-z0-9_]*)\s+ADD\s+COLUMN\s+([A-Za-z_][A-Za-z0-9_]*)\b[\s\S]*;?\s*$/i.exec(
|
const match =
|
||||||
executable,
|
/^ALTER\s+TABLE\s+([A-Za-z_][A-Za-z0-9_]*)\s+ADD\s+COLUMN\s+([A-Za-z_][A-Za-z0-9_]*)\b[\s\S]*;?\s*$/i.exec(
|
||||||
);
|
executable,
|
||||||
|
);
|
||||||
return match ? { table: match[1]!, column: match[2]! } : undefined;
|
return match ? { table: match[1]!, column: match[2]! } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { generateQueriesFile, parseQueries } from "../src/generate.ts";
|
||||||
|
|
||||||
|
const queries = parseQueries(`-- name: GetUser :one\nSELECT * FROM users WHERE email = :email;\n`);
|
||||||
|
|
||||||
|
test("the generated header records the dialect it was built for", () => {
|
||||||
|
// The same command emits different SQL per dialect, so a build under another
|
||||||
|
// profile rewrites the committed file. The stamp makes that visible in the
|
||||||
|
// diff instead of looking like unexplained churn.
|
||||||
|
expect(generateQueriesFile(queries, [], "sqlite")).toContain("(dialect: sqlite)");
|
||||||
|
expect(generateQueriesFile(queries, [], "postgres")).toContain("(dialect: postgres)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("placeholder style follows the dialect", () => {
|
||||||
|
expect(generateQueriesFile(queries, [], "sqlite")).toContain("email = ?");
|
||||||
|
expect(generateQueriesFile(queries, [], "postgres")).toContain("email = $1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generation is deterministic for a fixed dialect", () => {
|
||||||
|
const first = generateQueriesFile(queries, [], "postgres");
|
||||||
|
const second = generateQueriesFile(queries, [], "postgres");
|
||||||
|
expect(first).toBe(second);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/dev-server",
|
"name": "@wrnexus/dev-server",
|
||||||
"version": "0.8.33",
|
"version": "0.8.39",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import type { AssetServer } from "./runtime.ts";
|
|||||||
import { servePublicAsset } from "./public.ts";
|
import { servePublicAsset } from "./public.ts";
|
||||||
import { servePluginAsset, type ServedPluginAsset } from "./plugin-assets.ts";
|
import { servePluginAsset, type ServedPluginAsset } from "./plugin-assets.ts";
|
||||||
import { serveIslandArtifact, serveWrnBrowserArtifact } from "./pipeline.ts";
|
import { serveIslandArtifact, serveWrnBrowserArtifact } from "./pipeline.ts";
|
||||||
|
import { HMR_CLIENT_HREF, HMR_CLIENT_JS } from "./runtime.ts";
|
||||||
|
|
||||||
/** Style inputs the dev asset server needs to build `/__wrnexus/styles.css`. */
|
/** Style inputs the dev asset server needs to build `/__wrnexus/styles.css`. */
|
||||||
export interface DevStyles {
|
export interface DevStyles {
|
||||||
@@ -99,6 +100,7 @@ export function createDevAssetServer(
|
|||||||
if (pathname.startsWith("/__wrnexus/island/")) {
|
if (pathname.startsWith("/__wrnexus/island/")) {
|
||||||
return serveIslandArtifact(pathname) ?? new Response("Not Found", { status: 404 });
|
return serveIslandArtifact(pathname) ?? new Response("Not Found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
if (pathname === HMR_CLIENT_HREF) return jsResponse(HMR_CLIENT_JS);
|
||||||
if (pathname === "/__wrnexus/islands.js") return jsResponse(getIslandRuntime(true));
|
if (pathname === "/__wrnexus/islands.js") return jsResponse(getIslandRuntime(true));
|
||||||
if (pathname === "/__wrnexus/reactive.js") return jsResponse(getReactiveRuntime(true));
|
if (pathname === "/__wrnexus/reactive.js") return jsResponse(getReactiveRuntime(true));
|
||||||
if (pathname === "/__wrnexus/controllers.js")
|
if (pathname === "/__wrnexus/controllers.js")
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ interface Target extends GatewayApp {
|
|||||||
interface WsBridge {
|
interface WsBridge {
|
||||||
origin: string;
|
origin: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
backend?: WebSocket;
|
backend?: WebSocket;
|
||||||
queue: Array<string | ArrayBuffer>;
|
queue: Array<string | ArrayBuffer>;
|
||||||
maxMessageBytes: number;
|
maxMessageBytes: number;
|
||||||
@@ -158,7 +159,7 @@ function requestMessageBytes(value: string | ArrayBuffer | ArrayBufferView): num
|
|||||||
return value instanceof ArrayBuffer ? value.byteLength : value.byteLength;
|
return value instanceof ArrayBuffer ? value.byteLength : value.byteLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
function gatewayWebSocketOriginAllowed(
|
export function gatewayWebSocketOriginAllowed(
|
||||||
req: Request,
|
req: Request,
|
||||||
target: Target,
|
target: Target,
|
||||||
configured: string[],
|
configured: string[],
|
||||||
@@ -173,7 +174,11 @@ function gatewayWebSocketOriginAllowed(
|
|||||||
}
|
}
|
||||||
if (configured.includes(origin)) return true;
|
if (configured.includes(origin)) return true;
|
||||||
if (target.publicOrigin && origin === new URL(target.publicOrigin).origin) return true;
|
if (target.publicOrigin && origin === new URL(target.publicOrigin).origin) return true;
|
||||||
return target.domains.some((domain) => parsed.host.toLowerCase() === domain.toLowerCase());
|
// Compare hostnames, not hosts: configured domains carry no port, while the
|
||||||
|
// browser's Origin does. publicOrigin above only ever matches domains[0], so
|
||||||
|
// every other domain fell through to here and was denied purely on the port,
|
||||||
|
// which left the HMR socket reconnecting forever on those hosts.
|
||||||
|
return target.domains.some((domain) => parsed.hostname.toLowerCase() === domain.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -433,6 +438,30 @@ export function gatewayProxyHeaders(
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Forward application identity to the child while leaving WebSocket framing to Bun. */
|
||||||
|
export function gatewayWebSocketBackendHeaders(
|
||||||
|
req: Request,
|
||||||
|
url: URL,
|
||||||
|
ip: string,
|
||||||
|
forwardedHeaders: boolean,
|
||||||
|
backendOrigin: string,
|
||||||
|
): Record<string, string> {
|
||||||
|
const headers = stripUntrustedInternalHeaders(
|
||||||
|
gatewayProxyHeaders(req, url, ip, forwardedHeaders),
|
||||||
|
);
|
||||||
|
headers.delete("host");
|
||||||
|
headers.delete("connection");
|
||||||
|
headers.delete("upgrade");
|
||||||
|
headers.delete("accept-encoding");
|
||||||
|
for (const name of [...headers.keys()]) {
|
||||||
|
if (name.startsWith("sec-websocket-")) headers.delete(name);
|
||||||
|
}
|
||||||
|
// The public origin was validated at the gateway edge. The child receives a
|
||||||
|
// new, trusted same-origin connection from its private gateway listener.
|
||||||
|
headers.set("origin", backendOrigin);
|
||||||
|
return Object.fromEntries(headers);
|
||||||
|
}
|
||||||
|
|
||||||
/** Remove headers that only a direct workspace-to-app request may supply. */
|
/** Remove headers that only a direct workspace-to-app request may supply. */
|
||||||
export function stripUntrustedInternalHeaders(headers: Headers): Headers {
|
export function stripUntrustedInternalHeaders(headers: Headers): Headers {
|
||||||
const sanitized = new Headers(headers);
|
const sanitized = new Headers(headers);
|
||||||
@@ -441,19 +470,37 @@ export function stripUntrustedInternalHeaders(headers: Headers): Headers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The reserved inter-app RPC namespace is refused at the gateway edge, before
|
* Private inter-app RPC routes are refused at the gateway edge. The exact
|
||||||
* any proxying — it is only ever mounted by a child app's own dev-server and
|
* prefix is the CSRF-protected browser-to-app server-function endpoint and is
|
||||||
* must never be reachable from outside the workspace.
|
* intentionally proxied to the selected child app.
|
||||||
*/
|
*/
|
||||||
export function isRpcGatewayPath(pathname: string): boolean {
|
export function isRpcGatewayPath(pathname: string): boolean {
|
||||||
return (
|
return (
|
||||||
pathname === RPC_PATH_PREFIX ||
|
|
||||||
pathname.startsWith(`${RPC_PATH_PREFIX}/`) ||
|
pathname.startsWith(`${RPC_PATH_PREFIX}/`) ||
|
||||||
pathname === RPC_STREAM_PATH_PREFIX ||
|
pathname === RPC_STREAM_PATH_PREFIX ||
|
||||||
pathname.startsWith(`${RPC_STREAM_PATH_PREFIX}/`)
|
pathname.startsWith(`${RPC_STREAM_PATH_PREFIX}/`)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build the trusted internal hop for a browser server-function request. */
|
||||||
|
export function gatewayBrowserRpcHeaders(
|
||||||
|
req: Request,
|
||||||
|
url: URL,
|
||||||
|
ip: string,
|
||||||
|
forwardedHeaders: boolean,
|
||||||
|
backendOrigin: string,
|
||||||
|
): Headers {
|
||||||
|
const headers = stripUntrustedInternalHeaders(
|
||||||
|
gatewayProxyHeaders(req, url, ip, forwardedHeaders),
|
||||||
|
);
|
||||||
|
// The public request already passed the gateway's host and fetch-metadata
|
||||||
|
// checks. Present the internal proxy hop as same-origin to the child while
|
||||||
|
// retaining the double-submit CSRF cookie and header.
|
||||||
|
headers.set("origin", backendOrigin);
|
||||||
|
headers.delete("host");
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
/** Boot every app as a child process, then route by Host on one gateway port. */
|
/** Boot every app as a child process, then route by Host on one gateway port. */
|
||||||
export async function startGateway(opts: GatewayOptions): Promise<RunningGateway> {
|
export async function startGateway(opts: GatewayOptions): Promise<RunningGateway> {
|
||||||
const port = opts.port ?? 3000;
|
const port = opts.port ?? 3000;
|
||||||
@@ -468,7 +515,7 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
|||||||
);
|
);
|
||||||
// Loopback-only origins, computed up front (ports are assigned by index
|
// Loopback-only origins, computed up front (ports are assigned by index
|
||||||
// before any child spawns) so every child can reach every other child
|
// before any child spawns) so every child can reach every other child
|
||||||
// directly — bypassing the gateway, which 404s the RPC prefix by design.
|
// directly — bypassing the gateway, which 404s private nested RPC routes.
|
||||||
const internalOriginsEnv: Readonly<Record<string, string>> = Object.freeze(
|
const internalOriginsEnv: Readonly<Record<string, string>> = Object.freeze(
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
opts.apps.map((app, i) => [app.name, `http://127.0.0.1:${app.port ?? port + 1 + i}`]),
|
opts.apps.map((app, i) => [app.name, `http://127.0.0.1:${app.port ?? port + 1 + i}`]),
|
||||||
@@ -692,6 +739,13 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
|||||||
data: {
|
data: {
|
||||||
origin: target.origin,
|
origin: target.origin,
|
||||||
path: url.pathname + url.search,
|
path: url.pathname + url.search,
|
||||||
|
headers: gatewayWebSocketBackendHeaders(
|
||||||
|
req,
|
||||||
|
url,
|
||||||
|
ip,
|
||||||
|
forwardedHeaders,
|
||||||
|
target.origin,
|
||||||
|
),
|
||||||
queue: [],
|
queue: [],
|
||||||
maxMessageBytes: websocketSecurity.maxMessageBytes ?? 64 * 1024,
|
maxMessageBytes: websocketSecurity.maxMessageBytes ?? 64 * 1024,
|
||||||
maxQueuedMessages: websocketSecurity.maxQueuedMessages ?? 100,
|
maxQueuedMessages: websocketSecurity.maxQueuedMessages ?? 100,
|
||||||
@@ -701,9 +755,10 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HTTP → reverse-proxy to the app, preserving method/headers/body.
|
// HTTP → reverse-proxy to the app, preserving method/headers/body.
|
||||||
const headers = stripUntrustedInternalHeaders(
|
const headers =
|
||||||
gatewayProxyHeaders(req, url, ip, forwardedHeaders),
|
url.pathname === RPC_PATH_PREFIX
|
||||||
);
|
? gatewayBrowserRpcHeaders(req, url, ip, forwardedHeaders, target.origin)
|
||||||
|
: stripUntrustedInternalHeaders(gatewayProxyHeaders(req, url, ip, forwardedHeaders));
|
||||||
const body =
|
const body =
|
||||||
req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
|
req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
|
||||||
let res: Response;
|
let res: Response;
|
||||||
@@ -753,7 +808,11 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
|||||||
websocket: {
|
websocket: {
|
||||||
open(ws) {
|
open(ws) {
|
||||||
const backendUrl = ws.data.origin.replace(/^http/, "ws") + ws.data.path;
|
const backendUrl = ws.data.origin.replace(/^http/, "ws") + ws.data.path;
|
||||||
const backend = new WebSocket(backendUrl);
|
const BackendWebSocket = WebSocket as unknown as new (
|
||||||
|
url: string,
|
||||||
|
options: Bun.WebSocketOptions,
|
||||||
|
) => WebSocket;
|
||||||
|
const backend = new BackendWebSocket(backendUrl, { headers: ws.data.headers });
|
||||||
ws.data.backend = backend;
|
ws.data.backend = backend;
|
||||||
backend.addEventListener("open", () => {
|
backend.addEventListener("open", () => {
|
||||||
for (const m of ws.data.queue) backend.send(m);
|
for (const m of ws.data.queue) backend.send(m);
|
||||||
|
|||||||
@@ -48,6 +48,22 @@ import {
|
|||||||
wrnBrowserArtifactUrlAsync,
|
wrnBrowserArtifactUrlAsync,
|
||||||
} from "./pipeline.ts";
|
} from "./pipeline.ts";
|
||||||
import { createRpcHandler } from "@wrnexus/ssr/rpc";
|
import { createRpcHandler } from "@wrnexus/ssr/rpc";
|
||||||
|
import { createRecycleMonitor } from "./recycle.ts";
|
||||||
|
import { RESTART_EXIT_CODE } from "./restart.ts";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Recycle after this many hot rebuilds. Each retains roughly 0.66 MB that Bun
|
||||||
|
* cannot release, so 300 caps the leak near 200 MB -- far more than a normal
|
||||||
|
* session reaches, and far less than what makes the server crawl. Set
|
||||||
|
* WRNEXUS_DEV_RECYCLE_AFTER to tune it, or to 0 to never recycle.
|
||||||
|
*/
|
||||||
|
const RECYCLE_REBUILD_THRESHOLD = (() => {
|
||||||
|
const configured = Number(process.env.WRNEXUS_DEV_RECYCLE_AFTER);
|
||||||
|
return Number.isFinite(configured) && configured >= 0 ? configured : 300;
|
||||||
|
})();
|
||||||
|
/** Quiet period required first, so a recycle never interrupts a live request. */
|
||||||
|
const RECYCLE_IDLE_MS = 10_000;
|
||||||
|
const RECYCLE_CHECK_MS = 5_000;
|
||||||
import { createHandlers, type WsData } from "./runtime.ts";
|
import { createHandlers, type WsData } from "./runtime.ts";
|
||||||
import { createDevAssetServer } from "./assets.ts";
|
import { createDevAssetServer } from "./assets.ts";
|
||||||
import { pluginAssetsFromContributions } from "./plugin-assets.ts";
|
import { pluginAssetsFromContributions } from "./plugin-assets.ts";
|
||||||
@@ -440,10 +456,9 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
|||||||
config: opts.stylesConfig,
|
config: opts.stylesConfig,
|
||||||
appRoot,
|
appRoot,
|
||||||
publicDir: join(appRoot, "public"),
|
publicDir: join(appRoot, "public"),
|
||||||
sources: [
|
// Component discovery is separate from utility-source discovery.
|
||||||
...componentDirs,
|
// Packages that need Tailwind scanning opt in via styles.source.
|
||||||
...pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
|
sources: pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
|
||||||
],
|
|
||||||
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
|
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
|
||||||
},
|
},
|
||||||
theme,
|
theme,
|
||||||
@@ -589,6 +604,22 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
|||||||
validateCsrf: validateRpcCsrf,
|
validateCsrf: validateRpcCsrf,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Hot rebuilds retain their predecessors (see recycle.ts). Only dev reloads
|
||||||
|
* modules, so only dev needs to recycle.
|
||||||
|
*/
|
||||||
|
const recycle =
|
||||||
|
hmr && mode === "development" && RECYCLE_REBUILD_THRESHOLD > 0
|
||||||
|
? createRecycleMonitor({
|
||||||
|
threshold: RECYCLE_REBUILD_THRESHOLD,
|
||||||
|
idleMs: RECYCLE_IDLE_MS,
|
||||||
|
onRecycle(reason) {
|
||||||
|
console.log(`[wrnexus] ${reason}`);
|
||||||
|
process.exit(RESTART_EXIT_CODE);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
const server = Bun.serve<WsData>({
|
const server = Bun.serve<WsData>({
|
||||||
port,
|
port,
|
||||||
hostname,
|
hostname,
|
||||||
@@ -596,12 +627,19 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
|||||||
maxRequestBodySize: 10 * 1024 * 1024,
|
maxRequestBodySize: 10 * 1024 * 1024,
|
||||||
...(opts.tls ? { tls: opts.tls } : {}),
|
...(opts.tls ? { tls: opts.tls } : {}),
|
||||||
fetch(request, server) {
|
fetch(request, server) {
|
||||||
|
recycle?.recordRequest(Date.now());
|
||||||
if (new URL(request.url).pathname === "/__wrnexus/rpc") return rpcHandler(request);
|
if (new URL(request.url).pathname === "/__wrnexus/rpc") return rpcHandler(request);
|
||||||
return handlers.fetch(request, server);
|
return handlers.fetch(request, server);
|
||||||
},
|
},
|
||||||
websocket: handlers.websocket,
|
websocket: handlers.websocket,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (recycle) {
|
||||||
|
// unref so a pending check never keeps the process alive on its own.
|
||||||
|
const timer = setInterval(() => recycle.tick(Date.now()), RECYCLE_CHECK_MS);
|
||||||
|
timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pluginRunner.hook("configureServer", {
|
await pluginRunner.hook("configureServer", {
|
||||||
server,
|
server,
|
||||||
@@ -627,6 +665,7 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
|||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
invalidateModule(isAbsolute(file) ? file : resolve(appDir, file));
|
invalidateModule(isAbsolute(file) ? file : resolve(appDir, file));
|
||||||
|
recycle?.recordRebuild();
|
||||||
}
|
}
|
||||||
if (files.some((file) => file.endsWith(".wrn"))) assets.invalidateCss();
|
if (files.some((file) => file.endsWith(".wrn"))) assets.invalidateCss();
|
||||||
|
|
||||||
|
|||||||
@@ -58,9 +58,28 @@ export function runMiddleware(
|
|||||||
*/
|
*/
|
||||||
const moduleCache = new Map<string, Promise<Record<string, unknown>>>();
|
const moduleCache = new Map<string, Promise<Record<string, unknown>>>();
|
||||||
const moduleVersions = new Map<string, number>();
|
const moduleVersions = new Map<string, number>();
|
||||||
|
/*
|
||||||
|
* Artifact URLs carry a content hash, so a rebuild registers a new key and the
|
||||||
|
* previous one is never requested again -- left alone these grow for the life
|
||||||
|
* of the dev server. Bounded rather than cleared on rebuild because a page
|
||||||
|
* already mid-load may still ask for the URL it was served.
|
||||||
|
*/
|
||||||
|
const ARTIFACT_PATH_LIMIT = 512;
|
||||||
const browserArtifactPaths = new Map<string, string>();
|
const browserArtifactPaths = new Map<string, string>();
|
||||||
const islandArtifactPaths = new Map<string, string>();
|
const islandArtifactPaths = new Map<string, string>();
|
||||||
|
|
||||||
|
function rememberArtifact(paths: Map<string, string>, pathname: string, artifact: string): void {
|
||||||
|
// Re-insert so a key still in use is treated as recent.
|
||||||
|
paths.delete(pathname);
|
||||||
|
paths.set(pathname, artifact);
|
||||||
|
|
||||||
|
while (paths.size > ARTIFACT_PATH_LIMIT) {
|
||||||
|
const oldest = paths.keys().next();
|
||||||
|
if (oldest.done) break;
|
||||||
|
paths.delete(oldest.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type ImportMode = "legacy" | "compatible" | "explicit";
|
type ImportMode = "legacy" | "compatible" | "explicit";
|
||||||
interface CompileImportOptions {
|
interface CompileImportOptions {
|
||||||
mode: ImportMode;
|
mode: ImportMode;
|
||||||
@@ -622,7 +641,7 @@ export function compileWrnArtifactsAsync(file: string, version = 0): Promise<Wrn
|
|||||||
);
|
);
|
||||||
writeFileSync(artifacts.contract, JSON.stringify(targets.contract, null, 2) + "\n", "utf8");
|
writeFileSync(artifacts.contract, JSON.stringify(targets.contract, null, 2) + "\n", "utf8");
|
||||||
writeFileSync(artifacts.rpc, JSON.stringify(targets.rpc, null, 2) + "\n", "utf8");
|
writeFileSync(artifacts.rpc, JSON.stringify(targets.rpc, null, 2) + "\n", "utf8");
|
||||||
browserArtifactPaths.set(browserPath, artifacts.browser);
|
rememberArtifact(browserArtifactPaths, browserPath, artifacts.browser);
|
||||||
compileMetrics.compilations++;
|
compileMetrics.compilations++;
|
||||||
return artifacts;
|
return artifacts;
|
||||||
})().finally(() => asyncCompileInProgress.delete(key));
|
})().finally(() => asyncCompileInProgress.delete(key));
|
||||||
@@ -673,7 +692,7 @@ export function compileWrnArtifacts(file: string, version = 0): WrnCompileArtifa
|
|||||||
.map(([, path]) => path);
|
.map(([, path]) => path);
|
||||||
if (requiredArtifacts.every((path) => statSync(path).isFile())) {
|
if (requiredArtifacts.every((path) => statSync(path).isFile())) {
|
||||||
compileMetrics.hits++;
|
compileMetrics.hits++;
|
||||||
browserArtifactPaths.set(`/__wrnexus/client/${stem}.mjs`, artifacts.browser);
|
rememberArtifact(browserArtifactPaths, `/__wrnexus/client/${stem}.mjs`, artifacts.browser);
|
||||||
// A cached .wrn still needs its island bundles: the .tsx may have changed
|
// A cached .wrn still needs its island bundles: the .tsx may have changed
|
||||||
// since, and after a restart with a warm cache nothing else would build them.
|
// since, and after a restart with a warm cache nothing else would build them.
|
||||||
let cachedIslands: Array<{ name: string; sourcePath: string }> = [];
|
let cachedIslands: Array<{ name: string; sourcePath: string }> = [];
|
||||||
@@ -715,7 +734,7 @@ export function compileWrnArtifacts(file: string, version = 0): WrnCompileArtifa
|
|||||||
rewriteArtifactImports(targets.browser, result.ast, file, "browser"),
|
rewriteArtifactImports(targets.browser, result.ast, file, "browser"),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
browserArtifactPaths.set(browserPath, artifacts.browser);
|
rememberArtifact(browserArtifactPaths, browserPath, artifacts.browser);
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
artifacts.server,
|
artifacts.server,
|
||||||
rewriteArtifactImports(targets.server, result.ast, file, "server"),
|
rewriteArtifactImports(targets.server, result.ast, file, "server"),
|
||||||
@@ -775,7 +794,7 @@ export function serveWrnBrowserArtifact(pathname: string): Response | null {
|
|||||||
|
|
||||||
/** Registers a built island asset for serving under `/__wrnexus/island/`. */
|
/** Registers a built island asset for serving under `/__wrnexus/island/`. */
|
||||||
export function registerIslandArtifact(pathname: string, artifact: string): void {
|
export function registerIslandArtifact(pathname: string, artifact: string): void {
|
||||||
islandArtifactPaths.set(pathname, artifact);
|
rememberArtifact(islandArtifactPaths, pathname, artifact);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Serves a built island bundle, chunk, or the island mount runtime. */
|
/** Serves a built island bundle, chunk, or the island mount runtime. */
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Recycle the dev server once hot rebuilds have piled up.
|
||||||
|
*
|
||||||
|
* Every rebuild of a `.wrn` file has to be given a new module identity,
|
||||||
|
* because Bun caches modules by path and would otherwise serve the old one.
|
||||||
|
* Bun has no API to unload a module, so each rebuild retains its predecessor
|
||||||
|
* for the life of the process -- measured at roughly 0.66 MB per rebuild,
|
||||||
|
* while edits that mint no new module (CSS) cost nothing. Over a long session
|
||||||
|
* that is the difference between a fast dev server and a stuck one.
|
||||||
|
*
|
||||||
|
* The process therefore recycles itself: the child exits with
|
||||||
|
* RESTART_EXIT_CODE and the CLI supervisor respawns it. Browsers reconnect on
|
||||||
|
* their own because the HMR client already retries.
|
||||||
|
*
|
||||||
|
* Recycling is deferred until the server has been idle for a moment, so it
|
||||||
|
* never interrupts a request in flight. The cost is that in-memory state
|
||||||
|
* (realtime rooms, warmed caches) resets at that point, which is why the
|
||||||
|
* threshold is high enough that an ordinary editing session never reaches it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RecycleMonitorOptions {
|
||||||
|
/** Retained rebuilds tolerated before a recycle is armed. */
|
||||||
|
threshold: number;
|
||||||
|
/** Quiet period required before recycling, in milliseconds. */
|
||||||
|
idleMs: number;
|
||||||
|
onRecycle: (reason: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecycleMonitor {
|
||||||
|
/** Count one rebuild that retained a module version. */
|
||||||
|
recordRebuild(): void;
|
||||||
|
/** Note that a request was served, at `now`. */
|
||||||
|
recordRequest(now: number): void;
|
||||||
|
/** Recycle if the threshold is passed and the server has gone quiet. */
|
||||||
|
tick(now: number): void;
|
||||||
|
retained(): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRecycleMonitor(options: RecycleMonitorOptions): RecycleMonitor {
|
||||||
|
let rebuilds = 0;
|
||||||
|
let lastRequestAt: number | null = null;
|
||||||
|
let recycled = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
recordRebuild() {
|
||||||
|
rebuilds++;
|
||||||
|
},
|
||||||
|
|
||||||
|
recordRequest(now: number) {
|
||||||
|
lastRequestAt = now;
|
||||||
|
},
|
||||||
|
|
||||||
|
tick(now: number) {
|
||||||
|
if (recycled) return;
|
||||||
|
if (rebuilds < options.threshold) return;
|
||||||
|
// A server that has served nothing is idle by definition.
|
||||||
|
if (lastRequestAt !== null && now - lastRequestAt < options.idleMs) return;
|
||||||
|
|
||||||
|
recycled = true;
|
||||||
|
options.onRecycle(
|
||||||
|
`${rebuilds} hot rebuilds retained; restarting to release the memory they hold`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
retained() {
|
||||||
|
return rebuilds;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -81,7 +81,7 @@ import {
|
|||||||
type TenancyConfig,
|
type TenancyConfig,
|
||||||
} from "@wrnexus/styles";
|
} from "@wrnexus/styles";
|
||||||
import {
|
import {
|
||||||
renderI18nData,
|
renderI18nDataTag,
|
||||||
makeT,
|
makeT,
|
||||||
resolveLang,
|
resolveLang,
|
||||||
translateHtml,
|
translateHtml,
|
||||||
@@ -672,22 +672,16 @@ export const HMR_CLIENT_JS = `
|
|||||||
pendingSync = false;
|
pendingSync = false;
|
||||||
var doc = new DOMParser().parseFromString(html, "text/html");
|
var doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
|
||||||
var i18nScript = Array.prototype.find.call(
|
var i18nScript = doc.querySelector('script[type="application/json"][data-wrn-i18n]');
|
||||||
doc.querySelectorAll("script:not([src])"),
|
|
||||||
function (node) { return /^window[.]__wrnI18n=/.test(String(node.textContent || "").trim()); },
|
|
||||||
);
|
|
||||||
if (i18nScript) {
|
if (i18nScript) {
|
||||||
var i18nMatch = /^window[.]__wrnI18n=([^]*);\\s*$/.exec(String(i18nScript.textContent || "").trim());
|
try {
|
||||||
if (i18nMatch) {
|
var incomingI18n = JSON.parse(String(i18nScript.textContent || "{}"));
|
||||||
try {
|
var existingI18n = window.__wrnI18n || {};
|
||||||
var incomingI18n = JSON.parse(i18nMatch[1]);
|
incomingI18n.t = existingI18n.t;
|
||||||
var existingI18n = window.__wrnI18n || {};
|
incomingI18n.set = existingI18n.set;
|
||||||
incomingI18n.t = existingI18n.t;
|
window.__wrnI18n = incomingI18n;
|
||||||
incomingI18n.set = existingI18n.set;
|
} catch (error) {
|
||||||
window.__wrnI18n = incomingI18n;
|
console.error("[wrnexus] failed to synchronize i18n HMR data", error);
|
||||||
} catch (error) {
|
|
||||||
console.error("[wrnexus] failed to synchronize i18n HMR data", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -878,8 +872,19 @@ function randomNonce(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The dev HMR client as a nonce-tagged inline script (strict-CSP friendly). */
|
/** The dev HMR client as a nonce-tagged inline script (strict-CSP friendly). */
|
||||||
function hmrClientTag(nonce: string): string {
|
/** Path the dev asset server publishes the HMR client on. */
|
||||||
return `<script nonce="${nonce}">${HMR_CLIENT_JS}</script>`;
|
export const HMR_CLIENT_HREF = "/__wrnexus/hmr-client.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HMR client is served as an external module rather than inlined.
|
||||||
|
*
|
||||||
|
* A document's CSP nonce is fixed at load, so an inline script arriving from a
|
||||||
|
* later response — which is exactly what an HMR reload produces — can never
|
||||||
|
* carry a nonce this document accepts. An external file is covered by
|
||||||
|
* script-src 'self' and needs no nonce at all.
|
||||||
|
*/
|
||||||
|
function hmrClientTag(_nonce: string): string {
|
||||||
|
return `<script src="${HMR_CLIENT_HREF}"></script>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 403 for a rejected cross-site WebSocket handshake. */
|
/** 403 for a rejected cross-site WebSocket handshake. */
|
||||||
@@ -1967,9 +1972,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
|||||||
extraBody:
|
extraBody:
|
||||||
[
|
[
|
||||||
renderStoreHydration(storeContainer, (ctx.locals.cspNonce as string) ?? undefined),
|
renderStoreHydration(storeContainer, (ctx.locals.cspNonce as string) ?? undefined),
|
||||||
deps.i18n
|
deps.i18n ? renderI18nDataTag(deps.i18n, language) : "",
|
||||||
? `<script${ctx.locals.cspNonce ? ` nonce="${String(ctx.locals.cspNonce)}"` : ""}>${renderI18nData(deps.i18n, language)}</script>`
|
|
||||||
: "",
|
|
||||||
hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "",
|
hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "",
|
||||||
shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "",
|
shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { gatewayWebSocketOriginAllowed } from "../src/gateway.ts";
|
||||||
|
|
||||||
|
const target = {
|
||||||
|
name: "web",
|
||||||
|
origin: "http://127.0.0.1:3101",
|
||||||
|
domains: ["localhost", "web.localhost"],
|
||||||
|
publicOrigin: "http://localhost:3000",
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
function upgrade(origin: string, host: string): Request {
|
||||||
|
return new Request("http://" + host + "/__wrnexus/hmr", {
|
||||||
|
headers: { origin, host, upgrade: "websocket" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("allows an upgrade from the app's primary domain", () => {
|
||||||
|
expect(
|
||||||
|
gatewayWebSocketOriginAllowed(upgrade("http://localhost:3000", "localhost:3000"), target, []),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows an upgrade from a secondary domain on a non-default port", () => {
|
||||||
|
// publicOrigin is built from domains[0], so a browser on web.localhost falls
|
||||||
|
// through to the domain list — where the origin host still carries :3000 and
|
||||||
|
// the configured domain does not. That mismatch denied every HMR socket on
|
||||||
|
// any domain but the first, leaving the client reconnecting forever.
|
||||||
|
expect(
|
||||||
|
gatewayWebSocketOriginAllowed(
|
||||||
|
upgrade("http://web.localhost:3000", "web.localhost:3000"),
|
||||||
|
target,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still denies an unrelated origin", () => {
|
||||||
|
expect(
|
||||||
|
gatewayWebSocketOriginAllowed(
|
||||||
|
upgrade("http://evil.example:3000", "web.localhost:3000"),
|
||||||
|
target,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still denies a lookalike suffix domain", () => {
|
||||||
|
expect(
|
||||||
|
gatewayWebSocketOriginAllowed(
|
||||||
|
upgrade("http://notweb.localhost:3000", "web.localhost:3000"),
|
||||||
|
target,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
defaultGatewayHostname,
|
defaultGatewayHostname,
|
||||||
forwardAuthFailure,
|
forwardAuthFailure,
|
||||||
forwardAuthHeaders,
|
forwardAuthHeaders,
|
||||||
|
gatewayBrowserRpcHeaders,
|
||||||
gatewayProxyHeaders,
|
gatewayProxyHeaders,
|
||||||
|
gatewayWebSocketBackendHeaders,
|
||||||
stripUntrustedInternalHeaders,
|
stripUntrustedInternalHeaders,
|
||||||
gatewayRestartDelay,
|
gatewayRestartDelay,
|
||||||
internalError,
|
internalError,
|
||||||
@@ -37,6 +39,35 @@ test("gateway disables compression for its internal proxy hop", () => {
|
|||||||
expect(headers.get("x-forwarded-for")).toBe("127.0.0.1");
|
expect(headers.get("x-forwarded-for")).toBe("127.0.0.1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("gateway WebSocket bridge forwards validated application identity", () => {
|
||||||
|
const request = new Request("http://web.localhost:3000/__wrnexus/hmr", {
|
||||||
|
headers: {
|
||||||
|
host: "web.localhost:3000",
|
||||||
|
origin: "http://web.localhost:3000",
|
||||||
|
cookie: "session=abc",
|
||||||
|
connection: "Upgrade",
|
||||||
|
upgrade: "websocket",
|
||||||
|
"sec-websocket-key": "test-key",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const headers = gatewayWebSocketBackendHeaders(
|
||||||
|
request,
|
||||||
|
new URL(request.url),
|
||||||
|
"127.0.0.1",
|
||||||
|
true,
|
||||||
|
"http://127.0.0.1:3001",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(headers.origin).toBe("http://127.0.0.1:3001");
|
||||||
|
expect(headers.cookie).toBe("session=abc");
|
||||||
|
expect(headers["x-forwarded-host"]).toBe("web.localhost:3000");
|
||||||
|
expect(headers["x-forwarded-proto"]).toBe("http");
|
||||||
|
expect(headers.host).toBeUndefined();
|
||||||
|
expect(headers.connection).toBeUndefined();
|
||||||
|
expect(headers.upgrade).toBeUndefined();
|
||||||
|
expect(headers["sec-websocket-key"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
test("gateway proxy headers do not preserve the RPC internal marker", () => {
|
test("gateway proxy headers do not preserve the RPC internal marker", () => {
|
||||||
const request = new Request("http://localhost:3000/path", {
|
const request = new Request("http://localhost:3000/path", {
|
||||||
headers: { "x-wrnexus-internal": "1" },
|
headers: { "x-wrnexus-internal": "1" },
|
||||||
@@ -146,13 +177,40 @@ test("nested SSO proxy keeps the protected app's original request headers", () =
|
|||||||
expect(proxied.get("x-original-uri")).toBe("/settings");
|
expect(proxied.get("x-original-uri")).toBe("/settings");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the reserved RPC prefix is refused at the gateway before any proxying", () => {
|
test("the gateway proxies browser server functions but refuses private RPC routes", () => {
|
||||||
expect(isRpcGatewayPath(RPC_PATH_PREFIX)).toBe(true);
|
expect(isRpcGatewayPath(RPC_PATH_PREFIX)).toBe(false);
|
||||||
expect(isRpcGatewayPath(`${RPC_PATH_PREFIX}/billing/createInvoice`)).toBe(true);
|
expect(isRpcGatewayPath(`${RPC_PATH_PREFIX}/billing/createInvoice`)).toBe(true);
|
||||||
expect(isRpcGatewayPath("/api/billing")).toBe(false);
|
expect(isRpcGatewayPath("/api/billing")).toBe(false);
|
||||||
expect(isRpcGatewayPath("/__wrnexus/rpcfoo")).toBe(false);
|
expect(isRpcGatewayPath("/__wrnexus/rpcfoo")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("browser RPC proxy preserves CSRF credentials and trusts only the internal hop", () => {
|
||||||
|
const request = new Request(`http://web.localhost:3000${RPC_PATH_PREFIX}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
host: "web.localhost:3000",
|
||||||
|
origin: "http://web.localhost:3000",
|
||||||
|
cookie: "wrn-csrf=token",
|
||||||
|
"x-csrf-token": "token",
|
||||||
|
[RPC_INTERNAL_HEADER]: "forged",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const headers = gatewayBrowserRpcHeaders(
|
||||||
|
request,
|
||||||
|
new URL(request.url),
|
||||||
|
"127.0.0.1",
|
||||||
|
true,
|
||||||
|
"http://127.0.0.1:3001",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(headers.get("origin")).toBe("http://127.0.0.1:3001");
|
||||||
|
expect(headers.get("cookie")).toBe("wrn-csrf=token");
|
||||||
|
expect(headers.get("x-csrf-token")).toBe("token");
|
||||||
|
expect(headers.get("x-forwarded-host")).toBe("web.localhost:3000");
|
||||||
|
expect(headers.has(RPC_INTERNAL_HEADER)).toBe(false);
|
||||||
|
expect(headers.has("host")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("an inbound internal-marker header from outside is stripped regardless of casing", () => {
|
test("an inbound internal-marker header from outside is stripped regardless of casing", () => {
|
||||||
for (const name of [
|
for (const name of [
|
||||||
RPC_INTERNAL_HEADER,
|
RPC_INTERNAL_HEADER,
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { createRecycleMonitor } from "../src/recycle.ts";
|
||||||
|
|
||||||
|
/** Fresh monitor with a small threshold so tests stay readable. */
|
||||||
|
function monitor(overrides: Partial<Parameters<typeof createRecycleMonitor>[0]> = {}) {
|
||||||
|
const recycled: string[] = [];
|
||||||
|
const control = createRecycleMonitor({
|
||||||
|
threshold: 3,
|
||||||
|
idleMs: 1000,
|
||||||
|
onRecycle: (reason) => recycled.push(reason),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
return { control, recycled };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("stays quiet below the rebuild threshold", () => {
|
||||||
|
const { control, recycled } = monitor();
|
||||||
|
|
||||||
|
control.recordRebuild();
|
||||||
|
control.recordRebuild();
|
||||||
|
control.tick(10_000);
|
||||||
|
|
||||||
|
expect(recycled).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recycles once rebuilds pass the threshold and the server goes idle", () => {
|
||||||
|
const { control, recycled } = monitor();
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) control.recordRebuild();
|
||||||
|
control.recordRequest(0);
|
||||||
|
control.tick(1_500);
|
||||||
|
|
||||||
|
expect(recycled.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("waits for the idle gap rather than cutting off active work", () => {
|
||||||
|
// Recycling mid-request would drop it. The gap is the whole point.
|
||||||
|
const { control, recycled } = monitor();
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) control.recordRebuild();
|
||||||
|
control.recordRequest(0);
|
||||||
|
control.tick(500);
|
||||||
|
expect(recycled).toEqual([]);
|
||||||
|
|
||||||
|
control.recordRequest(900);
|
||||||
|
control.tick(1_400);
|
||||||
|
expect(recycled).toEqual([]);
|
||||||
|
|
||||||
|
control.tick(2_000);
|
||||||
|
expect(recycled.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recycles only once even if it keeps being ticked", () => {
|
||||||
|
const { control, recycled } = monitor();
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) control.recordRebuild();
|
||||||
|
control.recordRequest(0);
|
||||||
|
control.tick(5_000);
|
||||||
|
control.tick(6_000);
|
||||||
|
control.tick(7_000);
|
||||||
|
|
||||||
|
expect(recycled.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a server that never served a request can still recycle", () => {
|
||||||
|
const { control, recycled } = monitor();
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) control.recordRebuild();
|
||||||
|
control.tick(9_999);
|
||||||
|
|
||||||
|
expect(recycled.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports how many rebuilds are being retained", () => {
|
||||||
|
const { control } = monitor();
|
||||||
|
|
||||||
|
control.recordRebuild();
|
||||||
|
control.recordRebuild();
|
||||||
|
|
||||||
|
expect(control.retained()).toBe(2);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/dev-toolbar",
|
"name": "@wrnexus/dev-toolbar",
|
||||||
"version": "0.8.9",
|
"version": "0.8.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
@@ -20,6 +20,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ export const DEV_TOOLBAR_RUNTIME = String.raw`(() => {
|
|||||||
try{for(let i=0;i<localStorage.length;i++){const key=localStorage.key(i)||"";if(/(?:token|secret|password|session|credential|authorization)/i.test(key))found.push(issue("security/sensitive-local-storage","security","error","Sensitive value may be stored in localStorage","Storage key “"+key+"” looks authentication- or secret-related.",null,"Keep sessions and credentials in Secure, HttpOnly cookies."));}}catch{}
|
try{for(let i=0;i<localStorage.length;i++){const key=localStorage.key(i)||"";if(/(?:token|secret|password|session|credential|authorization)/i.test(key))found.push(issue("security/sensitive-local-storage","security","error","Sensitive value may be stored in localStorage","Storage key “"+key+"” looks authentication- or secret-related.",null,"Keep sessions and credentials in Secure, HttpOnly cookies."));}}catch{}
|
||||||
if(document.documentElement.scrollWidth>innerWidth+2)found.push(issue("responsive/document-overflow","responsive","error","Page has horizontal overflow","Document width exceeds the viewport.",null,"Inspect fixed widths, long text and overflowing media."));
|
if(document.documentElement.scrollWidth>innerWidth+2)found.push(issue("responsive/document-overflow","responsive","error","Page has horizontal overflow","Document width exceeds the viewport.",null,"Inspect fixed widths, long text and overflowing media."));
|
||||||
q("body *").filter(visible).slice(0,2500).forEach(el=>{const r=el.getBoundingClientRect();if((r.right>innerWidth+8||r.left<-8)&&found.filter(x=>x.ruleId==="responsive/element-overflow").length<20)found.push(issue("responsive/element-overflow","responsive","warning","Element extends outside the viewport","Element bounds exceed the current viewport.",el,"Use fluid sizing, wrapping, max-width or an intentional scroll container."));});
|
q("body *").filter(visible).slice(0,2500).forEach(el=>{const r=el.getBoundingClientRect();if((r.right>innerWidth+8||r.left<-8)&&found.filter(x=>x.ruleId==="responsive/element-overflow").length<20)found.push(issue("responsive/element-overflow","responsive","warning","Element extends outside the viewport","Element bounds exceed the current viewport.",el,"Use fluid sizing, wrapping, max-width or an intentional scroll container."));});
|
||||||
const resources=performance.getEntriesByType("resource");const total=resources.reduce((s,e)=>s+(e.transferSize||0),0);const jsBytes=resources.filter(e=>/(?:\.m?js)(?:\?|$)/i.test(e.name)).reduce((sum,e)=>sum+(e.transferSize||0),0);if(resources.length>150)found.push(issue("performance/resource-count","performance","warning","Page loads many resources","Found "+resources.length+" resource requests.",null,"Remove duplicates and defer non-critical resources."));if(total>5000000)found.push(issue("performance/transfer-size","performance",total>10000000?"error":"warning","Page transfer size is large","Observed transfer size is about "+(total/1000000).toFixed(2)+" MB.",null,"Compress and optimize page resources."));if(jsBytes>150000)found.push(issue("performance/javascript-budget","javascript",jsBytes>300000?"error":"warning","JavaScript budget exceeded","JavaScript transfer is about "+(jsBytes/1000).toFixed(1)+" KB.",null,"Split routes and defer optional hydration."));const hydrationRoots=q("[data-wrn-client-module],[data-wrn-hydrate]");if(hydrationRoots.length>50)found.push(issue("performance/hydration-count","runtime","warning","Many components hydrate",hydrationRoots.length+" hydration boundaries were found.",null,"Use visible, idle or interaction hydration."));if(state.runtimeMetrics.longTasks.length)found.push(issue("performance/long-tasks","javascript","warning","Long main-thread tasks detected",state.runtimeMetrics.longTasks.length+" task(s) exceeded 50 ms.",null,"Split expensive work and reduce hydration.","high",{longestMs:Math.max(...state.runtimeMetrics.longTasks)}));
|
const resources=performance.getEntriesByType("resource");const total=resources.reduce((s,e)=>s+(e.transferSize||0),0);const jsResources=resources.filter(e=>/(?:\.m?js)(?:\?|$)/i.test(e.name)&&!/\/__wrnexus\/dev-toolbar\.js(?:\?|$)/.test(e.name));const jsBytes=jsResources.reduce((sum,e)=>sum+(e.transferSize||0),0);if(resources.length>150)found.push(issue("performance/resource-count","performance","warning","Page loads many resources","Found "+resources.length+" resource requests.",null,"Remove duplicates and defer non-critical resources."));if(total>5000000)found.push(issue("performance/transfer-size","performance",total>10000000?"error":"warning","Page transfer size is large","Observed transfer size is about "+(total/1000000).toFixed(2)+" MB.",null,"Compress and optimize page resources."));if(jsBytes>300000)found.push(issue("performance/javascript-budget","javascript",jsBytes>600000?"error":"warning","Development JavaScript is large","Application JavaScript transfer is about "+(jsBytes/1000).toFixed(1)+" KB in development.",null,"Check the production build report before splitting routes; development modules are unminified.","medium",{javascriptBytes:jsBytes,mode:"development"}));const hydrationRoots=q("[data-wrn-client-module],[data-wrn-hydrate]");if(hydrationRoots.length>50)found.push(issue("performance/hydration-count","runtime","warning","Many components hydrate",hydrationRoots.length+" hydration boundaries were found.",null,"Use visible, idle or interaction hydration."));if(state.runtimeMetrics.longTasks.length)found.push(issue("performance/long-tasks","javascript","warning","Long main-thread tasks detected",state.runtimeMetrics.longTasks.length+" task(s) exceeded 50 ms.",null,"Split expensive work and reduce hydration.","high",{longestMs:Math.max(...state.runtimeMetrics.longTasks)}));
|
||||||
let checkedSelectors=0,unusedSelectors=0;for(const sheet of [...document.styleSheets]){let rules;try{rules=[...(sheet.cssRules||[])]}catch{continue}for(const rule of rules){if(checkedSelectors>=2000)break;const selector=rule.selectorText;if(!selector||selector.includes(":"))continue;checkedSelectors++;try{if(!document.querySelector(selector))unusedSelectors++}catch{}}}if(unusedSelectors)found.push(issue("css/unused-selectors","css","suggestion","Potentially unused CSS",unusedSelectors+" of "+checkedSelectors+" inspected selectors do not match this page.",null,"Review across routes before removing selectors.","medium",{checkedSelectors,unusedSelectors}));const memory=performance.memory;if(memory&&memory.jsHeapSizeLimit&&memory.usedJSHeapSize/memory.jsHeapSizeLimit>.8)found.push(issue("performance/memory-pressure","performance","warning","High JavaScript heap usage",Math.round(memory.usedJSHeapSize/1048576)+" MiB of "+Math.round(memory.jsHeapSizeLimit/1048576)+" MiB is in use.",null,"Inspect retained objects and repeated hydration."));
|
let checkedSelectors=0,unusedSelectors=0;for(const sheet of [...document.styleSheets]){const href=sheet.href||"";if(/\/__wrnexus\/(?:ui|framework)\.css(?:\?|$)|\/__wrnexus\/theme\/[^/?]+\.css(?:\?|$)/.test(href))continue;let rules;try{rules=[...(sheet.cssRules||[])]}catch{continue}for(const rule of rules){if(checkedSelectors>=2000)break;const selector=rule.selectorText;if(!selector||selector.includes(":"))continue;checkedSelectors++;try{if(!document.querySelector(selector))unusedSelectors++}catch{}}}const unusedRatio=checkedSelectors?unusedSelectors/checkedSelectors:0;if(checkedSelectors>=20&&unusedRatio>=.8)found.push(issue("css/unused-selectors","css","suggestion","Low current-page CSS coverage",unusedSelectors+" of "+checkedSelectors+" inspected application selectors do not match this page.",null,"Review across routes before removing selectors. WRNexus UI and theme styles are excluded.","medium",{checkedSelectors,unusedSelectors,unusedRatio}));const memory=performance.memory;if(memory&&memory.jsHeapSizeLimit&&memory.usedJSHeapSize/memory.jsHeapSizeLimit>.8)found.push(issue("performance/memory-pressure","performance","warning","High JavaScript heap usage",Math.round(memory.usedJSHeapSize/1048576)+" MiB of "+Math.round(memory.jsHeapSizeLimit/1048576)+" MiB is in use.",null,"Inspect retained objects and repeated hydration."));
|
||||||
q("[data-wrn-client-module]").forEach(el=>found.push(issue("runtime/client-module","runtime","info","Client function module",el.getAttribute("data-wrn-client-module")||"Unknown module",el,"Loaded according to the component hydration strategy.","high",{hydration:el.getAttribute("data-wrn-hydrate"),runtime:el.getAttribute("data-wrn-runtime")})));
|
q("[data-wrn-client-module]").forEach(el=>found.push(issue("runtime/client-module","runtime","info","Client function module",el.getAttribute("data-wrn-client-module")||"Unknown module",el,"Loaded according to the component hydration strategy.","high",{hydration:el.getAttribute("data-wrn-hydrate"),runtime:el.getAttribute("data-wrn-runtime")})));
|
||||||
const storeContainer=window.__wrnexusStoreContainer;
|
const storeContainer=window.__wrnexusStoreContainer;
|
||||||
if(storeContainer&&typeof storeContainer.inspect==="function"){
|
if(storeContainer&&typeof storeContainer.inspect==="function"){
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ test("exports usable development assets", () => {
|
|||||||
expect(DEV_TOOLBAR_RUNTIME).toContain('data-category="accessibility"');
|
expect(DEV_TOOLBAR_RUNTIME).toContain('data-category="accessibility"');
|
||||||
expect(DEV_TOOLBAR_RUNTIME).toContain('issue("plugin/"+app.id');
|
expect(DEV_TOOLBAR_RUNTIME).toContain('issue("plugin/"+app.id');
|
||||||
expect(DEV_TOOLBAR_RUNTIME).toContain("apps.appendChild(button)");
|
expect(DEV_TOOLBAR_RUNTIME).toContain("apps.appendChild(button)");
|
||||||
|
expect(DEV_TOOLBAR_RUNTIME).toContain("Low current-page CSS coverage");
|
||||||
|
expect(DEV_TOOLBAR_RUNTIME).toContain("WRNexus UI and theme styles are excluded");
|
||||||
|
expect(DEV_TOOLBAR_RUNTIME).toContain("checkedSelectors>=20&&unusedRatio>=.8");
|
||||||
|
expect(DEV_TOOLBAR_RUNTIME).toContain("Development JavaScript is large");
|
||||||
|
expect(DEV_TOOLBAR_RUNTIME).toContain("dev-toolbar\\.js");
|
||||||
|
expect(DEV_TOOLBAR_RUNTIME).toContain("jsBytes>300000");
|
||||||
expect(DEV_TOOLBAR_CSS).toContain(".wrn-panel");
|
expect(DEV_TOOLBAR_CSS).toContain(".wrn-panel");
|
||||||
expect(DEV_TOOLBAR_CSS).toContain(".wrn-plugin-panel");
|
expect(DEV_TOOLBAR_CSS).toContain(".wrn-plugin-panel");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/encryption",
|
"name": "@wrnexus/encryption",
|
||||||
"version": "0.8.8",
|
"version": "0.8.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*"
|
"@wrnexus/core": "workspace:*"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/i18n",
|
"name": "@wrnexus/i18n",
|
||||||
"version": "0.8.10",
|
"version": "0.8.12",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^6.0.3",
|
||||||
"@wrnexus/syntax": "workspace:*"
|
"@wrnexus/syntax": "workspace:*"
|
||||||
},
|
},
|
||||||
"wrnexus": {
|
"wrnexus": {
|
||||||
|
|||||||
@@ -425,9 +425,20 @@ function safeJson(value: unknown): string {
|
|||||||
.replace(/\u2029/g, "\\u2029");
|
.replace(/\u2029/g, "\\u2029");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Attribute marking the JSON block that carries per-request i18n data. */
|
||||||
|
export const I18N_DATA_ATTRIBUTE = "data-wrn-i18n";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The i18n payload, emitted as JSON rather than as an assignment.
|
||||||
|
*
|
||||||
|
* It ships inside a `type="application/json"` block, which the browser never
|
||||||
|
* executes, so `script-src` does not apply to it. As an inline executable
|
||||||
|
* script it was blocked whenever the surrounding document's CSP nonce came
|
||||||
|
* from a different response, leaving window.__wrnI18n undefined.
|
||||||
|
*/
|
||||||
export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
|
export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
|
||||||
const active = i18n.langs.includes(lang) ? lang : i18n.default;
|
const active = i18n.langs.includes(lang) ? lang : i18n.default;
|
||||||
return `window.__wrnI18n=${safeJson({
|
return `${safeJson({
|
||||||
lang: active,
|
lang: active,
|
||||||
langs: i18n.langs,
|
langs: i18n.langs,
|
||||||
default: i18n.default,
|
default: i18n.default,
|
||||||
@@ -437,7 +448,12 @@ export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
|
|||||||
directions: i18n.direction,
|
directions: i18n.direction,
|
||||||
labels: i18n.labels,
|
labels: i18n.labels,
|
||||||
cookie: i18n.cookie,
|
cookie: i18n.cookie,
|
||||||
})};`;
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full JSON block, including its script tag. */
|
||||||
|
export function renderI18nDataTag(i18n: ResolvedI18n, lang: string): string {
|
||||||
|
return `<script type="application/json" ${I18N_DATA_ATTRIBUTE}>${renderI18nData(i18n, lang)}</script>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const I18N_RUNTIME = String.raw`
|
export const I18N_RUNTIME = String.raw`
|
||||||
@@ -457,7 +473,23 @@ export const I18N_RUNTIME = String.raw`
|
|||||||
return params && Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : "{" + name + "}";
|
return params && Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : "{" + name + "}";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function state() { return window.__wrnI18n || {}; }
|
function readDataBlock() {
|
||||||
|
var node = document.querySelector('script[type="application/json"][data-wrn-i18n]');
|
||||||
|
if (!node) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(node.textContent || "{}");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[wrnexus] i18n data block was not valid JSON", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function state() {
|
||||||
|
if (!window.__wrnI18n) {
|
||||||
|
var data = readDataBlock();
|
||||||
|
if (data) window.__wrnI18n = data;
|
||||||
|
}
|
||||||
|
return window.__wrnI18n || {};
|
||||||
|
}
|
||||||
function t(key, params) {
|
function t(key, params) {
|
||||||
var current = state();
|
var current = state();
|
||||||
return interpolate(lookup(current.messages, key) || lookup(current.fallbackMessages, key) || key, params);
|
return interpolate(lookup(current.messages, key) || lookup(current.fallbackMessages, key) || key, params);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { I18N_RUNTIME, renderI18nData, renderI18nDataTag, resolveI18n } from "../src/index.ts";
|
||||||
|
|
||||||
|
const i18n = resolveI18n({ en: { hello: "Hello" }, es: { hello: "Hola" } }, { default: "en" });
|
||||||
|
|
||||||
|
test("the i18n payload is plain JSON, not an assignment", () => {
|
||||||
|
const data = renderI18nData(i18n, "en");
|
||||||
|
expect(() => JSON.parse(data)).not.toThrow();
|
||||||
|
expect(data).not.toContain("window.__wrnI18n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the data tag is a non-executable JSON block", () => {
|
||||||
|
// An executable inline script is subject to script-src and gets blocked
|
||||||
|
// whenever the document's CSP nonce came from a different response, which is
|
||||||
|
// what left window.__wrnI18n undefined. A JSON block is never executed.
|
||||||
|
const tag = renderI18nDataTag(i18n, "es");
|
||||||
|
expect(tag).toContain('type="application/json"');
|
||||||
|
expect(tag).toContain("data-wrn-i18n");
|
||||||
|
expect(tag).not.toContain("nonce=");
|
||||||
|
expect(tag).toContain("Hola");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the i18n runtime reads the data block instead of relying on an inline assignment", () => {
|
||||||
|
expect(I18N_RUNTIME).toContain('script[type="application/json"][data-wrn-i18n]');
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/image",
|
"name": "@wrnexus/image",
|
||||||
"version": "0.8.9",
|
"version": "0.8.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Responsive image planning, secure remote image policies, and performance auditing for WRNexusJS.",
|
"description": "Responsive image planning, secure remote image policies, and performance auditing for WRNexusJS.",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^6.0.3",
|
||||||
"@wrnexus/syntax": "workspace:*"
|
"@wrnexus/syntax": "workspace:*"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/jwt",
|
"name": "@wrnexus/jwt",
|
||||||
"version": "0.8.8",
|
"version": "0.8.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*"
|
"@wrnexus/core": "workspace:*"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/language-server",
|
"name": "@wrnexus/language-server",
|
||||||
"version": "0.8.9",
|
"version": "0.8.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Editor-neutral Language Server Protocol implementation for WRNexus .wrn files.",
|
"description": "Editor-neutral Language Server Protocol implementation for WRNexus .wrn files.",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/syntax": "workspace:*",
|
"@wrnexus/syntax": "workspace:*",
|
||||||
"@wrnexus/typecheck": "workspace:*"
|
"@wrnexus/typecheck": "workspace:*",
|
||||||
|
"vscode-html-languageservice": "^5.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { TextDocument } from "./index.ts";
|
||||||
|
|
||||||
|
export interface HtmlRegion {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Byte ranges of the markup inside each `view { }` block.
|
||||||
|
*
|
||||||
|
* This is a tolerant scanner rather than the parser: completion fires while
|
||||||
|
* the document is being typed, which is exactly when it does not parse.
|
||||||
|
*/
|
||||||
|
export function viewRegions(text: string): HtmlRegion[] {
|
||||||
|
const regions: HtmlRegion[] = [];
|
||||||
|
const pattern = /\bview\s*\{/g;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = pattern.exec(text))) {
|
||||||
|
const bodyStart = match.index + match[0].length;
|
||||||
|
const end = matchingBrace(text, bodyStart);
|
||||||
|
regions.push({ start: bodyStart, end });
|
||||||
|
pattern.lastIndex = end;
|
||||||
|
}
|
||||||
|
return regions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offset of the brace closing the block that starts at `from`, or the end of
|
||||||
|
* the text when it is never closed (an unterminated block is normal mid-edit).
|
||||||
|
*
|
||||||
|
* Quotes are only tracked inside a tag, never in text content: `<p>it's</p>`
|
||||||
|
* would otherwise open a string that never closes and swallow the rest of the
|
||||||
|
* file.
|
||||||
|
*/
|
||||||
|
function matchingBrace(text: string, from: number): number {
|
||||||
|
let depth = 1;
|
||||||
|
let inTag = false;
|
||||||
|
let quote: string | null = null;
|
||||||
|
|
||||||
|
for (let index = from; index < text.length; index += 1) {
|
||||||
|
const char = text[index]!;
|
||||||
|
|
||||||
|
if (quote) {
|
||||||
|
if (char === quote) quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inTag && (char === '"' || char === "'")) {
|
||||||
|
quote = char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "<") inTag = true;
|
||||||
|
else if (char === ">") inTag = false;
|
||||||
|
else if (char === "{") depth += 1;
|
||||||
|
else if (char === "}") {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A parallel document containing only the markup.
|
||||||
|
*
|
||||||
|
* Everything outside a view block becomes whitespace of the same length, and
|
||||||
|
* newlines are preserved, so an offset in the source is the same offset here.
|
||||||
|
* That removes the need for a mapping table entirely.
|
||||||
|
*/
|
||||||
|
export function virtualHtmlDocument(document: TextDocument): {
|
||||||
|
uri: string;
|
||||||
|
languageId: "html";
|
||||||
|
text: string;
|
||||||
|
} {
|
||||||
|
const source = document.text;
|
||||||
|
const keep = new Array<boolean>(source.length).fill(false);
|
||||||
|
for (const region of viewRegions(source)) {
|
||||||
|
for (let index = region.start; index < region.end; index += 1) keep[index] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = "";
|
||||||
|
for (let index = 0; index < source.length; index += 1) {
|
||||||
|
const char = source[index]!;
|
||||||
|
text += keep[index] || char === "\n" ? char : char === "\r" ? "\r" : " ";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uri: `${document.uri}.html`, languageId: "html", text };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInsideHtml(document: TextDocument, offset: number): boolean {
|
||||||
|
return regionsFor(document).some((region) => offset >= region.start && offset <= region.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regions for a document, cached by uri and version.
|
||||||
|
*
|
||||||
|
* A single keystroke produces a burst of completion, hover, and tag-close
|
||||||
|
* requests; without this each one rescans the file.
|
||||||
|
*/
|
||||||
|
const regionCache = new Map<string, { version: number; regions: HtmlRegion[] }>();
|
||||||
|
|
||||||
|
function regionsFor(document: TextDocument): HtmlRegion[] {
|
||||||
|
// Documents without a version have no way to signal changes, so bypass cache.
|
||||||
|
if (document.version === undefined) {
|
||||||
|
return viewRegions(document.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = regionCache.get(document.uri);
|
||||||
|
if (cached && cached.version === document.version) return cached.regions;
|
||||||
|
|
||||||
|
const regions = viewRegions(document.text);
|
||||||
|
regionCache.set(document.uri, { version: document.version, regions });
|
||||||
|
return regions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops a document's cached regions. Call when a document closes. */
|
||||||
|
export function clearHtmlRegionCache(uri?: string): void {
|
||||||
|
if (uri) regionCache.delete(uri);
|
||||||
|
else regionCache.clear();
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// The default `main` entrypoint is a UMD bundle whose internal AMD-style
|
||||||
|
// `require("./parser/htmlScanner")` calls survive bundling literally instead
|
||||||
|
// of being inlined, so a bundled language server fails at runtime with
|
||||||
|
// "Cannot find module './parser/htmlScanner'". The ESM entrypoint bundles
|
||||||
|
// cleanly, so import it explicitly.
|
||||||
|
import {
|
||||||
|
getLanguageService,
|
||||||
|
TextDocument as HtmlTextDocument,
|
||||||
|
} from "vscode-html-languageservice/lib/esm/htmlLanguageService.js";
|
||||||
|
import { isInsideHtml, virtualHtmlDocument } from "./html-regions.ts";
|
||||||
|
import { offsetAt, type Position, type TextDocument } from "./index.ts";
|
||||||
|
|
||||||
|
export interface HtmlCompletionItem {
|
||||||
|
label: string;
|
||||||
|
kind: number;
|
||||||
|
detail?: string;
|
||||||
|
documentation?: string;
|
||||||
|
sortText?: string;
|
||||||
|
insertText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = getLanguageService();
|
||||||
|
|
||||||
|
/** The virtual document as the HTML service's own document type. */
|
||||||
|
function htmlDocument(document: TextDocument) {
|
||||||
|
const virtual = virtualHtmlDocument(document);
|
||||||
|
return HtmlTextDocument.create(virtual.uri, "html", document.version ?? 1, virtual.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdown(value: unknown): string | undefined {
|
||||||
|
if (typeof value === "string") return value || undefined;
|
||||||
|
if (value && typeof value === "object" && "value" in value) {
|
||||||
|
return String((value as { value: unknown }).value) || undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML completions for a position inside a view block.
|
||||||
|
*
|
||||||
|
* Every item carries the `1` sortText prefix so the server can rank WRNexus
|
||||||
|
* entries above these without filtering either list.
|
||||||
|
*/
|
||||||
|
export function htmlCompletions(document: TextDocument, position: Position): HtmlCompletionItem[] {
|
||||||
|
if (!isInsideHtml(document, offsetAt(document.text, position))) return [];
|
||||||
|
|
||||||
|
const virtual = htmlDocument(document);
|
||||||
|
const parsed = service.parseHTMLDocument(virtual);
|
||||||
|
const list = service.doComplete(virtual, position, parsed);
|
||||||
|
|
||||||
|
return list.items.map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
kind: typeof item.kind === "number" ? item.kind : 1,
|
||||||
|
detail: item.detail,
|
||||||
|
documentation: markdown(item.documentation),
|
||||||
|
sortText: `1${item.sortText ?? item.label}`,
|
||||||
|
insertText: item.textEdit && "newText" in item.textEdit ? item.textEdit.newText : undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function htmlHover(document: TextDocument, position: Position): { contents: string } | null {
|
||||||
|
if (!isInsideHtml(document, offsetAt(document.text, position))) return null;
|
||||||
|
|
||||||
|
const virtual = htmlDocument(document);
|
||||||
|
const result = service.doHover(virtual, position, service.parseHTMLDocument(virtual));
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
const contents = markdown(result.contents);
|
||||||
|
return contents ? { contents } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function htmlFoldingRanges(
|
||||||
|
document: TextDocument,
|
||||||
|
): Array<{ startLine: number; endLine: number }> {
|
||||||
|
return service
|
||||||
|
.getFoldingRanges(htmlDocument(document))
|
||||||
|
.map((range) => ({ startLine: range.startLine, endLine: range.endLine }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ranges of the opening and closing tag names, so renaming one renames both. */
|
||||||
|
export function htmlLinkedEditingRanges(
|
||||||
|
document: TextDocument,
|
||||||
|
position: Position,
|
||||||
|
): Array<{ start: Position; end: Position }> | null {
|
||||||
|
if (!isInsideHtml(document, offsetAt(document.text, position))) return null;
|
||||||
|
|
||||||
|
const virtual = htmlDocument(document);
|
||||||
|
const ranges = service.findLinkedEditingRanges(
|
||||||
|
virtual,
|
||||||
|
position,
|
||||||
|
service.parseHTMLDocument(virtual),
|
||||||
|
);
|
||||||
|
return ranges && ranges.length ? ranges : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects `<Name attr="x" /` just before `position` and completes the `>`.
|
||||||
|
*
|
||||||
|
* `vscode-html-languageservice`'s own `doTagComplete` only reacts to a typed
|
||||||
|
* `/` when it opens an end tag (`</`); it has no notion of a self-closing
|
||||||
|
* start tag, since plain HTML has no such elements outside its fixed void-element
|
||||||
|
* list. WRNexus components (`<Card />`) are exactly that case, so we complete
|
||||||
|
* it ourselves rather than relying on the library.
|
||||||
|
*
|
||||||
|
* The scan tracks quote state from the tag's opening `<` up to `offset` (quotes
|
||||||
|
* are only meaningful inside a tag) so a `/` inside an attribute value — e.g. the
|
||||||
|
* first slash of `href="https://..."` — never misfires as a self-close: the
|
||||||
|
* library already returns `null` there on purpose, because the cursor sits in an
|
||||||
|
* attribute value, not a tag-close position.
|
||||||
|
*/
|
||||||
|
function selfClosingTagCompletion(text: string, offset: number): string | null {
|
||||||
|
if (text.charAt(offset - 1) !== "/") return null;
|
||||||
|
if (text.charAt(offset) === ">") return null;
|
||||||
|
|
||||||
|
const tagStart = text.lastIndexOf("<", offset - 1);
|
||||||
|
if (tagStart < 0) return null;
|
||||||
|
if (!/^<[A-Za-z][\w-]*/.test(text.slice(tagStart))) return null;
|
||||||
|
|
||||||
|
let quote: '"' | "'" | null = null;
|
||||||
|
for (let i = tagStart + 1; i < offset - 1; i++) {
|
||||||
|
const ch = text[i];
|
||||||
|
if (quote) {
|
||||||
|
if (ch === quote) quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"' || ch === "'") {
|
||||||
|
quote = ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "<" || ch === ">") return null;
|
||||||
|
}
|
||||||
|
if (quote) return null;
|
||||||
|
|
||||||
|
return ">";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The snippet that closes the tag being typed, or null.
|
||||||
|
*
|
||||||
|
* Void elements and already-closed tags return null, which is why this decision
|
||||||
|
* belongs here rather than in the editor client.
|
||||||
|
*/
|
||||||
|
export function htmlTagComplete(document: TextDocument, position: Position): string | null {
|
||||||
|
const offset = offsetAt(document.text, position);
|
||||||
|
if (!isInsideHtml(document, offset)) return null;
|
||||||
|
|
||||||
|
const virtual = htmlDocument(document);
|
||||||
|
const result = service.doTagComplete(virtual, position, service.parseHTMLDocument(virtual));
|
||||||
|
if (result) return result;
|
||||||
|
|
||||||
|
return selfClosingTagCompletion(document.text, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One completion list from both sources.
|
||||||
|
*
|
||||||
|
* WRNexus entries take the `0` sortText prefix so they rank above HTML without
|
||||||
|
* either list being filtered. An exact label collision resolves to the
|
||||||
|
* WRNexus entry: a component named `Table` is what the author meant.
|
||||||
|
*/
|
||||||
|
interface CompletionLike {
|
||||||
|
label: string;
|
||||||
|
kind?: number;
|
||||||
|
sortText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeCompletions(
|
||||||
|
wrnexus: CompletionLike[],
|
||||||
|
html: CompletionLike[],
|
||||||
|
): CompletionLike[] {
|
||||||
|
const taken = new Set(wrnexus.map((item) => item.label));
|
||||||
|
return [
|
||||||
|
...wrnexus.map((item) => ({ ...item, sortText: `0${item.sortText ?? item.label}` })),
|
||||||
|
...html.filter((item) => !taken.has(item.label)),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -19,6 +19,15 @@ import {
|
|||||||
htmlToWrn,
|
htmlToWrn,
|
||||||
type TextDocument,
|
type TextDocument,
|
||||||
} from "./index.ts";
|
} from "./index.ts";
|
||||||
|
import {
|
||||||
|
htmlCompletions,
|
||||||
|
htmlFoldingRanges,
|
||||||
|
htmlHover,
|
||||||
|
htmlLinkedEditingRanges,
|
||||||
|
htmlTagComplete,
|
||||||
|
mergeCompletions,
|
||||||
|
} from "./html-service.ts";
|
||||||
|
import { clearHtmlRegionCache } from "./html-regions.ts";
|
||||||
|
|
||||||
type JsonRpc = { jsonrpc?: string; id?: number | string; method?: string; params?: any };
|
type JsonRpc = { jsonrpc?: string; id?: number | string; method?: string; params?: any };
|
||||||
const documents = new Map<string, TextDocument>();
|
const documents = new Map<string, TextDocument>();
|
||||||
@@ -111,8 +120,12 @@ async function handle(message: JsonRpc): Promise<void> {
|
|||||||
capabilities: {
|
capabilities: {
|
||||||
textDocumentSync: { openClose: true, change: 1, save: true },
|
textDocumentSync: { openClose: true, change: 1, save: true },
|
||||||
documentFormattingProvider: true,
|
documentFormattingProvider: true,
|
||||||
completionProvider: { triggerCharacters: ["<", "@", ":", "."] },
|
completionProvider: {
|
||||||
|
triggerCharacters: ["<", "@", ":", ".", " ", "=", '"', "/"],
|
||||||
|
},
|
||||||
hoverProvider: true,
|
hoverProvider: true,
|
||||||
|
foldingRangeProvider: true,
|
||||||
|
linkedEditingRangeProvider: true,
|
||||||
definitionProvider: true,
|
definitionProvider: true,
|
||||||
referencesProvider: true,
|
referencesProvider: true,
|
||||||
renameProvider: { prepareProvider: true },
|
renameProvider: { prepareProvider: true },
|
||||||
@@ -172,6 +185,7 @@ async function handle(message: JsonRpc): Promise<void> {
|
|||||||
case "textDocument/didClose":
|
case "textDocument/didClose":
|
||||||
clearDiagnosticTimer(params.textDocument.uri);
|
clearDiagnosticTimer(params.textDocument.uri);
|
||||||
documents.delete(params.textDocument.uri);
|
documents.delete(params.textDocument.uri);
|
||||||
|
clearHtmlRegionCache(params.textDocument.uri);
|
||||||
send({
|
send({
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
method: "textDocument/publishDiagnostics",
|
method: "textDocument/publishDiagnostics",
|
||||||
@@ -195,9 +209,13 @@ async function handle(message: JsonRpc): Promise<void> {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "textDocument/completion":
|
case "textDocument/completion": {
|
||||||
result(message.id, [...completionItems(), ...workspaceCompletionItems(workspaceRoot)]);
|
const document = documents.get(params.textDocument.uri);
|
||||||
|
const wrnexus = [...completionItems(), ...workspaceCompletionItems(workspaceRoot)];
|
||||||
|
const html = document ? htmlCompletions(document, params.position) : [];
|
||||||
|
result(message.id, html.length ? mergeCompletions(wrnexus, html) : wrnexus);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "textDocument/documentSymbol": {
|
case "textDocument/documentSymbol": {
|
||||||
const document = documents.get(params.textDocument.uri);
|
const document = documents.get(params.textDocument.uri);
|
||||||
result(message.id, document ? documentSymbols(document) : []);
|
result(message.id, document ? documentSymbols(document) : []);
|
||||||
@@ -210,7 +228,24 @@ async function handle(message: JsonRpc): Promise<void> {
|
|||||||
}
|
}
|
||||||
case "textDocument/hover": {
|
case "textDocument/hover": {
|
||||||
const document = documents.get(params.textDocument.uri);
|
const document = documents.get(params.textDocument.uri);
|
||||||
result(message.id, document ? hover(document, params.position) : null);
|
const html = document ? htmlHover(document, params.position) : null;
|
||||||
|
result(message.id, html ?? (document ? hover(document, params.position) : null));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "textDocument/foldingRange": {
|
||||||
|
const document = documents.get(params.textDocument.uri);
|
||||||
|
result(message.id, document ? htmlFoldingRanges(document) : []);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "textDocument/linkedEditingRange": {
|
||||||
|
const document = documents.get(params.textDocument.uri);
|
||||||
|
const ranges = document ? htmlLinkedEditingRanges(document, params.position) : null;
|
||||||
|
result(message.id, ranges ? { ranges } : null);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "wrn/tagComplete": {
|
||||||
|
const document = documents.get(params.textDocument.uri);
|
||||||
|
result(message.id, document ? htmlTagComplete(document, params.position) : null);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "textDocument/definition": {
|
case "textDocument/definition": {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
page HtmlEditingCheck {
|
||||||
|
seo {
|
||||||
|
title = "HTML editing check"
|
||||||
|
description = "Scratch page for verifying editor support inside view blocks."
|
||||||
|
canonical = "/html-editing-check"
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<main>
|
||||||
|
<h1>Editor check</h1>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { positionAt } from "../src/index.ts";
|
||||||
|
|
||||||
|
// End-to-end verification (Task 8): drives the real language server, over
|
||||||
|
// real LSP stdio framing, against a realistic WRN page fixture. The page's
|
||||||
|
// text is read from disk rather than duplicated here, so the fixture remains
|
||||||
|
// reusable and independently inspectable.
|
||||||
|
|
||||||
|
const pagePath = join(
|
||||||
|
fileURLToPath(new URL(".", import.meta.url)),
|
||||||
|
"fixtures/html-editing-check.wrn",
|
||||||
|
);
|
||||||
|
const pageText = readFileSync(pagePath, "utf8");
|
||||||
|
|
||||||
|
function packet(value: unknown): Uint8Array {
|
||||||
|
const body = JSON.stringify(value);
|
||||||
|
return new TextEncoder().encode(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withServer<T>(
|
||||||
|
run: (
|
||||||
|
send: (msg: unknown) => Promise<void>,
|
||||||
|
readUntil: (marker: string) => Promise<string>,
|
||||||
|
) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const process = Bun.spawn(
|
||||||
|
["bun", "run", fileURLToPath(new URL("../src/server.ts", import.meta.url))],
|
||||||
|
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
const reader = process.stdout.getReader();
|
||||||
|
let output = "";
|
||||||
|
async function readUntil(marker: string): Promise<string> {
|
||||||
|
while (!output.includes(marker)) {
|
||||||
|
const chunk = await reader.read();
|
||||||
|
if (chunk.done) break;
|
||||||
|
output += new TextDecoder().decode(chunk.value);
|
||||||
|
}
|
||||||
|
return output.slice(output.indexOf(marker));
|
||||||
|
}
|
||||||
|
async function send(message: unknown): Promise<void> {
|
||||||
|
process.stdin.write(packet(message));
|
||||||
|
await process.stdin.flush();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await run(send, readUntil);
|
||||||
|
} finally {
|
||||||
|
process.kill();
|
||||||
|
await process.exited;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("HTML editor support works end-to-end against the real scratch page", async () => {
|
||||||
|
const uri = "file:///html-editing-check.wrn";
|
||||||
|
|
||||||
|
// Positions derived from the real file content, not hardcoded line/column
|
||||||
|
// literals, so the test tracks the page if it changes.
|
||||||
|
const viewOpenBrace = pageText.indexOf("view {");
|
||||||
|
const viewCloseBrace = pageText.indexOf("}", pageText.indexOf("</main>"));
|
||||||
|
const h1TagOffset = pageText.indexOf("<h1>") + 1; // inside "h1"
|
||||||
|
const mainOpenNameOffset = pageText.indexOf("<main>") + 1; // inside "main" opening tag name
|
||||||
|
const mainOpenTagEndOffset = pageText.indexOf("<main>") + "<main>".length; // just after '>'
|
||||||
|
const seoTitleOffset = pageText.indexOf('title = "HTML editing check"'); // inside the seo block
|
||||||
|
|
||||||
|
// Simulate typing "<" inside the seo {} block: insert the character into a
|
||||||
|
// copy of the real page text (the checked-in file itself is never
|
||||||
|
// mutated) and ask for completion right after it. This is the scenario
|
||||||
|
// that actually exercises the view-region guard: if the guard were
|
||||||
|
// defeated, this exact position is where the HTML language service would
|
||||||
|
// offer tag completions, because it is positioned directly after an
|
||||||
|
// unclosed "<".
|
||||||
|
const seoInjectedText = pageText.slice(0, seoTitleOffset) + "<" + pageText.slice(seoTitleOffset);
|
||||||
|
const seoInjectedPosition = positionAt(seoInjectedText, seoTitleOffset + 1);
|
||||||
|
|
||||||
|
const viewPosition = positionAt(pageText, h1TagOffset);
|
||||||
|
const linkedEditingPosition = positionAt(pageText, mainOpenNameOffset);
|
||||||
|
const tagCompletePosition = positionAt(pageText, mainOpenTagEndOffset);
|
||||||
|
const viewStartLine = positionAt(pageText, viewOpenBrace).line;
|
||||||
|
const viewEndLine = positionAt(pageText, viewCloseBrace).line;
|
||||||
|
|
||||||
|
await withServer(async (send, readUntil) => {
|
||||||
|
await send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} });
|
||||||
|
await readUntil('"id":1');
|
||||||
|
|
||||||
|
await send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "textDocument/didOpen",
|
||||||
|
params: { textDocument: { uri, version: 1, text: pageText } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Completion inside view {} : HTML entries present, WRNexus sorts first ---
|
||||||
|
await send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 2,
|
||||||
|
method: "textDocument/completion",
|
||||||
|
params: { textDocument: { uri }, position: viewPosition },
|
||||||
|
});
|
||||||
|
const completionReply = await readUntil('"id":2');
|
||||||
|
// An HTML tag entry is present, tagged with the "1" (below-wrnexus) sortText prefix.
|
||||||
|
expect(completionReply).toMatch(/"label":"div"[^}]*"sortText":"1/);
|
||||||
|
// A WRNexus keyword entry is present, tagged with the "0" (above-html) sortText prefix.
|
||||||
|
expect(completionReply).toMatch(/"label":"page"[^}]*"sortText":"0/);
|
||||||
|
// No html-prefixed sortText is lexicographically smaller than any wrnexus one:
|
||||||
|
// every "0..." sortText must precede every "1..." sortText, which is exactly
|
||||||
|
// what makes WRNexus entries render above HTML ones in an editor.
|
||||||
|
expect(completionReply).not.toMatch(/"sortText":"1[^"]*"[\s\S]*"sortText":"0/);
|
||||||
|
|
||||||
|
// --- Negative case: completion right after "<" typed inside seo {} must NOT include HTML entries ---
|
||||||
|
const seoUri = "file:///html-editing-check-seo-inject.wrn";
|
||||||
|
await send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "textDocument/didOpen",
|
||||||
|
params: { textDocument: { uri: seoUri, version: 1, text: seoInjectedText } },
|
||||||
|
});
|
||||||
|
await send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 3,
|
||||||
|
method: "textDocument/completion",
|
||||||
|
params: { textDocument: { uri: seoUri }, position: seoInjectedPosition },
|
||||||
|
});
|
||||||
|
const seoReply = await readUntil('"id":3');
|
||||||
|
expect(seoReply).not.toContain('"label":"div"');
|
||||||
|
expect(seoReply).not.toContain('"label":"span"');
|
||||||
|
expect(seoReply).not.toContain('"label":"h1"');
|
||||||
|
expect(seoReply).not.toMatch(/"sortText":"1/);
|
||||||
|
|
||||||
|
// --- Hover over a tag inside the view block returns documentation ---
|
||||||
|
await send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 4,
|
||||||
|
method: "textDocument/hover",
|
||||||
|
params: { textDocument: { uri }, position: viewPosition },
|
||||||
|
});
|
||||||
|
const hoverReply = await readUntil('"id":4');
|
||||||
|
expect(hoverReply).toContain('"contents"');
|
||||||
|
expect(hoverReply).not.toMatch(/"result":\s*null/);
|
||||||
|
|
||||||
|
// --- Folding ranges are returned and all lie within the view block ---
|
||||||
|
await send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 5,
|
||||||
|
method: "textDocument/foldingRange",
|
||||||
|
params: { textDocument: { uri } },
|
||||||
|
});
|
||||||
|
const foldReply = await readUntil('"id":5');
|
||||||
|
expect(foldReply).toContain('"result":[');
|
||||||
|
const foldBody = foldReply.slice(foldReply.indexOf('"result":['));
|
||||||
|
const foldRanges = [...foldBody.matchAll(/"startLine":(\d+),"endLine":(\d+)/g)];
|
||||||
|
expect(foldRanges.length).toBeGreaterThan(0);
|
||||||
|
for (const [, start, end] of foldRanges) {
|
||||||
|
expect(Number(start)).toBeGreaterThanOrEqual(viewStartLine);
|
||||||
|
expect(Number(end)).toBeLessThanOrEqual(viewEndLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Linked editing at the <main> opening tag name returns two ranges ---
|
||||||
|
await send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 6,
|
||||||
|
method: "textDocument/linkedEditingRange",
|
||||||
|
params: { textDocument: { uri }, position: linkedEditingPosition },
|
||||||
|
});
|
||||||
|
const linkedReply = await readUntil('"id":6');
|
||||||
|
expect(linkedReply).toContain('"ranges"');
|
||||||
|
const rangeCount = (linkedReply.match(/"start":\{/g) ?? []).length;
|
||||||
|
expect(rangeCount).toBe(2);
|
||||||
|
|
||||||
|
// --- wrn/tagComplete after <main> returns the closing snippet ---
|
||||||
|
await send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 7,
|
||||||
|
method: "wrn/tagComplete",
|
||||||
|
params: { textDocument: { uri }, position: tagCompletePosition },
|
||||||
|
});
|
||||||
|
const tagCompleteReply = await readUntil('"id":7');
|
||||||
|
expect(tagCompleteReply).toContain('"result":"$0</main>"');
|
||||||
|
|
||||||
|
// --- wrn/tagComplete for a void element (<br>) returns null ---
|
||||||
|
const voidText = `page A {\n view {\n <br>\n }\n}\n`;
|
||||||
|
const voidUri = "file:///html-editing-check-void.wrn";
|
||||||
|
await send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "textDocument/didOpen",
|
||||||
|
params: { textDocument: { uri: voidUri, version: 1, text: voidText } },
|
||||||
|
});
|
||||||
|
const brOffset = voidText.indexOf("<br>") + "<br>".length;
|
||||||
|
const brPosition = positionAt(voidText, brOffset);
|
||||||
|
await send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 8,
|
||||||
|
method: "wrn/tagComplete",
|
||||||
|
params: { textDocument: { uri: voidUri }, position: brPosition },
|
||||||
|
});
|
||||||
|
const voidReply = await readUntil('"id":8');
|
||||||
|
expect(voidReply).toContain('"result":null');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
clearHtmlRegionCache,
|
||||||
|
isInsideHtml,
|
||||||
|
viewRegions,
|
||||||
|
virtualHtmlDocument,
|
||||||
|
} from "../src/html-regions.ts";
|
||||||
|
|
||||||
|
function doc(text: string): { uri: string; text: string; version?: number } {
|
||||||
|
return { uri: `file:///${Math.random()}.wrn`, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE = `page Home {
|
||||||
|
view {
|
||||||
|
<div class="card">hello</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
test("the virtual document preserves length and newline offsets", () => {
|
||||||
|
// This is what makes position mapping unnecessary. If it breaks, every
|
||||||
|
// feature reports positions off by some amount instead of failing loudly.
|
||||||
|
const source = doc(PAGE);
|
||||||
|
const virtual = virtualHtmlDocument(source);
|
||||||
|
|
||||||
|
expect(virtual.text.length).toBe(source.text.length);
|
||||||
|
expect(virtual.languageId).toBe("html");
|
||||||
|
for (let i = 0; i < source.text.length; i += 1) {
|
||||||
|
if (source.text[i] === "\n") expect(virtual.text[i]).toBe("\n");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("markup survives into the virtual document and everything else is blanked", () => {
|
||||||
|
const virtual = virtualHtmlDocument(doc(PAGE));
|
||||||
|
expect(virtual.text).toContain('<div class="card">hello</div>');
|
||||||
|
expect(virtual.text).not.toContain("page Home");
|
||||||
|
expect(virtual.text).not.toContain("view");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an apostrophe in text content does not swallow later regions", () => {
|
||||||
|
// A scanner treating ' as a string delimiter anywhere considers the rest of
|
||||||
|
// the file one open string and loses every later region.
|
||||||
|
const source = `page A {
|
||||||
|
view {
|
||||||
|
<p>it's fine</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
component B {
|
||||||
|
view {
|
||||||
|
<span>second</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(viewRegions(source)).toHaveLength(2);
|
||||||
|
expect(virtualHtmlDocument(doc(source)).text).toContain("<span>second</span>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("interpolation braces nest without ending the region early", () => {
|
||||||
|
const source = `page A {
|
||||||
|
view {
|
||||||
|
<div class={cond ? "a" : "b"} data-x={{ a: 1 }}>after</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const regions = viewRegions(source);
|
||||||
|
expect(regions).toHaveLength(1);
|
||||||
|
expect(virtualHtmlDocument(doc(source)).text).toContain("after</div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unparseable mid-edit markup still yields a region", () => {
|
||||||
|
// Completion fires exactly when the document does not parse.
|
||||||
|
const source = `page A {
|
||||||
|
view {
|
||||||
|
<div class="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(viewRegions(source).length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a file with no view block yields no regions and a fully blank document", () => {
|
||||||
|
const source = `page A {
|
||||||
|
functions {
|
||||||
|
function go() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(viewRegions(source)).toEqual([]);
|
||||||
|
expect(virtualHtmlDocument(doc(source)).text.trim()).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("regions are cached per document version", () => {
|
||||||
|
// One keystroke fans out into completion, hover, and tag-close requests.
|
||||||
|
const first = doc(PAGE);
|
||||||
|
first.version = 1;
|
||||||
|
expect(isInsideHtml(first, PAGE.indexOf("<div"))).toBe(true);
|
||||||
|
|
||||||
|
// Same version, mutated text: the cached regions are reused, proving the
|
||||||
|
// scan did not run again.
|
||||||
|
first.text = "page A { }";
|
||||||
|
expect(isInsideHtml(first, PAGE.indexOf("<div"))).toBe(true);
|
||||||
|
|
||||||
|
first.version = 2;
|
||||||
|
expect(isInsideHtml(first, PAGE.indexOf("<div"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearHtmlRegionCache drops a closed document's cached regions", () => {
|
||||||
|
// A reopened document commonly restarts at version 1. Without clearing the
|
||||||
|
// cache on close, that version would match the stale entry from the prior
|
||||||
|
// session and serve regions scanned from the old text.
|
||||||
|
const first = doc(PAGE);
|
||||||
|
first.version = 1;
|
||||||
|
expect(isInsideHtml(first, PAGE.indexOf("<div"))).toBe(true);
|
||||||
|
|
||||||
|
clearHtmlRegionCache(first.uri);
|
||||||
|
|
||||||
|
// Same uri, same version 1, but a document with no view block at all: if
|
||||||
|
// the cache had survived, this would still report true from the old scan.
|
||||||
|
const reopened = { uri: first.uri, text: "page A { }", version: 1 };
|
||||||
|
expect(isInsideHtml(reopened, PAGE.indexOf("<div"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isInsideHtml distinguishes markup from surrounding code", () => {
|
||||||
|
const source = doc(PAGE);
|
||||||
|
const markupOffset = PAGE.indexOf("<div");
|
||||||
|
const keywordOffset = PAGE.indexOf("page");
|
||||||
|
|
||||||
|
expect(isInsideHtml(source, markupOffset)).toBe(true);
|
||||||
|
expect(isInsideHtml(source, keywordOffset)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("documents without a version field scan every time and detect mutations", () => {
|
||||||
|
// Without a version, the cache has no key to validate freshness. Mutations
|
||||||
|
// must be detected on every call, even when uri and document are reused.
|
||||||
|
const source = doc(PAGE);
|
||||||
|
// Explicitly verify version is undefined (not set by doc() helper).
|
||||||
|
expect(source.version).toBeUndefined();
|
||||||
|
|
||||||
|
const markupOffset = PAGE.indexOf("<div");
|
||||||
|
expect(isInsideHtml(source, markupOffset)).toBe(true);
|
||||||
|
|
||||||
|
// Mutate the text: remove the view block.
|
||||||
|
source.text = "page A { }";
|
||||||
|
// Same uri, same missing version, but different text: mutation must be detected.
|
||||||
|
expect(isInsideHtml(source, markupOffset)).toBe(false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
htmlCompletions,
|
||||||
|
htmlFoldingRanges,
|
||||||
|
htmlHover,
|
||||||
|
htmlLinkedEditingRanges,
|
||||||
|
htmlTagComplete,
|
||||||
|
} from "../src/html-service.ts";
|
||||||
|
|
||||||
|
function doc(text: string) {
|
||||||
|
return { uri: "file:///Page.wrn", text };
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionOf(text: string, needle: string) {
|
||||||
|
const offset = text.indexOf(needle) + needle.length;
|
||||||
|
const before = text.slice(0, offset);
|
||||||
|
const lines = before.split("\n");
|
||||||
|
return { line: lines.length - 1, character: lines[lines.length - 1]!.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("suggests HTML tags inside a view block", () => {
|
||||||
|
const text = `page A {
|
||||||
|
view {
|
||||||
|
<
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const items = htmlCompletions(doc(text), positionOf(text, " <"));
|
||||||
|
expect(items.some((item) => item.label === "div")).toBe(true);
|
||||||
|
expect(items.every((item) => item.sortText?.startsWith("1"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("suggests attributes inside a tag", () => {
|
||||||
|
const text = `page A {
|
||||||
|
view {
|
||||||
|
<input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const items = htmlCompletions(doc(text), positionOf(text, "<input "));
|
||||||
|
expect(items.some((item) => item.label === "type")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns nothing outside a view block", () => {
|
||||||
|
const text = `page A {
|
||||||
|
functions {
|
||||||
|
function go() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(htmlCompletions(doc(text), positionOf(text, "function go() "))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hovers a tag inside a view block and nothing outside one", () => {
|
||||||
|
const text = `page A {
|
||||||
|
view {
|
||||||
|
<div>x</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(htmlHover(doc(text), positionOf(text, "<di"))).not.toBeNull();
|
||||||
|
|
||||||
|
const code = `page A {
|
||||||
|
functions {
|
||||||
|
function go() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(htmlHover(doc(code), positionOf(code, "func"))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closes an open tag and leaves void elements alone", () => {
|
||||||
|
const open = `page A {
|
||||||
|
view {
|
||||||
|
<div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(htmlTagComplete(doc(open), positionOf(open, "<div>"))).toContain("</div>");
|
||||||
|
|
||||||
|
const void_ = `page A {
|
||||||
|
view {
|
||||||
|
<br>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(htmlTagComplete(doc(void_), positionOf(void_, "<br>"))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("completes a self-closing component tag", () => {
|
||||||
|
const text = `page A {
|
||||||
|
view {
|
||||||
|
<Card /
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(htmlTagComplete(doc(text), positionOf(text, "<Card /"))).toBe(">");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not misfire inside a quoted attribute value containing a slash", () => {
|
||||||
|
const doubleQuoted = `page A {
|
||||||
|
view {
|
||||||
|
<a href="https:/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(htmlTagComplete(doc(doubleQuoted), positionOf(doubleQuoted, `href="https:/`))).toBeNull();
|
||||||
|
|
||||||
|
const singleQuoted = `page A {
|
||||||
|
view {
|
||||||
|
<img src='/assets/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(htmlTagComplete(doc(singleQuoted), positionOf(singleQuoted, `src='/assets/`))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still completes a self-close after a preceding attribute", () => {
|
||||||
|
const text = `page A {
|
||||||
|
view {
|
||||||
|
<Card title="x" /
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(htmlTagComplete(doc(text), positionOf(text, `title="x" /`))).toBe(">");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns no tag completion outside a view block", () => {
|
||||||
|
const text = `page A {
|
||||||
|
functions {
|
||||||
|
function go() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(htmlTagComplete(doc(text), positionOf(text, "function go() "))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("folding ranges stay inside view regions", () => {
|
||||||
|
const text = `page A {
|
||||||
|
view {
|
||||||
|
<ul>
|
||||||
|
<li>one</li>
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const ranges = htmlFoldingRanges(doc(text));
|
||||||
|
expect(ranges.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const viewStartLine = text.slice(0, text.indexOf("view {")).split("\n").length - 1;
|
||||||
|
for (const range of ranges) expect(range.startLine).toBeGreaterThan(viewStartLine - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
import { mergeCompletions } from "../src/html-service.ts";
|
||||||
|
|
||||||
|
test("merging ranks WRNexus entries above HTML and drops exact collisions", () => {
|
||||||
|
const merged = mergeCompletions(
|
||||||
|
[
|
||||||
|
{ label: "Card", kind: 7 },
|
||||||
|
{ label: "table", kind: 7 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ label: "div", kind: 10, sortText: "1div" },
|
||||||
|
{ label: "table", kind: 10, sortText: "1table" },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const labels = merged.map((item) => item.label);
|
||||||
|
expect(labels.filter((label) => label === "table")).toHaveLength(1);
|
||||||
|
expect(merged.find((item) => item.label === "Card")?.sortText?.startsWith("0")).toBe(true);
|
||||||
|
expect(merged.find((item) => item.label === "div")?.sortText?.startsWith("1")).toBe(true);
|
||||||
|
|
||||||
|
const sorted = [...merged].sort((a, b) => (a.sortText ?? "").localeCompare(b.sortText ?? ""));
|
||||||
|
expect(sorted[0]!.label).toBe("Card");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("linked editing returns both the opening and closing tag names", () => {
|
||||||
|
const text = `page A {
|
||||||
|
view {
|
||||||
|
<div>x</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const ranges = htmlLinkedEditingRanges(doc(text), positionOf(text, "<di"));
|
||||||
|
expect(ranges).not.toBeNull();
|
||||||
|
expect(ranges).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("linked editing returns null outside a view block", () => {
|
||||||
|
const text = `page A {
|
||||||
|
functions {
|
||||||
|
function go() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
expect(htmlLinkedEditingRanges(doc(text), positionOf(text, "func"))).toBeNull();
|
||||||
|
});
|
||||||
@@ -217,3 +217,207 @@ test("coalesces rapid document changes into one pending diagnostic analysis", as
|
|||||||
process.kill();
|
process.kill();
|
||||||
await process.exited;
|
await process.exited;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("didClose drops the region cache so a reopened document at the same version is rescanned", async () => {
|
||||||
|
// Regression coverage for the didClose wiring in server.ts: without the
|
||||||
|
// clearHtmlRegionCache(uri) call there, a document that closes and reopens
|
||||||
|
// at version 1 (a common restart point) matches the stale cache entry from
|
||||||
|
// the prior session. Open first WITHOUT a view block (caching "no HTML
|
||||||
|
// here" for this uri/version), close, then reopen the SAME uri at the SAME
|
||||||
|
// version WITH a view block covering the same offset: correct behaviour
|
||||||
|
// rescans and finds it, a stale cache still says "no HTML here" and
|
||||||
|
// suppresses the completions entirely.
|
||||||
|
const process = Bun.spawn(
|
||||||
|
["bun", "run", fileURLToPath(new URL("../src/server.ts", import.meta.url))],
|
||||||
|
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
const uri = "file:///reopen.wrn";
|
||||||
|
const withoutView = `page A {
|
||||||
|
functions {
|
||||||
|
x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const withView = `page A {
|
||||||
|
view {
|
||||||
|
<
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const position = { line: 2, character: 5 };
|
||||||
|
const reader = process.stdout.getReader();
|
||||||
|
let output = "";
|
||||||
|
async function readUntil(marker: string): Promise<void> {
|
||||||
|
while (!output.includes(marker)) {
|
||||||
|
const chunk = await reader.read();
|
||||||
|
if (chunk.done) break;
|
||||||
|
output += new TextDecoder().decode(chunk.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdin.write(packet({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }));
|
||||||
|
await process.stdin.flush();
|
||||||
|
await readUntil('"id":1');
|
||||||
|
|
||||||
|
process.stdin.write(
|
||||||
|
packet({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "textDocument/didOpen",
|
||||||
|
params: { textDocument: { uri, version: 1, text: withoutView } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
process.stdin.write(
|
||||||
|
packet({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 2,
|
||||||
|
method: "textDocument/completion",
|
||||||
|
params: { textDocument: { uri }, position },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await process.stdin.flush();
|
||||||
|
await readUntil('"id":2');
|
||||||
|
const firstReply = output.slice(output.indexOf('"id":2'));
|
||||||
|
expect(firstReply).not.toContain('"label":"div"');
|
||||||
|
|
||||||
|
process.stdin.write(
|
||||||
|
packet({ jsonrpc: "2.0", method: "textDocument/didClose", params: { textDocument: { uri } } }),
|
||||||
|
);
|
||||||
|
process.stdin.write(
|
||||||
|
packet({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "textDocument/didOpen",
|
||||||
|
params: { textDocument: { uri, version: 1, text: withView } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
process.stdin.write(
|
||||||
|
packet({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 3,
|
||||||
|
method: "textDocument/completion",
|
||||||
|
params: { textDocument: { uri }, position },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await process.stdin.flush();
|
||||||
|
await readUntil('"id":3');
|
||||||
|
const secondReply = output.slice(output.indexOf('"id":3'));
|
||||||
|
expect(secondReply).toContain('"label":"div"');
|
||||||
|
|
||||||
|
process.kill();
|
||||||
|
await process.exited;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("advertises and serves folding ranges and linked editing ranges over the wire", async () => {
|
||||||
|
const process = Bun.spawn(
|
||||||
|
["bun", "run", fileURLToPath(new URL("../src/server.ts", import.meta.url))],
|
||||||
|
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
const uri = "file:///fold.wrn";
|
||||||
|
const text = `page A {
|
||||||
|
view {
|
||||||
|
<div>x</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const reader = process.stdout.getReader();
|
||||||
|
let output = "";
|
||||||
|
async function readUntil(marker: string): Promise<void> {
|
||||||
|
while (!output.includes(marker)) {
|
||||||
|
const chunk = await reader.read();
|
||||||
|
if (chunk.done) break;
|
||||||
|
output += new TextDecoder().decode(chunk.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdin.write(packet({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }));
|
||||||
|
await process.stdin.flush();
|
||||||
|
await readUntil('"id":1');
|
||||||
|
const initReply = output.slice(output.indexOf('"id":1'));
|
||||||
|
expect(initReply).toContain('"foldingRangeProvider":true');
|
||||||
|
expect(initReply).toContain('"linkedEditingRangeProvider":true');
|
||||||
|
|
||||||
|
process.stdin.write(
|
||||||
|
packet({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "textDocument/didOpen",
|
||||||
|
params: { textDocument: { uri, version: 1, text } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
process.stdin.write(
|
||||||
|
packet({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 2,
|
||||||
|
method: "textDocument/foldingRange",
|
||||||
|
params: { textDocument: { uri } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await process.stdin.flush();
|
||||||
|
await readUntil('"id":2');
|
||||||
|
const foldReply = output.slice(output.indexOf('"id":2'));
|
||||||
|
expect(foldReply).toContain('"result":[');
|
||||||
|
|
||||||
|
process.stdin.write(
|
||||||
|
packet({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 3,
|
||||||
|
method: "textDocument/linkedEditingRange",
|
||||||
|
params: { textDocument: { uri }, position: { line: 2, character: 6 } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await process.stdin.flush();
|
||||||
|
await readUntil('"id":3');
|
||||||
|
const linkedReply = output.slice(output.indexOf('"id":3'));
|
||||||
|
expect(linkedReply).toContain('"ranges"');
|
||||||
|
|
||||||
|
process.kill();
|
||||||
|
await process.exited;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serves wrn/tagComplete over the wire", async () => {
|
||||||
|
const process = Bun.spawn(
|
||||||
|
["bun", "run", fileURLToPath(new URL("../src/server.ts", import.meta.url))],
|
||||||
|
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
|
||||||
|
);
|
||||||
|
const uri = "file:///tag-complete.wrn";
|
||||||
|
const text = `page A {
|
||||||
|
view {
|
||||||
|
<div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const reader = process.stdout.getReader();
|
||||||
|
let output = "";
|
||||||
|
async function readUntil(marker: string): Promise<void> {
|
||||||
|
while (!output.includes(marker)) {
|
||||||
|
const chunk = await reader.read();
|
||||||
|
if (chunk.done) break;
|
||||||
|
output += new TextDecoder().decode(chunk.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdin.write(packet({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }));
|
||||||
|
await process.stdin.flush();
|
||||||
|
await readUntil('"id":1');
|
||||||
|
|
||||||
|
process.stdin.write(
|
||||||
|
packet({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "textDocument/didOpen",
|
||||||
|
params: { textDocument: { uri, version: 1, text } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
process.stdin.write(
|
||||||
|
packet({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 2,
|
||||||
|
method: "wrn/tagComplete",
|
||||||
|
params: { textDocument: { uri }, position: { line: 2, character: 9 } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await process.stdin.flush();
|
||||||
|
await readUntil('"id":2');
|
||||||
|
const reply = output.slice(output.indexOf('"id":2'));
|
||||||
|
expect(reply).toContain('"result":"$0</div>"');
|
||||||
|
|
||||||
|
process.kill();
|
||||||
|
await process.exited;
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/pwa",
|
"name": "@wrnexus/pwa",
|
||||||
"version": "0.8.8",
|
"version": "0.8.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Service workers, manifests, offline queues, background sync, push, and conflict resolution for WRNexusJS.",
|
"description": "Service workers, manifests, offline queues, background sync, push, and conflict resolution for WRNexusJS.",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
@@ -14,6 +14,6 @@
|
|||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ import { createRoot, type Root } from "react-dom/client";
|
|||||||
import { IslandErrorBoundary } from "./error-boundary.tsx";
|
import { IslandErrorBoundary } from "./error-boundary.tsx";
|
||||||
|
|
||||||
export interface MountOptions {
|
export interface MountOptions {
|
||||||
loader: (name: string) => Promise<{ default: ComponentType<any> }>;
|
/**
|
||||||
|
* Resolve an island module by name.
|
||||||
|
*
|
||||||
|
* `generation` counts remounts. A rebuilt island keeps its URL and the
|
||||||
|
* browser caches a module by URL, so a dev loader must fold this into the
|
||||||
|
* request or the page keeps running the code it first imported.
|
||||||
|
*/
|
||||||
|
loader: (name: string, generation: number) => Promise<{ default: ComponentType<any> }>;
|
||||||
development?: boolean;
|
development?: boolean;
|
||||||
/**
|
/**
|
||||||
* Re-render islands that are already mounted instead of skipping them.
|
* Re-render islands that are already mounted instead of skipping them.
|
||||||
@@ -16,6 +23,7 @@ export interface MountOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const roots = new Map<Element, Root>();
|
const roots = new Map<Element, Root>();
|
||||||
|
let generation = 0;
|
||||||
|
|
||||||
export function islandRootCount(): number {
|
export function islandRootCount(): number {
|
||||||
return roots.size;
|
return roots.size;
|
||||||
@@ -32,8 +40,40 @@ function readProps(element: Element): Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function whenReady(element: Element, strategy: string): Promise<void> {
|
function rectOf(element: Element): DOMRect | null {
|
||||||
if (strategy === "visible" && typeof IntersectionObserver !== "undefined") {
|
const measure = (element as HTMLElement).getBoundingClientRect;
|
||||||
|
return typeof measure === "function" ? (element as HTMLElement).getBoundingClientRect() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inViewport(element: Element): boolean {
|
||||||
|
const rect = rectOf(element);
|
||||||
|
if (!rect) return false;
|
||||||
|
|
||||||
|
const height = window.innerHeight || document.documentElement?.clientHeight || 0;
|
||||||
|
const width = window.innerWidth || document.documentElement?.clientWidth || 0;
|
||||||
|
|
||||||
|
return rect.top <= height && rect.bottom >= 0 && rect.left <= width && rect.right >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve once the island's placeholder has come into view.
|
||||||
|
*
|
||||||
|
* An island renders nothing until it mounts, so its placeholder is usually
|
||||||
|
* zero-height -- and IntersectionObserver does not treat a zero-area target
|
||||||
|
* consistently. When it declines to report one, the island never mounts at
|
||||||
|
* all, which is silent: the markup and every asset are present and correct.
|
||||||
|
* Those are driven from the element's own rect instead; a placeholder with
|
||||||
|
* real size (an SSR fallback, or a reserved min-height) still uses the
|
||||||
|
* observer, which is cheaper and needs no scroll listener.
|
||||||
|
*/
|
||||||
|
function whenVisible(element: Element): Promise<void> {
|
||||||
|
if (typeof window === "undefined") return Promise.resolve();
|
||||||
|
if (inViewport(element)) return Promise.resolve();
|
||||||
|
|
||||||
|
const rect = rectOf(element);
|
||||||
|
const hasArea = !!rect && rect.width > 0 && rect.height > 0;
|
||||||
|
|
||||||
|
if (hasArea && typeof IntersectionObserver !== "undefined") {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
if (entries.some((entry) => entry.isIntersecting)) {
|
if (entries.some((entry) => entry.isIntersecting)) {
|
||||||
@@ -44,6 +84,26 @@ function whenReady(element: Element, strategy: string): Promise<void> {
|
|||||||
observer.observe(element);
|
observer.observe(element);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const check = () => {
|
||||||
|
if (!inViewport(element)) return;
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const cleanup = () => {
|
||||||
|
window.removeEventListener("scroll", check, true);
|
||||||
|
window.removeEventListener("resize", check);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Capture phase so a scrolling container, not just the page, wakes it.
|
||||||
|
window.addEventListener("scroll", check, true);
|
||||||
|
window.addEventListener("resize", check);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function whenReady(element: Element, strategy: string): Promise<void> {
|
||||||
|
if (strategy === "visible") return whenVisible(element);
|
||||||
if (strategy === "idle" && typeof requestIdleCallback !== "undefined") {
|
if (strategy === "idle" && typeof requestIdleCallback !== "undefined") {
|
||||||
return new Promise((resolve) => requestIdleCallback(() => resolve()));
|
return new Promise((resolve) => requestIdleCallback(() => resolve()));
|
||||||
}
|
}
|
||||||
@@ -65,7 +125,7 @@ async function mountOne(element: Element, options: MountOptions): Promise<void>
|
|||||||
|
|
||||||
let Component: ComponentType<any>;
|
let Component: ComponentType<any>;
|
||||||
try {
|
try {
|
||||||
Component = (await options.loader(name)).default;
|
Component = (await options.loader(name, generation)).default;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[wrnexus] failed to load island bundle for '${name}'`, error);
|
console.error(`[wrnexus] failed to load island bundle for '${name}'`, error);
|
||||||
return;
|
return;
|
||||||
@@ -118,6 +178,10 @@ export function unmountIslands(root: ParentNode): void {
|
|||||||
* a runtime and is out of scope.
|
* a runtime and is out of scope.
|
||||||
*/
|
*/
|
||||||
export async function remountIslands(root: ParentNode, options: MountOptions): Promise<void> {
|
export async function remountIslands(root: ParentNode, options: MountOptions): Promise<void> {
|
||||||
|
// A remount only happens after a rebuild, so the modules on the other side
|
||||||
|
// of the loader have changed.
|
||||||
|
generation++;
|
||||||
|
|
||||||
// Every mounted container is swapped for a bare clone before remounting.
|
// Every mounted container is swapped for a bare clone before remounting.
|
||||||
//
|
//
|
||||||
// Re-rendering the existing root is not enough: HMR wipes the container's
|
// Re-rendering the existing root is not enough: HMR wipes the container's
|
||||||
|
|||||||
@@ -8,8 +8,11 @@
|
|||||||
export function getIslandRuntime(development = false): string {
|
export function getIslandRuntime(development = false): string {
|
||||||
return `
|
return `
|
||||||
(function () {
|
(function () {
|
||||||
function loader(name) {
|
function loader(name, generation) {
|
||||||
return import("/__wrnexus/island/" + encodeURIComponent(name) + ".js");
|
var url = "/__wrnexus/island/" + encodeURIComponent(name) + ".js";
|
||||||
|
// A rebuilt island keeps its URL, and the browser caches modules by URL,
|
||||||
|
// so a remount has to ask for a URL it has not imported before.
|
||||||
|
return import(generation ? url + "?v=" + generation : url);
|
||||||
}
|
}
|
||||||
|
|
||||||
function boot() {
|
function boot() {
|
||||||
|
|||||||
@@ -92,3 +92,26 @@ test("remount re-renders in place instead of creating a second root", async () =
|
|||||||
expect(islandRootCount()).toBe(1);
|
expect(islandRootCount()).toBe(1);
|
||||||
expect(window.document.body.textContent).toContain("v1");
|
expect(window.document.body.textContent).toContain("v1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a remount asks the loader for a newer generation than the mount did", async () => {
|
||||||
|
// The rebuilt island keeps its URL. Without a changing generation the dev
|
||||||
|
// loader re-imports the cached module and the page keeps the old code --
|
||||||
|
// silently, because the island still mounts and still works.
|
||||||
|
const window = domWith(marker);
|
||||||
|
const generations: number[] = [];
|
||||||
|
const loader = async (_name: string, generation: number) => {
|
||||||
|
generations.push(generation);
|
||||||
|
return { default: () => createElement("span", null, `gen ${generation}`) };
|
||||||
|
};
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await mountIslands(host(window), { loader });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await remountIslands(host(window), { loader });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(generations.length).toBe(2);
|
||||||
|
expect(generations[1]).toBeGreaterThan(generations[0]!);
|
||||||
|
expect(window.document.body.textContent).toContain(`gen ${generations[1]}`);
|
||||||
|
});
|
||||||
|
|||||||
@@ -128,3 +128,91 @@ test("malformed props JSON falls back to empty props instead of throwing", async
|
|||||||
expect(window.document.body.textContent).toContain("none");
|
expect(window.document.body.textContent).toContain("none");
|
||||||
expect(islandRootCount()).toBe(1);
|
expect(islandRootCount()).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand in for the browser's IntersectionObserver, reporting only targets that
|
||||||
|
* actually have area.
|
||||||
|
*
|
||||||
|
* That is the case the real one is inconsistent about: an island placeholder
|
||||||
|
* is empty until it mounts, so it is zero-height, and an engine that declines
|
||||||
|
* to report it leaves the island unmounted forever.
|
||||||
|
*/
|
||||||
|
function installAreaOnlyObserver(window: Window, onObserve?: () => void) {
|
||||||
|
const observed: Element[] = [];
|
||||||
|
(globalThis as any).IntersectionObserver = class {
|
||||||
|
constructor(private callback: (entries: { isIntersecting: boolean }[]) => void) {}
|
||||||
|
observe(element: Element) {
|
||||||
|
observed.push(element);
|
||||||
|
onObserve?.();
|
||||||
|
const rect = (element as unknown as HTMLElement).getBoundingClientRect();
|
||||||
|
if (rect.width > 0 && rect.height > 0) this.callback([{ isIntersecting: true }]);
|
||||||
|
}
|
||||||
|
disconnect() {}
|
||||||
|
};
|
||||||
|
return observed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Place the island marker at a given position with a given size. */
|
||||||
|
function positionIsland(window: Window, top: number, height: number) {
|
||||||
|
const element = window.document.querySelector("[data-wrn-island]") as unknown as HTMLElement;
|
||||||
|
element.getBoundingClientRect = () =>
|
||||||
|
({ top, bottom: top + height, left: 0, right: 800, width: 800, height }) as DOMRect;
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("mounts a visible island whose placeholder has no height", async () => {
|
||||||
|
const window = domWith(marker("{}", "visible"));
|
||||||
|
installAreaOnlyObserver(window);
|
||||||
|
positionIsland(window, 40, 0);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await mountIslands(host(window), { loader });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(islandRootCount()).toBe(1);
|
||||||
|
delete (globalThis as any).IntersectionObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a visible island below the fold waits, then mounts once scrolled to", async () => {
|
||||||
|
const window = domWith(marker("{}", "visible"));
|
||||||
|
installAreaOnlyObserver(window);
|
||||||
|
positionIsland(window, 5000, 0);
|
||||||
|
|
||||||
|
let settled = false;
|
||||||
|
// Started outside act: it stays pending until the scroll, and the render it
|
||||||
|
// then performs is what act needs to wrap.
|
||||||
|
const mounting = mountIslands(host(window), { loader }).then(() => {
|
||||||
|
settled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
expect(islandRootCount()).toBe(0);
|
||||||
|
|
||||||
|
positionIsland(window, 100, 0);
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(new window.Event("scroll"));
|
||||||
|
await mounting;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(settled).toBe(true);
|
||||||
|
expect(islandRootCount()).toBe(1);
|
||||||
|
delete (globalThis as any).IntersectionObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a placeholder with real size still goes through the observer", async () => {
|
||||||
|
const window = domWith(marker("{}", "visible"));
|
||||||
|
let observedCount = 0;
|
||||||
|
installAreaOnlyObserver(window, () => {
|
||||||
|
observedCount++;
|
||||||
|
});
|
||||||
|
positionIsland(window, 5000, 300);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await mountIslands(host(window), { loader });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(observedCount).toBe(1);
|
||||||
|
expect(islandRootCount()).toBe(1);
|
||||||
|
delete (globalThis as any).IntersectionObserver;
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/realtime",
|
"name": "@wrnexus/realtime",
|
||||||
"version": "0.8.9",
|
"version": "0.8.10",
|
||||||
"description": "Typed realtime rooms, message helpers, presence utilities, package UI blocks, and WRNexusJS integration.",
|
"description": "Typed realtime rooms, message helpers, presence utilities, package UI blocks, and WRNexusJS integration.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"@wrnexus/syntax": "workspace:*",
|
"@wrnexus/syntax": "workspace:*",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"wrnexus": {
|
"wrnexus": {
|
||||||
"plugin": {
|
"plugin": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/rpc",
|
"name": "@wrnexus/rpc",
|
||||||
"version": "0.8.9",
|
"version": "0.8.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
@@ -26,6 +26,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ function parseOriginMap(value: string | undefined): Record<string, string> {
|
|||||||
*
|
*
|
||||||
* Prefer `WRNEXUS_INTERNAL_ORIGINS` (loopback origins the gateway hands each
|
* Prefer `WRNEXUS_INTERNAL_ORIGINS` (loopback origins the gateway hands each
|
||||||
* child before spawning it) over `appOrigin`, which resolves the app's
|
* child before spawning it) over `appOrigin`, which resolves the app's
|
||||||
* PUBLIC origin. The public origin is the wrong target for RPC: the gateway
|
* PUBLIC origin. The public origin is the wrong target for inter-app RPC: the
|
||||||
* unconditionally 404s the reserved `/__wrnexus/rpc` prefix on anything that
|
* gateway 404s private `/__wrnexus/rpc/<service>/<procedure>` routes. (The
|
||||||
* arrives at a public origin — that block is the whole point, it is what
|
* exact prefix remains the CSRF-protected browser server-function endpoint.)
|
||||||
* keeps inter-app calls off the public internet. Falling back to `appOrigin`
|
* That block keeps inter-app calls off the public internet. Falling back to `appOrigin`
|
||||||
* when no internal-origin map is present keeps single-app and test setups
|
* when no internal-origin map is present keeps single-app and test setups
|
||||||
* (which only set `WRNEXUS_WORKSPACE_ORIGINS`) working.
|
* (which only set `WRNEXUS_WORKSPACE_ORIGINS`) working.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -184,8 +184,8 @@ describe("RPC integration", () => {
|
|||||||
const originalInternal = process.env.WRNEXUS_INTERNAL_ORIGINS;
|
const originalInternal = process.env.WRNEXUS_INTERNAL_ORIGINS;
|
||||||
try {
|
try {
|
||||||
// The workspace (public) origin deliberately points somewhere that
|
// The workspace (public) origin deliberately points somewhere that
|
||||||
// cannot serve the RPC — the gateway 404s the RPC prefix on any
|
// cannot serve the RPC — the gateway 404s private nested RPC routes
|
||||||
// request that arrives at a public origin. Only the internal-origin
|
// that arrive at a public origin. Only the internal-origin
|
||||||
// map points at the real server. If httpTransport() ever falls back
|
// map points at the real server. If httpTransport() ever falls back
|
||||||
// to the public origin by default again, this call fails.
|
// to the public origin by default again, this call fails.
|
||||||
process.env.WRNEXUS_WORKSPACE_ORIGINS = JSON.stringify({
|
process.env.WRNEXUS_WORKSPACE_ORIGINS = JSON.stringify({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/typecheck",
|
"name": "@wrnexus/typecheck",
|
||||||
"version": "0.8.9",
|
"version": "0.8.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -10,6 +10,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/syntax": "workspace:*",
|
"@wrnexus/syntax": "workspace:*",
|
||||||
"typescript": "^5.5.0"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1654,11 +1654,21 @@ test("carousel supports RTL, multiple slides, dragging, snap, and thumbnail layo
|
|||||||
expect(css).toContain('.wrn-next--carousel[data-centered="true"] .wrn-next__carousel-track');
|
expect(css).toContain('.wrn-next--carousel[data-centered="true"] .wrn-next__carousel-track');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("carousel autoplay timers are available in the browser reactive runtime", async () => {
|
test("carousel autoplay timers are available in the browser reactive runtime", () => {
|
||||||
const { getReactiveRuntime } = await import("../../csr/src/index.ts");
|
// Resolve them through the runtime rather than asserting on its source: the
|
||||||
const runtime = getReactiveRuntime();
|
// timers only have to be reachable from a client expression, and a substring
|
||||||
expect(runtime).toContain('name === "setInterval"');
|
// check goes stale the moment the lookup is written differently.
|
||||||
expect(runtime).toContain('name === "clearInterval"');
|
const dom = mountHtml(
|
||||||
|
`<div data-scope="started: 0, stopped: 0">` +
|
||||||
|
`<button data-on-click="started = setInterval; stopped = clearInterval">go</button>` +
|
||||||
|
`<span class="started" data-text="started"></span>` +
|
||||||
|
`<span class="stopped" data-text="stopped"></span>` +
|
||||||
|
`</div>`,
|
||||||
|
);
|
||||||
|
(dom.querySelector("button") as HTMLButtonElement).click();
|
||||||
|
|
||||||
|
expect(dom.querySelector(".started")?.textContent).toContain("function");
|
||||||
|
expect(dom.querySelector(".stopped")?.textContent).toContain("function");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("carousel snap controls scroll and multiple slides stop at the last full group", async () => {
|
test("carousel snap controls scroll and multiple slides stop at the last full group", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/uploader",
|
"name": "@wrnexus/uploader",
|
||||||
"version": "0.8.9",
|
"version": "0.8.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^6.0.3",
|
||||||
"@wrnexus/syntax": "workspace:*"
|
"@wrnexus/syntax": "workspace:*"
|
||||||
},
|
},
|
||||||
"wrnexus": {
|
"wrnexus": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/validation",
|
"name": "@wrnexus/validation",
|
||||||
"version": "0.8.9",
|
"version": "0.8.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^6.0.3",
|
||||||
"@wrnexus/syntax": "workspace:*"
|
"@wrnexus/syntax": "workspace:*"
|
||||||
},
|
},
|
||||||
"wrnexus": {
|
"wrnexus": {
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ export function validateStagedManifest(stage: string, manifest: Record<string, u
|
|||||||
}
|
}
|
||||||
if (!/^@wrnexus\/[a-z0-9-]+$/.test(name)) throw new Error(`${name} has an invalid package name.`);
|
if (!/^@wrnexus\/[a-z0-9-]+$/.test(name)) throw new Error(`${name} has an invalid package name.`);
|
||||||
if (manifest.private !== undefined) throw new Error(`${name} staged manifest must omit private.`);
|
if (manifest.private !== undefined) throw new Error(`${name} staged manifest must omit private.`);
|
||||||
if ((manifest.publishConfig as any)?.registry !== "https://registry.npmjs.org/") {
|
const expectedRegistry = process.env.WRNEXUS_NPM_REGISTRY ?? "https://registry.npmjs.org/";
|
||||||
|
if ((manifest.publishConfig as any)?.registry !== expectedRegistry) {
|
||||||
throw new Error(`${name} staged manifest has an unexpected registry.`);
|
throw new Error(`${name} staged manifest has an unexpected registry.`);
|
||||||
}
|
}
|
||||||
if (!["restricted", "public"].includes(String((manifest.publishConfig as any)?.access))) {
|
if (!["restricted", "public"].includes(String((manifest.publishConfig as any)?.access))) {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import { validateAndHashStage, type StagedPackageIntegrity } from "./lib/package
|
|||||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const packagesDir = join(repoRoot, "packages");
|
const packagesDir = join(repoRoot, "packages");
|
||||||
const stageRoot = join(repoRoot, ".publish");
|
const stageRoot = join(repoRoot, ".publish");
|
||||||
const REGISTRY = "https://registry.npmjs.org/";
|
const REGISTRY = process.env.WRNEXUS_NPM_REGISTRY ?? "https://registry.npmjs.org/";
|
||||||
// First release is published PRIVATE ("restricted") so we can test before the
|
// First release is published PRIVATE ("restricted") so we can test before the
|
||||||
// world sees it; flip to public with `npm access public @wrnexus/<name>` (or set
|
// world sees it; flip to public with `npm access public @wrnexus/<name>` (or set
|
||||||
// WRNEXUS_NPM_ACCESS=public to stage public manifests). Requires the `wrnexus`
|
// WRNEXUS_NPM_ACCESS=public to stage public manifests). Requires the `wrnexus`
|
||||||
|
|||||||
@@ -169,7 +169,19 @@ addCheck(
|
|||||||
* minified transfer once. That is the number worth defending.
|
* minified transfer once. That is the number worth defending.
|
||||||
*/
|
*/
|
||||||
const runtimeBudgets = {
|
const runtimeBudgets = {
|
||||||
"reactive-runtime.ts": 49_000,
|
/*
|
||||||
|
* Raised from 49,000 on 2026-08-19, to just above what the runtime actually
|
||||||
|
* minifies to rather than to a round number with room to drift.
|
||||||
|
*
|
||||||
|
* The runtime was already over 49,000 before client-side control blocks and
|
||||||
|
* for/while support were added. Trimming it afterwards -- prototype-safe
|
||||||
|
* global lookup tables, shared hasOwn/toArray/pairBinding helpers, dead code
|
||||||
|
* -- recovered 2,414 bytes, which was everything available without dropping
|
||||||
|
* or deferring a feature. What a visitor pays is the compressed transfer:
|
||||||
|
* 50,156 minified is ~16,000 gzipped, once, behind an immutable year-long
|
||||||
|
* cache.
|
||||||
|
*/
|
||||||
|
"reactive-runtime.ts": 50_500,
|
||||||
"component-controllers.ts": 24_100,
|
"component-controllers.ts": 24_100,
|
||||||
"nav-runtime.ts": 12_000,
|
"nav-runtime.ts": 12_000,
|
||||||
"realtime-runtime.ts": 8_000,
|
"realtime-runtime.ts": 8_000,
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ check(
|
|||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
"i18n runtime data is injected into rendered documents",
|
"i18n runtime data is injected into rendered documents",
|
||||||
has("packages/dev-server/src/runtime.ts", "renderI18nData(deps.i18n, language)") &&
|
has("packages/dev-server/src/runtime.ts", "renderI18nDataTag(deps.i18n, language)") &&
|
||||||
has("packages/dev-server/src/runtime.ts", "deps.i18n.cookie.name"),
|
has("packages/dev-server/src/runtime.ts", "deps.i18n.cookie.name"),
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
|
|||||||
@@ -14,6 +14,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
"types": ["bun"],
|
"types": ["bun"],
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user