Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
fund=false
|
||||
|
||||
@@ -272,7 +272,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.8.36",
|
||||
"version": "0.8.42",
|
||||
"bin": {
|
||||
"wrnexus": "src/index.ts",
|
||||
},
|
||||
@@ -287,6 +287,7 @@
|
||||
"@wrnexus/mcp": "workspace:*",
|
||||
"@wrnexus/playground": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*",
|
||||
"@wrnexus/react": "workspace:*",
|
||||
"@wrnexus/router": "workspace:*",
|
||||
"@wrnexus/security": "workspace:*",
|
||||
"@wrnexus/styles": "workspace:*",
|
||||
@@ -299,9 +300,8 @@
|
||||
},
|
||||
"packages/compiler": {
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.8.10",
|
||||
"version": "0.8.11",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/csr": "workspace:*",
|
||||
"@wrnexus/store": "workspace:*",
|
||||
"@wrnexus/syntax": "workspace:*",
|
||||
@@ -322,14 +322,14 @@
|
||||
},
|
||||
"packages/csr": {
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.8.21",
|
||||
"version": "0.8.22",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/db": {
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.8.15",
|
||||
"version": "0.8.16",
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^6.0.3",
|
||||
@@ -337,7 +337,7 @@
|
||||
},
|
||||
"packages/dev-server": {
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.8.33",
|
||||
"version": "0.8.38",
|
||||
"dependencies": {
|
||||
"@wrnexus/authz": "workspace:*",
|
||||
"@wrnexus/cache": "workspace:*",
|
||||
@@ -364,7 +364,7 @@
|
||||
},
|
||||
"packages/dev-toolbar": {
|
||||
"name": "@wrnexus/dev-toolbar",
|
||||
"version": "0.8.10",
|
||||
"version": "0.8.13",
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^6.0.3",
|
||||
@@ -397,7 +397,7 @@
|
||||
},
|
||||
"packages/i18n": {
|
||||
"name": "@wrnexus/i18n",
|
||||
"version": "0.8.11",
|
||||
"version": "0.8.12",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*",
|
||||
@@ -451,13 +451,14 @@
|
||||
},
|
||||
"packages/language-server": {
|
||||
"name": "@wrnexus/language-server",
|
||||
"version": "0.8.9",
|
||||
"version": "0.8.10",
|
||||
"bin": {
|
||||
"wrnexus-language-server": "src/server.ts",
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/syntax": "workspace:*",
|
||||
"@wrnexus/typecheck": "workspace:*",
|
||||
"vscode-html-languageservice": "^5.6.2",
|
||||
},
|
||||
},
|
||||
"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=="],
|
||||
|
||||
"@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],
|
||||
|
||||
"@wrnexus/ai": ["@wrnexus/ai@workspace:packages/ai"],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"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"],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
||||
|
||||
@@ -1674,6 +1674,7 @@
|
||||
"@wrnexus/i18n": {
|
||||
".": [
|
||||
"ExtractedTranslationKey",
|
||||
"I18N_DATA_ATTRIBUTE",
|
||||
"I18N_JS_HREF",
|
||||
"I18N_RUNTIME",
|
||||
"I18nConfig",
|
||||
@@ -1710,6 +1711,7 @@
|
||||
"plural",
|
||||
"pseudoLocalize",
|
||||
"renderI18nData",
|
||||
"renderI18nDataTag",
|
||||
"resolveI18n",
|
||||
"resolveLang",
|
||||
"translateHtml",
|
||||
|
||||
@@ -30,26 +30,26 @@
|
||||
|
||||
**New package `packages/react/`:**
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `package.json` | Package manifest; optional peer deps |
|
||||
| `src/snapshot-cache.ts` | Referentially-stable snapshot + selector caching. No React import. |
|
||||
| `src/store-bridge.ts` | `useWrnStore` hook over `useSyncExternalStore` |
|
||||
| `src/error-boundary.tsx` | Per-island React error boundary |
|
||||
| `src/island-runtime.ts` | Mount/unmount, strategies, root registry |
|
||||
| `src/runtime-source.ts` | `getIslandRuntime()` returning browser JS (mirrors `@wrnexus/csr`) |
|
||||
| `src/index.ts` | Public exports |
|
||||
| File | Responsibility |
|
||||
| ------------------------ | ------------------------------------------------------------------ |
|
||||
| `package.json` | Package manifest; optional peer deps |
|
||||
| `src/snapshot-cache.ts` | Referentially-stable snapshot + selector caching. No React import. |
|
||||
| `src/store-bridge.ts` | `useWrnStore` hook over `useSyncExternalStore` |
|
||||
| `src/error-boundary.tsx` | Per-island React error boundary |
|
||||
| `src/island-runtime.ts` | Mount/unmount, strategies, root registry |
|
||||
| `src/runtime-source.ts` | `getIslandRuntime()` returning browser JS (mirrors `@wrnexus/csr`) |
|
||||
| `src/index.ts` | Public exports |
|
||||
|
||||
**Modified:**
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `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-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/prod.ts` | Serve island routes in prod |
|
||||
| `packages/cli/src/build.ts` | Emit island assets in static build |
|
||||
| File | Change |
|
||||
| ------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| `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-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/prod.ts` | Serve island routes in prod |
|
||||
| `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.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/react/package.json`
|
||||
- Create: `packages/react/src/snapshot-cache.ts`
|
||||
- Test: `packages/react/test/snapshot-cache.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: nothing.
|
||||
- Produces:
|
||||
- `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
|
||||
* `readonlySnapshot` does by design.
|
||||
*/
|
||||
export function createSnapshotCache<S extends object>(
|
||||
source: SnapshotSource<S>,
|
||||
): SnapshotCache<S> {
|
||||
export function createSnapshotCache<S extends object>(source: SnapshotSource<S>): SnapshotCache<S> {
|
||||
let cached: Readonly<S> | undefined;
|
||||
let dirty = true;
|
||||
|
||||
@@ -259,12 +259,14 @@ git commit -m "feat(react): add referentially-stable snapshot and selector cache
|
||||
### Task 2: Store bridge hook
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/react/src/store-bridge.ts`
|
||||
- Create: `packages/react/src/index.ts`
|
||||
- Modify: `package.json` (root) — add `react`, `react-dom` to devDependencies for tests
|
||||
- Test: `packages/react/test/store-bridge.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `createSnapshotCache`, `createSelectorCache` from Task 1.
|
||||
- Produces:
|
||||
- `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
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/compiler/src/island-codegen.ts`
|
||||
- Test: `packages/compiler/test/island-codegen.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `escapeHtml` from `@wrnexus/core` (`packages/core/src/security.ts`).
|
||||
- Produces:
|
||||
- `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
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/compiler/src/import-resolver.ts`
|
||||
- Test: `packages/compiler/test/island-resolution.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: nothing from prior tasks.
|
||||
- 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:
|
||||
|
||||
```ts
|
||||
if (found) {
|
||||
const resolved = realpathSync(found);
|
||||
return resolved.endsWith(".tsx")
|
||||
? { declaration, resolved, kind: "island" }
|
||||
: { declaration, resolved };
|
||||
}
|
||||
if (found) {
|
||||
const resolved = realpathSync(found);
|
||||
return resolved.endsWith(".tsx")
|
||||
? { declaration, resolved, kind: "island" }
|
||||
: { declaration, resolved };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **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
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/react/src/error-boundary.tsx`
|
||||
- Test: `packages/react/test/error-boundary.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: nothing from prior tasks.
|
||||
- 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
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/react/src/island-runtime.ts`
|
||||
- Modify: `packages/react/src/index.ts`
|
||||
- Test: `packages/react/test/island-runtime.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `IslandErrorBoundary` (Task 5), `setStoreResolver` (Task 2).
|
||||
- Produces:
|
||||
- `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
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/compiler/src/island-bundle.ts`
|
||||
- Test: `packages/compiler/test/island-bundle.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: nothing from prior tasks.
|
||||
- Produces:
|
||||
- `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
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/react/src/runtime-source.ts`
|
||||
- Modify: `packages/dev-server/src/assets.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`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `mountIslands`, `unmountIslands` (Task 6).
|
||||
- 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:
|
||||
|
||||
```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)`:
|
||||
|
||||
```ts
|
||||
if (pathname.startsWith("/__wrnexus/island/")) {
|
||||
return serveIslandArtifact(pathname) ?? new Response("Not Found", { status: 404 });
|
||||
}
|
||||
if (pathname.startsWith("/__wrnexus/island/")) {
|
||||
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/`.
|
||||
@@ -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:
|
||||
|
||||
```ts
|
||||
if (pathname === "/__wrnexus/islands.js")
|
||||
return new Response(getIslandRuntime(), { headers: JS_HEADERS });
|
||||
if (pathname === "/__wrnexus/islands.js")
|
||||
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.
|
||||
@@ -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.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/compiler/src/analysis.ts`
|
||||
- Test: `packages/compiler/test/island-classification.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `ResolvedImport.kind` (Task 4).
|
||||
- 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.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `examples/basic-app/app/islands/Counter.tsx`
|
||||
- Test: `packages/compiler/test/island-integration.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `buildIslands` (Task 7), `renderIslandMarker` (Task 3), `routeNeedsIslands` (Task 9).
|
||||
- Produces: nothing consumed downstream.
|
||||
|
||||
@@ -1493,10 +1511,7 @@ import { join, resolve } from "node:path";
|
||||
import { buildIslands } from "../src/island-bundle.ts";
|
||||
import { routeNeedsIslands } from "../src/analysis.ts";
|
||||
|
||||
const COUNTER = resolve(
|
||||
import.meta.dir,
|
||||
"../../../examples/basic-app/app/islands/Counter.tsx",
|
||||
);
|
||||
const COUNTER = resolve(import.meta.dir, "../../../examples/basic-app/app/islands/Counter.tsx");
|
||||
|
||||
test("a route with no islands ships zero framework JavaScript", async () => {
|
||||
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"))
|
||||
.map((file) => readFileSync(join(outDir, file), "utf8"));
|
||||
|
||||
const withReactInternals = bundles.filter((source) =>
|
||||
source.includes("react.development") || source.includes("REACT_ELEMENT_TYPE"),
|
||||
const withReactInternals = bundles.filter(
|
||||
(source) => source.includes("react.development") || source.includes("REACT_ELEMENT_TYPE"),
|
||||
);
|
||||
|
||||
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.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/react/src/render-phase.ts`
|
||||
- Modify: `packages/react/src/store-bridge.ts` (wrap actions in `useWrnActions`)
|
||||
- Modify: `packages/react/src/index.ts`
|
||||
- Test: `packages/react/test/render-phase.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `useWrnActions` (Task 2).
|
||||
- Produces:
|
||||
- `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.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/react/src/island-runtime.ts`
|
||||
- Modify: `packages/react/src/runtime-source.ts`
|
||||
- Modify: `packages/react/src/index.ts`
|
||||
- Test: `packages/react/test/island-hmr.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `mountIslands`, `unmountIslands` (Task 6).
|
||||
- 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 () => {
|
||||
const window = new Window();
|
||||
window.document.body.innerHTML =
|
||||
`<div data-wrn-island="Chart" data-wrn-island-strategy="only"
|
||||
window.document.body.innerHTML = `<div data-wrn-island="Chart" data-wrn-island-strategy="only"
|
||||
data-wrn-island-props='{}'></div>`;
|
||||
(globalThis as any).window = window;
|
||||
(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
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| Purpose | npm ecosystem access |
|
||||
| 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 />` |
|
||||
| Data flow | Two-way store access via `useSyncExternalStore` (read + write through actions) |
|
||||
| Bundling | Extend the existing Bun pipeline |
|
||||
| Packaging | New isolated package `@wrnexus/react` |
|
||||
| Question | Decision |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------- |
|
||||
| Purpose | npm ecosystem access |
|
||||
| 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 />` |
|
||||
| Data flow | Two-way store access via `useSyncExternalStore` (read + write through actions) |
|
||||
| Bundling | Extend the existing Bun pipeline |
|
||||
| Packaging | New isolated package `@wrnexus/react` |
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -100,7 +100,7 @@ separate island hydration channel is introduced.
|
||||
`readonlySnapshot` in `packages/store/src/index.ts` returns `Object.freeze(clone(state))` — a
|
||||
**new reference on every call**. `useSyncExternalStore` requires `getSnapshot()` to return a
|
||||
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
|
||||
one cached snapshot per store instance, returns the same reference until the store's `subscribe`
|
||||
@@ -182,15 +182,15 @@ point.
|
||||
|
||||
## Error handling
|
||||
|
||||
| Condition | Behavior |
|
||||
|---|---|
|
||||
| `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 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` |
|
||||
| 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. |
|
||||
| Cleanup throws on unmount | Caught and logged; navigation must not break |
|
||||
| Condition | Behavior |
|
||||
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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 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` |
|
||||
| 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. |
|
||||
| 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
|
||||
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.
|
||||
@@ -134,6 +134,11 @@
|
||||
"maximum": 240,
|
||||
"scope": "resource",
|
||||
"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": {
|
||||
"*.wrn": "wrn"
|
||||
},
|
||||
"emmet.includeLanguages": {
|
||||
"wrn": "html"
|
||||
},
|
||||
"[wrn]": {
|
||||
"editor.defaultFormatter": "wrnexus.wrnexus",
|
||||
"editor.formatOnSave": false,
|
||||
@@ -283,6 +291,7 @@
|
||||
"@vscode/vsce": "^3.9.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"vscode-languageclient": "^10.1.0"
|
||||
"vscode-languageclient": "^10.1.0",
|
||||
"vscode-html-languageservice": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,6 +570,41 @@ function isInsideWatch(document, position) {
|
||||
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) {
|
||||
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
||||
|
||||
@@ -634,6 +669,8 @@ function addFunctionCompletions(items, document) {
|
||||
}
|
||||
|
||||
function provideCompletionItems(document, position) {
|
||||
if (isInsideViewBlock(document.getText(), document.offsetAt(position))) return [];
|
||||
|
||||
const items = [];
|
||||
|
||||
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
||||
@@ -707,6 +744,7 @@ module.exports = {
|
||||
extractProps,
|
||||
extractRouteParams,
|
||||
extractStates,
|
||||
isInsideViewBlock,
|
||||
provideCompletionItems,
|
||||
registerCompletionProvider,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// WRN editor extension source hash: c9938fa5f643ca435ead2c8dd5b545c6906c37c0024cf3ea788666236c28ddb6
|
||||
// WRN editor extension source hash: 2012cf8721073e3d9df81af2d3207fdf070119fb3f85ed832ce23c3ee4307662
|
||||
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
||||
"use strict";
|
||||
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||||
@@ -22718,6 +22718,42 @@ async function recoverWrnLanguage(document) {
|
||||
console.warn("[wrnexus] unable to recover .wrn language association:", error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
function registerAutoCloseTags(context, client2) {
|
||||
const listener = vscode.workspace.onDidChangeTextDocument(async (event) => {
|
||||
if (event.document.languageId !== "wrn")
|
||||
return;
|
||||
if (!vscode.workspace.getConfiguration("wrnexus.html").get("autoClosingTags", true))
|
||||
return;
|
||||
const change = event.contentChanges[0];
|
||||
if (!change || change.text !== ">" && change.text !== "/")
|
||||
return;
|
||||
if (change.rangeLength !== 0)
|
||||
return;
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor || editor.document !== event.document)
|
||||
return;
|
||||
const documentVersion = event.document.version;
|
||||
const position = change.range.start.translate(0, change.text.length);
|
||||
if (!editor.selection.isEmpty || !editor.selection.active.isEqual(position))
|
||||
return;
|
||||
const snippet = await client2.sendRequest("wrn/tagComplete", {
|
||||
textDocument: { uri: event.document.uri.toString() },
|
||||
position: { line: position.line, character: position.character }
|
||||
});
|
||||
if (typeof snippet !== "string" || !snippet)
|
||||
return;
|
||||
if (vscode.window.activeTextEditor !== editor)
|
||||
return;
|
||||
if (editor.document !== event.document)
|
||||
return;
|
||||
if (editor.document.version !== documentVersion)
|
||||
return;
|
||||
if (!editor.selection.isEmpty || !editor.selection.active.isEqual(position))
|
||||
return;
|
||||
await editor.insertSnippet(new vscode.SnippetString(snippet), position);
|
||||
});
|
||||
context.subscriptions.push(listener);
|
||||
}
|
||||
async function activate(context) {
|
||||
for (const document of vscode.workspace.textDocuments)
|
||||
recoverWrnLanguage(document);
|
||||
@@ -22730,6 +22766,7 @@ async function activate(context) {
|
||||
debug: { module: module2, transport: TransportKind.stdio, options: { execArgv: ["--nolazy"] } }
|
||||
}, { documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] });
|
||||
await client.start();
|
||||
registerAutoCloseTags(context, client);
|
||||
}
|
||||
async function deactivate() {
|
||||
const running = client;
|
||||
@@ -22737,4 +22774,4 @@ async function deactivate() {
|
||||
if (running)
|
||||
await running.stop();
|
||||
}
|
||||
module.exports = { activate, deactivate, recoverWrnLanguage };
|
||||
module.exports = { activate, deactivate, recoverWrnLanguage, registerAutoCloseTags };
|
||||
|
||||
@@ -23,6 +23,51 @@ async function recoverWrnLanguage(document) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 change = event.contentChanges[0];
|
||||
if (!change || (change.text !== ">" && change.text !== "/")) return;
|
||||
// A replaced selection (overtype/select-and-type) makes `range.start + text.length`
|
||||
// an incorrect offset for both the query and the insertion; decline rather than guess.
|
||||
if (change.rangeLength !== 0) return;
|
||||
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor || editor.document !== event.document) return;
|
||||
|
||||
const documentVersion = event.document.version;
|
||||
const position = change.range.start.translate(0, change.text.length);
|
||||
if (!editor.selection.isEmpty || !editor.selection.active.isEqual(position)) return;
|
||||
|
||||
const snippet = await client.sendRequest("wrn/tagComplete", {
|
||||
textDocument: { uri: event.document.uri.toString() },
|
||||
position: { line: position.line, character: position.character },
|
||||
});
|
||||
if (typeof snippet !== "string" || !snippet) 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.selection.isEmpty || !editor.selection.active.isEqual(position)) return;
|
||||
|
||||
await editor.insertSnippet(new vscode.SnippetString(snippet), position);
|
||||
});
|
||||
|
||||
context.subscriptions.push(listener);
|
||||
}
|
||||
|
||||
/** @param {vscode.ExtensionContext} context */
|
||||
async function activate(context) {
|
||||
for (const document of vscode.workspace.textDocuments) void recoverWrnLanguage(document);
|
||||
@@ -43,6 +88,7 @@ async function activate(context) {
|
||||
{ documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] },
|
||||
);
|
||||
await client.start();
|
||||
registerAutoCloseTags(context, client);
|
||||
}
|
||||
|
||||
async function deactivate() {
|
||||
@@ -51,4 +97,4 @@ async function deactivate() {
|
||||
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,110 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const Module = require("node:module");
|
||||
|
||||
// Mock the vscode module for unit tests
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function load(request, parent, isMain) {
|
||||
if (request === "vscode") {
|
||||
return {
|
||||
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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
const { isInsideViewBlock, provideCompletionItems } = require("../src/completion.js");
|
||||
Module._load = originalLoad;
|
||||
|
||||
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");
|
||||
});
|
||||
@@ -113,6 +113,21 @@ try {
|
||||
readFileSync(join(root, rel), "utf8");
|
||||
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) {
|
||||
bad("Marketplace metadata", e.message);
|
||||
}
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
"@wrnexus/validation": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/lucide": "^1.2.0",
|
||||
"@iconify/tailwind4": "^1.0.0",
|
||||
"@tailwindcss/cli": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.2"
|
||||
"@iconify-json/lucide": "^1.2.123",
|
||||
"@iconify/tailwind4": "^1.2.3",
|
||||
"@tailwindcss/cli": "^4.3.3",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"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 { users } from "./schema.ts";
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export interface Routes {
|
||||
"/client-only": Record<string, never>;
|
||||
"/dashboard": Record<string, never>;
|
||||
"/hello": Record<string, never>;
|
||||
"/html-editing-check": Record<string, never>;
|
||||
"/island-demo": Record<string, never>;
|
||||
"/language-tools": Record<string, never>;
|
||||
"/layout": Record<string, never>;
|
||||
@@ -32,6 +33,7 @@ export interface RouteNames {
|
||||
"client.only": "/client-only";
|
||||
"dashboard": "/dashboard";
|
||||
"hello": "/hello";
|
||||
"html.editing.check": "/html-editing-check";
|
||||
"island.demo": "/island-demo";
|
||||
"language.tools": "/language-tools";
|
||||
"layout": "/layout";
|
||||
@@ -124,6 +126,7 @@ export function route<N extends RouteName>(
|
||||
"client.only": "/client-only",
|
||||
"dashboard": "/dashboard",
|
||||
"hello": "/hello",
|
||||
"html.editing.check": "/html-editing-check",
|
||||
"island.demo": "/island-demo",
|
||||
"language.tools": "/language-tools",
|
||||
"layout": "/layout",
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ declare namespace WRNexusGenerated {
|
||||
: never;
|
||||
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
|
||||
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
|
||||
type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
|
||||
type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "html.editing.check" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
|
||||
type ApiRoute = "/api/accounts" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
|
||||
type RealtimeRoute = "/realtime/chat" | "/realtime/hello";
|
||||
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
|
||||
|
||||
@@ -23,12 +23,12 @@
|
||||
"devDependencies": {
|
||||
"@wrnexus/test": "workspace:*",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@iconify-json/lucide": "^1.2.118",
|
||||
"@iconify-json/lucide": "^1.2.123",
|
||||
"@iconify/tailwind4": "^1.2.3",
|
||||
"@tailwindcss/cli": "^4.0.0",
|
||||
"eslint": "^10.8.0",
|
||||
"prettier": "^3.9.4",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript-eslint": "^8.65.0"
|
||||
"@tailwindcss/cli": "^4.3.3",
|
||||
"eslint": "^10.8.1",
|
||||
"prettier": "^3.9.6",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript-eslint": "^8.67.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
"@wrnexus/validation": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/lucide": "^1.2.0",
|
||||
"@iconify/tailwind4": "^1.0.0",
|
||||
"@tailwindcss/cli": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.2"
|
||||
"@iconify-json/lucide": "^1.2.123",
|
||||
"@iconify/tailwind4": "^1.2.3",
|
||||
"@tailwindcss/cli": "^4.3.3",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,10 @@
|
||||
"@wrnexus/ui": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/lucide": "^1.2.118",
|
||||
"@iconify-json/lucide": "^1.2.123",
|
||||
"@iconify/tailwind4": "^1.2.3",
|
||||
"@tailwindcss/cli": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.5.0"
|
||||
"@tailwindcss/cli": "^4.3.3",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
"@wrnexus/ui": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/lucide": "^1.2.118",
|
||||
"@iconify-json/lucide": "^1.2.123",
|
||||
"@iconify/tailwind4": "^1.2.3",
|
||||
"@tailwindcss/cli": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.2"
|
||||
"@tailwindcss/cli": "^4.3.3",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,40 +24,40 @@
|
||||
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/ai": "0.8.6",
|
||||
"@wrnexus/auth": "0.8.6",
|
||||
"@wrnexus/captcha": "0.8.6",
|
||||
"@wrnexus/core": "0.8.6",
|
||||
"@wrnexus/csr": "0.8.6",
|
||||
"@wrnexus/db": "0.8.6",
|
||||
"@wrnexus/dev-server": "0.8.6",
|
||||
"@wrnexus/encryption": "0.8.6",
|
||||
"@wrnexus/helpers": "0.8.6",
|
||||
"@wrnexus/i18n": "0.8.6",
|
||||
"@wrnexus/image": "0.8.6",
|
||||
"@wrnexus/jwt": "0.8.6",
|
||||
"@wrnexus/observability": "0.8.6",
|
||||
"@wrnexus/realtime": "0.8.6",
|
||||
"@wrnexus/security": "0.8.6",
|
||||
"@wrnexus/store": "0.8.6",
|
||||
"@wrnexus/styles": "0.8.6",
|
||||
"@wrnexus/tracking": "0.8.6",
|
||||
"@wrnexus/ui": "0.8.6",
|
||||
"@wrnexus/uploader": "0.8.6",
|
||||
"@wrnexus/validation": "0.8.6",
|
||||
"@wrnexus/authz": "0.8.6"
|
||||
"@wrnexus/ai": "^0.8.9",
|
||||
"@wrnexus/auth": "^0.8.12",
|
||||
"@wrnexus/captcha": "^0.8.11",
|
||||
"@wrnexus/core": "^0.8.9",
|
||||
"@wrnexus/csr": "^0.8.21",
|
||||
"@wrnexus/db": "^0.8.15",
|
||||
"@wrnexus/dev-server": "^0.8.33",
|
||||
"@wrnexus/encryption": "^0.8.9",
|
||||
"@wrnexus/helpers": "^0.8.8",
|
||||
"@wrnexus/i18n": "^0.8.11",
|
||||
"@wrnexus/image": "^0.8.10",
|
||||
"@wrnexus/jwt": "^0.8.9",
|
||||
"@wrnexus/observability": "^0.8.8",
|
||||
"@wrnexus/realtime": "^0.8.10",
|
||||
"@wrnexus/security": "^0.8.8",
|
||||
"@wrnexus/store": "^0.8.8",
|
||||
"@wrnexus/styles": "^0.8.15",
|
||||
"@wrnexus/tracking": "^0.8.8",
|
||||
"@wrnexus/ui": "^0.8.19",
|
||||
"@wrnexus/uploader": "^0.8.10",
|
||||
"@wrnexus/validation": "^0.8.10",
|
||||
"@wrnexus/authz": "^0.8.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@wrnexus/cli": "0.8.6",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@iconify-json/lucide": "^1.2.118",
|
||||
"@wrnexus/cli": "^0.8.36",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@iconify-json/lucide": "^1.2.123",
|
||||
"@iconify/tailwind4": "^1.2.3",
|
||||
"@tailwindcss/cli": "^4.0.0",
|
||||
"@tailwindcss/cli": "^4.3.3",
|
||||
"@types/bun": "^1.3.14",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint": "^10.8.1",
|
||||
"prettier": "^3.9.6",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript-eslint": "^8.65.0"
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.67.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,40 +24,40 @@
|
||||
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/ai": "0.8.6",
|
||||
"@wrnexus/auth": "0.8.6",
|
||||
"@wrnexus/captcha": "0.8.6",
|
||||
"@wrnexus/core": "0.8.6",
|
||||
"@wrnexus/csr": "0.8.6",
|
||||
"@wrnexus/db": "0.8.6",
|
||||
"@wrnexus/dev-server": "0.8.6",
|
||||
"@wrnexus/encryption": "0.8.6",
|
||||
"@wrnexus/helpers": "0.8.6",
|
||||
"@wrnexus/i18n": "0.8.6",
|
||||
"@wrnexus/image": "0.8.6",
|
||||
"@wrnexus/jwt": "0.8.6",
|
||||
"@wrnexus/observability": "0.8.6",
|
||||
"@wrnexus/realtime": "0.8.6",
|
||||
"@wrnexus/security": "0.8.6",
|
||||
"@wrnexus/store": "0.8.6",
|
||||
"@wrnexus/styles": "0.8.6",
|
||||
"@wrnexus/tracking": "0.8.6",
|
||||
"@wrnexus/ui": "0.8.6",
|
||||
"@wrnexus/uploader": "0.8.6",
|
||||
"@wrnexus/validation": "0.8.6",
|
||||
"@wrnexus/authz": "0.8.6"
|
||||
"@wrnexus/ai": "^0.8.9",
|
||||
"@wrnexus/auth": "^0.8.12",
|
||||
"@wrnexus/captcha": "^0.8.11",
|
||||
"@wrnexus/core": "^0.8.9",
|
||||
"@wrnexus/csr": "^0.8.21",
|
||||
"@wrnexus/db": "^0.8.15",
|
||||
"@wrnexus/dev-server": "^0.8.33",
|
||||
"@wrnexus/encryption": "^0.8.9",
|
||||
"@wrnexus/helpers": "^0.8.8",
|
||||
"@wrnexus/i18n": "^0.8.11",
|
||||
"@wrnexus/image": "^0.8.10",
|
||||
"@wrnexus/jwt": "^0.8.9",
|
||||
"@wrnexus/observability": "^0.8.8",
|
||||
"@wrnexus/realtime": "^0.8.10",
|
||||
"@wrnexus/security": "^0.8.8",
|
||||
"@wrnexus/store": "^0.8.8",
|
||||
"@wrnexus/styles": "^0.8.15",
|
||||
"@wrnexus/tracking": "^0.8.8",
|
||||
"@wrnexus/ui": "^0.8.19",
|
||||
"@wrnexus/uploader": "^0.8.10",
|
||||
"@wrnexus/validation": "^0.8.10",
|
||||
"@wrnexus/authz": "^0.8.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@wrnexus/cli": "0.8.6",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@iconify-json/lucide": "^1.2.118",
|
||||
"@wrnexus/cli": "^0.8.36",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@iconify-json/lucide": "^1.2.123",
|
||||
"@iconify/tailwind4": "^1.2.3",
|
||||
"@tailwindcss/cli": "^4.0.0",
|
||||
"@tailwindcss/cli": "^4.3.3",
|
||||
"@types/bun": "^1.3.14",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint": "^10.8.1",
|
||||
"prettier": "^3.9.6",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript-eslint": "^8.65.0"
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.67.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,12 @@
|
||||
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@wrnexus/cli": "0.8.6",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@wrnexus/cli": "^0.8.36",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/bun": "^1.3.14",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint": "^10.8.1",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript-eslint": "^8.65.0"
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.67.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/pubsub": "0.8.6"
|
||||
"@wrnexus/pubsub": "^0.8.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
@@ -54,7 +54,7 @@
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"@wrnexus/syntax": "workspace:*",
|
||||
"typescript": "^5.9.2"
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"wrnexus": {
|
||||
"plugin": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/captcha",
|
||||
"version": "0.8.10",
|
||||
"version": "0.8.11",
|
||||
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
@@ -48,7 +48,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript": "^6.0.3",
|
||||
"@wrnexus/syntax": "workspace:*"
|
||||
},
|
||||
"wrnexus": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.8.36",
|
||||
"version": "0.8.42",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
@@ -28,6 +28,7 @@
|
||||
"@wrnexus/db": "workspace:*",
|
||||
"@wrnexus/authz": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*",
|
||||
"@wrnexus/react": "workspace:*",
|
||||
"@wrnexus/syntax": "workspace:*",
|
||||
"@wrnexus/typecheck": "workspace:*",
|
||||
"@wrnexus/security": "workspace:*",
|
||||
|
||||
@@ -620,10 +620,14 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
appDir,
|
||||
appRoot: root,
|
||||
mode: "production",
|
||||
sources: [
|
||||
...componentDirs,
|
||||
...pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
|
||||
],
|
||||
// Component discovery is not style discovery. Built-in and plugin
|
||||
// components own their CSS; scanning every available component makes
|
||||
// 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] : [])),
|
||||
},
|
||||
config.styles,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.8.10",
|
||||
"version": "0.8.11",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/content",
|
||||
"version": "0.8.8",
|
||||
"version": "0.8.9",
|
||||
"type": "module",
|
||||
"description": "Typed Markdown content collections, loaders, indexes, feeds, and preview workflows for WRNexusJS.",
|
||||
"main": "./src/index.ts",
|
||||
@@ -14,6 +14,6 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^5.9.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.8.21",
|
||||
"version": "0.8.22",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -368,15 +368,10 @@ export const NAV_RUNTIME = String.raw`
|
||||
}
|
||||
|
||||
function syncI18n(nextDocument) {
|
||||
var script = Array.prototype.find.call(
|
||||
nextDocument.querySelectorAll("script:not([src])"),
|
||||
function (node) { return /^window\.__wrnI18n=/.test(String(node.textContent || "").trim()); },
|
||||
);
|
||||
var script = nextDocument.querySelector('script[type="application/json"][data-wrn-i18n]');
|
||||
if (!script) return;
|
||||
var match = /^window\.__wrnI18n=([\s\S]*);\s*$/.exec(String(script.textContent || "").trim());
|
||||
if (!match) return;
|
||||
try {
|
||||
var incoming = JSON.parse(match[1]);
|
||||
var incoming = JSON.parse(String(script.textContent || "{}"));
|
||||
var current = window.__wrnI18n || {};
|
||||
var translator = current.t;
|
||||
var setter = current.set;
|
||||
|
||||
@@ -148,7 +148,7 @@ test("synchronizes and rebinds i18n data during client navigation", async () =>
|
||||
win.__wrnLang = { bind: (root: unknown) => (boundRoot = root) };
|
||||
nextHtml =
|
||||
`<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>`;
|
||||
|
||||
win.document.getElementById("lnk").click();
|
||||
@@ -180,7 +180,7 @@ test("preserves same-language translations when an incoming navigation catalog i
|
||||
};
|
||||
nextHtml =
|
||||
`<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>`;
|
||||
|
||||
win.document.getElementById("lnk").click();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.8.14",
|
||||
"version": "0.8.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
@@ -26,6 +26,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^5.9.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,11 +226,16 @@ export function generateQueriesFile(
|
||||
);
|
||||
}
|
||||
|
||||
const imports = [
|
||||
`import type { Db${usesExecResult ? ", ExecResult" : ""} } from "@wrnexus/db";`,
|
||||
];
|
||||
const imports = [`import type { Db${usesExecResult ? ", ExecResult" : ""} } from "@wrnexus/db";`];
|
||||
if (usedModels.size > 0) {
|
||||
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(/--[^\r\n]*/g, " ")
|
||||
.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(
|
||||
executable,
|
||||
);
|
||||
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(
|
||||
executable,
|
||||
);
|
||||
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",
|
||||
"version": "0.8.33",
|
||||
"version": "0.8.38",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -34,6 +34,7 @@ import type { AssetServer } from "./runtime.ts";
|
||||
import { servePublicAsset } from "./public.ts";
|
||||
import { servePluginAsset, type ServedPluginAsset } from "./plugin-assets.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`. */
|
||||
export interface DevStyles {
|
||||
@@ -99,6 +100,7 @@ export function createDevAssetServer(
|
||||
if (pathname.startsWith("/__wrnexus/island/")) {
|
||||
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/reactive.js") return jsResponse(getReactiveRuntime(true));
|
||||
if (pathname === "/__wrnexus/controllers.js")
|
||||
|
||||
@@ -113,6 +113,7 @@ interface Target extends GatewayApp {
|
||||
interface WsBridge {
|
||||
origin: string;
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
backend?: WebSocket;
|
||||
queue: Array<string | ArrayBuffer>;
|
||||
maxMessageBytes: number;
|
||||
@@ -158,7 +159,7 @@ function requestMessageBytes(value: string | ArrayBuffer | ArrayBufferView): num
|
||||
return value instanceof ArrayBuffer ? value.byteLength : value.byteLength;
|
||||
}
|
||||
|
||||
function gatewayWebSocketOriginAllowed(
|
||||
export function gatewayWebSocketOriginAllowed(
|
||||
req: Request,
|
||||
target: Target,
|
||||
configured: string[],
|
||||
@@ -173,7 +174,11 @@ function gatewayWebSocketOriginAllowed(
|
||||
}
|
||||
if (configured.includes(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;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function stripUntrustedInternalHeaders(headers: Headers): Headers {
|
||||
const sanitized = new Headers(headers);
|
||||
@@ -692,6 +721,13 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
||||
data: {
|
||||
origin: target.origin,
|
||||
path: url.pathname + url.search,
|
||||
headers: gatewayWebSocketBackendHeaders(
|
||||
req,
|
||||
url,
|
||||
ip,
|
||||
forwardedHeaders,
|
||||
target.origin,
|
||||
),
|
||||
queue: [],
|
||||
maxMessageBytes: websocketSecurity.maxMessageBytes ?? 64 * 1024,
|
||||
maxQueuedMessages: websocketSecurity.maxQueuedMessages ?? 100,
|
||||
@@ -753,7 +789,11 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
||||
websocket: {
|
||||
open(ws) {
|
||||
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;
|
||||
backend.addEventListener("open", () => {
|
||||
for (const m of ws.data.queue) backend.send(m);
|
||||
|
||||
@@ -440,10 +440,9 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
config: opts.stylesConfig,
|
||||
appRoot,
|
||||
publicDir: join(appRoot, "public"),
|
||||
sources: [
|
||||
...componentDirs,
|
||||
...pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
|
||||
],
|
||||
// Component discovery is separate from utility-source discovery.
|
||||
// Packages that need Tailwind scanning opt in via styles.source.
|
||||
sources: pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
|
||||
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
|
||||
},
|
||||
theme,
|
||||
|
||||
@@ -81,7 +81,7 @@ import {
|
||||
type TenancyConfig,
|
||||
} from "@wrnexus/styles";
|
||||
import {
|
||||
renderI18nData,
|
||||
renderI18nDataTag,
|
||||
makeT,
|
||||
resolveLang,
|
||||
translateHtml,
|
||||
@@ -672,22 +672,16 @@ export const HMR_CLIENT_JS = `
|
||||
pendingSync = false;
|
||||
var doc = new DOMParser().parseFromString(html, "text/html");
|
||||
|
||||
var i18nScript = Array.prototype.find.call(
|
||||
doc.querySelectorAll("script:not([src])"),
|
||||
function (node) { return /^window[.]__wrnI18n=/.test(String(node.textContent || "").trim()); },
|
||||
);
|
||||
var i18nScript = doc.querySelector('script[type="application/json"][data-wrn-i18n]');
|
||||
if (i18nScript) {
|
||||
var i18nMatch = /^window[.]__wrnI18n=([^]*);\\s*$/.exec(String(i18nScript.textContent || "").trim());
|
||||
if (i18nMatch) {
|
||||
try {
|
||||
var incomingI18n = JSON.parse(i18nMatch[1]);
|
||||
var existingI18n = window.__wrnI18n || {};
|
||||
incomingI18n.t = existingI18n.t;
|
||||
incomingI18n.set = existingI18n.set;
|
||||
window.__wrnI18n = incomingI18n;
|
||||
} catch (error) {
|
||||
console.error("[wrnexus] failed to synchronize i18n HMR data", error);
|
||||
}
|
||||
try {
|
||||
var incomingI18n = JSON.parse(String(i18nScript.textContent || "{}"));
|
||||
var existingI18n = window.__wrnI18n || {};
|
||||
incomingI18n.t = existingI18n.t;
|
||||
incomingI18n.set = existingI18n.set;
|
||||
window.__wrnI18n = incomingI18n;
|
||||
} 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). */
|
||||
function hmrClientTag(nonce: string): string {
|
||||
return `<script nonce="${nonce}">${HMR_CLIENT_JS}</script>`;
|
||||
/** Path the dev asset server publishes the HMR client on. */
|
||||
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. */
|
||||
@@ -1967,9 +1972,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
||||
extraBody:
|
||||
[
|
||||
renderStoreHydration(storeContainer, (ctx.locals.cspNonce as string) ?? undefined),
|
||||
deps.i18n
|
||||
? `<script${ctx.locals.cspNonce ? ` nonce="${String(ctx.locals.cspNonce)}"` : ""}>${renderI18nData(deps.i18n, language)}</script>`
|
||||
: "",
|
||||
deps.i18n ? renderI18nDataTag(deps.i18n, language) : "",
|
||||
hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "",
|
||||
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);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
forwardAuthFailure,
|
||||
forwardAuthHeaders,
|
||||
gatewayProxyHeaders,
|
||||
gatewayWebSocketBackendHeaders,
|
||||
stripUntrustedInternalHeaders,
|
||||
gatewayRestartDelay,
|
||||
internalError,
|
||||
@@ -37,6 +38,35 @@ test("gateway disables compression for its internal proxy hop", () => {
|
||||
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", () => {
|
||||
const request = new Request("http://localhost:3000/path", {
|
||||
headers: { "x-wrnexus-internal": "1" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-toolbar",
|
||||
"version": "0.8.9",
|
||||
"version": "0.8.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
@@ -20,6 +20,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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{}
|
||||
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."));});
|
||||
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)}));
|
||||
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."));
|
||||
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]){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")})));
|
||||
const storeContainer=window.__wrnexusStoreContainer;
|
||||
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('issue("plugin/"+app.id');
|
||||
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-plugin-panel");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/encryption",
|
||||
"version": "0.8.8",
|
||||
"version": "0.8.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^5.9.2"
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/i18n",
|
||||
"version": "0.8.10",
|
||||
"version": "0.8.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
@@ -23,7 +23,7 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript": "^6.0.3",
|
||||
"@wrnexus/syntax": "workspace:*"
|
||||
},
|
||||
"wrnexus": {
|
||||
|
||||
@@ -425,9 +425,20 @@ function safeJson(value: unknown): string {
|
||||
.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 {
|
||||
const active = i18n.langs.includes(lang) ? lang : i18n.default;
|
||||
return `window.__wrnI18n=${safeJson({
|
||||
return `${safeJson({
|
||||
lang: active,
|
||||
langs: i18n.langs,
|
||||
default: i18n.default,
|
||||
@@ -437,7 +448,12 @@ export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
|
||||
directions: i18n.direction,
|
||||
labels: i18n.labels,
|
||||
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`
|
||||
@@ -457,7 +473,23 @@ export const I18N_RUNTIME = String.raw`
|
||||
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) {
|
||||
var current = state();
|
||||
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",
|
||||
"version": "0.8.9",
|
||||
"version": "0.8.10",
|
||||
"type": "module",
|
||||
"description": "Responsive image planning, secure remote image policies, and performance auditing for WRNexusJS.",
|
||||
"main": "./src/index.ts",
|
||||
@@ -22,7 +22,7 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript": "^6.0.3",
|
||||
"@wrnexus/syntax": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/jwt",
|
||||
"version": "0.8.8",
|
||||
"version": "0.8.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^5.9.2"
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/language-server",
|
||||
"version": "0.8.9",
|
||||
"version": "0.8.10",
|
||||
"type": "module",
|
||||
"description": "Editor-neutral Language Server Protocol implementation for WRNexus .wrn files.",
|
||||
"main": "src/index.ts",
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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 {
|
||||
if (typeof value === "string") return value;
|
||||
if (value && typeof value === "object" && "value" in value) {
|
||||
return String((value as { value: unknown }).value);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
type TextDocument,
|
||||
} 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 };
|
||||
const documents = new Map<string, TextDocument>();
|
||||
@@ -111,8 +120,12 @@ async function handle(message: JsonRpc): Promise<void> {
|
||||
capabilities: {
|
||||
textDocumentSync: { openClose: true, change: 1, save: true },
|
||||
documentFormattingProvider: true,
|
||||
completionProvider: { triggerCharacters: ["<", "@", ":", "."] },
|
||||
completionProvider: {
|
||||
triggerCharacters: ["<", "@", ":", ".", " ", "=", '"', "/"],
|
||||
},
|
||||
hoverProvider: true,
|
||||
foldingRangeProvider: true,
|
||||
linkedEditingRangeProvider: true,
|
||||
definitionProvider: true,
|
||||
referencesProvider: true,
|
||||
renameProvider: { prepareProvider: true },
|
||||
@@ -172,6 +185,7 @@ async function handle(message: JsonRpc): Promise<void> {
|
||||
case "textDocument/didClose":
|
||||
clearDiagnosticTimer(params.textDocument.uri);
|
||||
documents.delete(params.textDocument.uri);
|
||||
clearHtmlRegionCache(params.textDocument.uri);
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "textDocument/publishDiagnostics",
|
||||
@@ -195,9 +209,13 @@ async function handle(message: JsonRpc): Promise<void> {
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "textDocument/completion":
|
||||
result(message.id, [...completionItems(), ...workspaceCompletionItems(workspaceRoot)]);
|
||||
case "textDocument/completion": {
|
||||
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;
|
||||
}
|
||||
case "textDocument/documentSymbol": {
|
||||
const document = documents.get(params.textDocument.uri);
|
||||
result(message.id, document ? documentSymbols(document) : []);
|
||||
@@ -210,7 +228,24 @@ async function handle(message: JsonRpc): Promise<void> {
|
||||
}
|
||||
case "textDocument/hover": {
|
||||
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;
|
||||
}
|
||||
case "textDocument/definition": {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
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 the real scratch page checked in at
|
||||
// examples/basic-app/app/pages/html-editing-check.wrn. The page's text is
|
||||
// read from disk rather than duplicated here, so this test breaks if the
|
||||
// page and the test drift apart.
|
||||
|
||||
const pagePath = join(
|
||||
fileURLToPath(new URL("../../../", import.meta.url)),
|
||||
"examples/basic-app/app/pages/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();
|
||||
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",
|
||||
"version": "0.8.8",
|
||||
"version": "0.8.9",
|
||||
"type": "module",
|
||||
"description": "Service workers, manifests, offline queues, background sync, push, and conflict resolution for WRNexusJS.",
|
||||
"main": "./src/index.ts",
|
||||
@@ -14,6 +14,6 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^5.9.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
@@ -29,7 +29,7 @@
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"@wrnexus/syntax": "workspace:*",
|
||||
"typescript": "^5.9.2"
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"wrnexus": {
|
||||
"plugin": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/rpc",
|
||||
"version": "0.8.9",
|
||||
"version": "0.8.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
@@ -26,6 +26,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^5.9.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/typecheck",
|
||||
"version": "0.8.9",
|
||||
"version": "0.8.10",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
@@ -10,6 +10,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/syntax": "workspace:*",
|
||||
"typescript": "^5.5.0"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/uploader",
|
||||
"version": "0.8.9",
|
||||
"version": "0.8.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
@@ -23,7 +23,7 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript": "^6.0.3",
|
||||
"@wrnexus/syntax": "workspace:*"
|
||||
},
|
||||
"wrnexus": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/validation",
|
||||
"version": "0.8.9",
|
||||
"version": "0.8.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript": "^6.0.3",
|
||||
"@wrnexus/syntax": "workspace:*"
|
||||
},
|
||||
"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 (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.`);
|
||||
}
|
||||
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 packagesDir = join(repoRoot, "packages");
|
||||
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
|
||||
// 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`
|
||||
|
||||
@@ -145,7 +145,7 @@ check(
|
||||
);
|
||||
check(
|
||||
"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"),
|
||||
);
|
||||
check(
|
||||
|
||||
@@ -14,6 +14,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^5.9.2"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"types": ["bun"],
|
||||
"strict": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
|
||||
Reference in New Issue
Block a user