Compare commits
43
Commits
2f075df42c
...
cd0dffa87d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd0dffa87d | ||
|
|
f57bd05a03 | ||
|
|
2bb52487eb | ||
|
|
990a8128a7 | ||
|
|
7a1b4e5b33 | ||
|
|
aded2daab9 | ||
|
|
6bb3ab5fe7 | ||
|
|
4aa0973352 | ||
|
|
e616ed276e | ||
|
|
74490964ee | ||
|
|
7a3e55b150 | ||
|
|
fb24cc7ec3 | ||
|
|
63316111cb | ||
|
|
0ed8351828 | ||
|
|
890d6106b3 | ||
|
|
de99a2c2e0 | ||
|
|
442c058d0c | ||
|
|
a08dfa5322 | ||
|
|
712a6d3d8c | ||
|
|
9dec811069 | ||
|
|
847b7010d1 | ||
|
|
1dbe16dc93 | ||
|
|
f32b33e3b6 | ||
|
|
768074ac0a | ||
|
|
c7e40154ca | ||
|
|
680ea73975 | ||
|
|
953b1cd692 | ||
|
|
87a00de5f3 | ||
|
|
d069f5ddd7 | ||
|
|
abebbcf8af | ||
|
|
5720f93db1 | ||
|
|
7f5e1bc3cf | ||
|
|
97d60d0ba8 | ||
|
|
e7e7b58160 | ||
|
|
e09a35b4bd | ||
|
|
5c8f3ede54 | ||
|
|
d19595c7ba | ||
|
|
b7796b103a | ||
|
|
ec63090006 | ||
|
|
224af8fd96 | ||
|
|
68cc75d0b0 | ||
|
|
43652c14af | ||
|
|
e40d8319a6 |
@@ -93,6 +93,25 @@
|
||||
"typescript": "^6.0.3",
|
||||
},
|
||||
},
|
||||
"examples/crm-app": {
|
||||
"name": "wrnexus-crm-example",
|
||||
"version": "0.8.0",
|
||||
"dependencies": {
|
||||
"@wrnexus/auth": "workspace:*",
|
||||
"@wrnexus/authz": "workspace:*",
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/db": "workspace:*",
|
||||
"@wrnexus/styles": "workspace:*",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/lucide": "^1.2.123",
|
||||
"@iconify/tailwind4": "^1.2.3",
|
||||
"@tailwindcss/cli": "^4.3.3",
|
||||
"@types/bun": "^1.3.14",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
},
|
||||
},
|
||||
"examples/i18n-showcase": {
|
||||
"name": "i18n-showcase",
|
||||
"version": "0.8.0",
|
||||
@@ -216,7 +235,7 @@
|
||||
},
|
||||
"packages/auth": {
|
||||
"name": "@wrnexus/auth",
|
||||
"version": "0.8.12",
|
||||
"version": "0.8.13",
|
||||
"dependencies": {
|
||||
"@wrnexus/authz": "workspace:*",
|
||||
"@wrnexus/captcha": "workspace:*",
|
||||
@@ -272,7 +291,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.8.46",
|
||||
"version": "0.8.47",
|
||||
"bin": {
|
||||
"wrnexus": "src/index.ts",
|
||||
},
|
||||
@@ -337,7 +356,7 @@
|
||||
},
|
||||
"packages/dev-server": {
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.8.41",
|
||||
"version": "0.8.42",
|
||||
"dependencies": {
|
||||
"@wrnexus/authz": "workspace:*",
|
||||
"@wrnexus/cache": "workspace:*",
|
||||
@@ -642,7 +661,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@wrnexus/ui",
|
||||
"version": "0.8.20",
|
||||
"version": "0.8.21",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
},
|
||||
@@ -1406,6 +1425,8 @@
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"wrnexus-crm-example": ["wrnexus-crm-example@workspace:examples/crm-app"],
|
||||
|
||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
@@ -1288,8 +1288,6 @@ wrnexus help | --help | -h
|
||||
import type { AppConfig } from "@wrnexus/styles";
|
||||
|
||||
const config: AppConfig = {
|
||||
compatibilityDate: "2026-08-02",
|
||||
frameworkBehaviour: 1,
|
||||
head: [ '<link rel="stylesheet" href="…">' ], // string | string[] → appended to every <head>
|
||||
|
||||
seo: { // SeoConfig (global defaults, merged per page)
|
||||
|
||||
@@ -10,7 +10,7 @@ Generated for workspace version **0.8.8** from the checked-out source and genera
|
||||
- Architecture: compiler-driven, SSR-first, Bun-native/full-stack, with Node-friendly selected tooling.
|
||||
- Static routes retain the zero-framework-JavaScript goal; hydration is selective and islands are loaded only where declared.
|
||||
- Current headline features include typed callable API blocks, reactive `if`/`each` control blocks, runtime-scoped state and functions, typed outputs, stores, React islands, HTML-aware editor tooling, package/plugin discovery, generated contracts, security gates, and multi-app RPC.
|
||||
- Audited public API baseline: **48 packages / 2741 exported symbols** across root and subpath exports.
|
||||
- Audited public API baseline: **48 packages / 2735 exported symbols** across root and subpath exports.
|
||||
- Audited packaged `.wrn` sources: **142**; generated UI reference: **102 components**.
|
||||
|
||||
## 2. What a `.wrn` file is
|
||||
@@ -119,11 +119,11 @@ Legacy API binding remains valid: `ssr { api users GET /api/users { return users
|
||||
| Legacy/earlier approach | Current approach | Compatibility/status |
|
||||
| ------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Compiler-owned/internal parsing imports | Canonical `@wrnexus/syntax` lexer/parser/AST/diagnostics | Compiler re-exports remain for compatibility; direct internals are deprecated. |
|
||||
| Implicit component discovery everywhere | Explicit imports and generated contracts | Upgraded apps can retain `legacyComponentDiscovery`; unresolved symbols are reported rather than guessed. |
|
||||
| `layout = "PublicLayout"` | Import layout and use `layout = PublicLayout` | String layouts remain behind compatibility configuration. |
|
||||
| Implicit component discovery everywhere | Explicit imports and generated contracts | Unresolved symbols are reported rather than guessed; there is no compatibility opt-out. |
|
||||
| `layout = "PublicLayout"` | Import layout and use `layout = PublicLayout` | String layouts are no longer supported; the config key that toggled them was removed. |
|
||||
| `@event changed = function` in props | `outputs { changed(payload: Type) }` | v0.6 migration converts declarations; ambiguous payloads become `unknown`. |
|
||||
| `$emit("changed", value)` and `event.detail` | `output.changed(value)` and direct `payload` | Static cases auto-migrated; dynamic emit names require review. |
|
||||
| Unclassified functions | `server function`, `client function`, or `shared function` | v0.6 classifies unambiguous cases; compatibility default can preserve ambiguous behavior. |
|
||||
| Unclassified functions | `server function`, `client function`, or `shared function` | v0.6 classifies unambiguous cases; the legacy default runtime that preserved ambiguous behavior was removed. |
|
||||
| Manually copied CAPTCHA JS/script tags | Package-discovered client runtime/assets | v0.4 removes tags and archives old assets under `.wrnexus/legacy-assets/0.4.0`. |
|
||||
| Package components/routes/assets wired manually | Automatic package/plugin discovery and contribution registry | Current CLI/build/dev server inspect and consume contributions. |
|
||||
| Only scalar/quoted dynamic props | Native arrays/objects and JSX-style unquoted expressions | Quoted expression attributes remain supported. |
|
||||
@@ -137,7 +137,7 @@ Legacy API binding remains valid: `ssr { api users GET /api/users { return users
|
||||
| Per-component/global style placement inconsistencies | `style {}` promoted to document head | Current pipeline supports CSP, HMR and CSR navigation. |
|
||||
| Manually maintained API/component knowledge | Generated public API and component references plus validation gates | `check:public-api`, generated-type checks, UI visual contract and package audits detect drift. |
|
||||
|
||||
Compatibility flags visible in generated/upgraded config include `legacyEmit`, `legacyEventProps`, `legacyComponentDiscovery`, `stringLayouts`, and `functions.legacyDefaultRuntime`. New projects default legacy flags off; migration-created configs may enable them to preserve behavior until source modernization is complete.
|
||||
`legacyEmit`, `legacyEventProps`, `legacyComponentDiscovery`, `stringLayouts`, and `functions.legacyDefaultRuntime` (and the other pre-1.0 compatibility keys) were removed before the first public release; a config that still sets any of them is rejected by validation rather than honored.
|
||||
|
||||
## 7. Diagnostics, security, and correctness guarantees
|
||||
|
||||
@@ -174,11 +174,11 @@ Run `bun run check:production` for the complete production gate. Its chain cover
|
||||
| `@wrnexus/benchmark` | 0.8.8 | Deterministic benchmark runner and performance regression budgets for WRNexusJS. |
|
||||
| `@wrnexus/cache` | 0.8.8 | Tag-aware memory and response caching with stale-while-revalidate for WRNexusJS. |
|
||||
| `@wrnexus/captcha` | 0.8.11 | First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI. |
|
||||
| `@wrnexus/cli` | 0.8.45 | — |
|
||||
| `@wrnexus/compiler` | 0.8.13 | — |
|
||||
| `@wrnexus/cli` | 0.8.46 | — |
|
||||
| `@wrnexus/compiler` | 0.8.14 | — |
|
||||
| `@wrnexus/content` | 0.8.9 | Typed Markdown content collections, loaders, indexes, feeds, and preview workflows for WRNexusJS. |
|
||||
| `@wrnexus/core` | 0.8.9 | — |
|
||||
| `@wrnexus/csr` | 0.8.24 | — |
|
||||
| `@wrnexus/core` | 0.8.10 | — |
|
||||
| `@wrnexus/csr` | 0.8.25 | — |
|
||||
| `@wrnexus/db` | 0.8.16 | Typed database drivers, migrations, instrumentation, repositories, pagination, and transaction helpers. |
|
||||
| `@wrnexus/dev-server` | 0.8.41 | — |
|
||||
| `@wrnexus/dev-toolbar` | 0.8.13 | — |
|
||||
@@ -209,13 +209,13 @@ Run `bun run check:production` for the complete production gate. Its chain cover
|
||||
| `@wrnexus/ssr` | 0.8.9 | — |
|
||||
| `@wrnexus/store` | 0.8.8 | — |
|
||||
| `@wrnexus/styles` | 0.8.15 | — |
|
||||
| `@wrnexus/syntax` | 0.8.9 | — |
|
||||
| `@wrnexus/syntax` | 0.8.10 | — |
|
||||
| `@wrnexus/test` | 0.8.9 | — |
|
||||
| `@wrnexus/tracking` | 0.8.8 | — |
|
||||
| `@wrnexus/typecheck` | 0.8.10 | — |
|
||||
| `@wrnexus/ui` | 0.8.20 | — |
|
||||
| `@wrnexus/uploader` | 0.8.10 | Secure upload drivers, policies, client runtime, helper functions, and reusable upload components. |
|
||||
| `@wrnexus/validation` | 0.8.10 | Shared server/browser schemas, validation helpers, form runtime, and reusable error components. |
|
||||
| `@wrnexus/validation` | 0.8.11 | Shared server/browser schemas, validation helpers, form runtime, and reusable error components. |
|
||||
|
||||
## Appendix B — complete audited public export inventory
|
||||
|
||||
@@ -456,9 +456,9 @@ Run `bun run check:production` for the complete production gate. Its chain cover
|
||||
- **./server:** `createRequestStoreContainer`
|
||||
- **./types:** `PersistenceStorage`, `StoreActionContext`, `StoreActionDefinition`, `StoreCombinedState`, `StoreDefinition`, `StoreFunction`, `StoreInstance`, `StoreInstanceCore`, `StoreKind`, `StoreLifecycleContext`, `StoreMutation`, `StorePersistenceConfig`, `StoreRuntime`
|
||||
|
||||
### `@wrnexus/styles` (82 symbols)
|
||||
### `@wrnexus/styles` (76 symbols)
|
||||
|
||||
- **.:** `ACCENT_COOKIE`, `AppConfig`, `BrowserCookieApi`, `BrowserCookieOptions`, `BrowserCookiePreference`, `BrowserCookiesConfig`, `BuildConfig`, `CURRENT_COMPATIBILITY_DATE`, `CURRENT_FRAMEWORK_BEHAVIOUR`, `CompatibilityPolicy`, `CompatibilityReport`, `ConfigIssue`, `ContrastResult`, `CssPerformanceAuditIssue`, `CssTokenAudit`, `CustomThemePalette`, `DEFAULT_THEMES`, `DevToolbarConfig`, `ExperimentalConfig`, `ExplainedConfig`, `FontConfig`, `FontDisplay`, `GoogleFont`, `LocalFontFace`, `MobileConfig`, `Mode`, `NavigationConfig`, `ObservabilityConfig`, `PerformanceConfig`, `PwaConfig`, `ResolvedConfigLayers`, `ResolvedTheme`, `StyleProcessContext`, `StyleSource`, `StylesConfig`, `StylesMode`, `THEME_COOKIE`, `THEME_CSS_HREF`, `THEME_CSS_PREFIX`, `THEME_JS_HREF`, `THEME_PALETTES`, `THEME_PALETTE_NAMES`, `TenancyConfig`, `ThemeAccentConfig`, `ThemeConfig`, `ThemeCookieConfig`, `ThemePaletteName`, `ThemeSemanticColor`, `ThemeToken`, `ThemeTokens`, `activeThemeCssHref`, `auditCssPerformance`, `auditWrnTokens`, `bundleCss`, `contrast`, `defineConfig`, `defineThemeTokens`, `explainAppConfig`, `findStyleEntry`, `fontCspSources`, `headToString`, `isCompatibilityDate`, `loadAppConfig`, `loadEnv`, `loadRawConfig`, `normalizeStyleSources`, `renderActiveThemeCss`, `renderFontHead`, `renderProductionFontHead`, `renderStyles`, `renderThemeCss`, `renderThemeRuntime`, `resolveAccentName`, `resolveBrowserCookieOptions`, `resolveCompatibility`, `resolveConfigLayers`, `resolveProfile`, `resolveThemeConfig`, `resolveThemeName`, `tailwindSourceDirectives`, `themeVar`, `validateAppConfig`
|
||||
- **.:** `ACCENT_COOKIE`, `AppConfig`, `BrowserCookieApi`, `BrowserCookieOptions`, `BrowserCookiePreference`, `BrowserCookiesConfig`, `BuildConfig`, `ConfigIssue`, `ContrastResult`, `CssPerformanceAuditIssue`, `CssTokenAudit`, `CustomThemePalette`, `DEFAULT_THEMES`, `DevToolbarConfig`, `ExperimentalConfig`, `ExplainedConfig`, `FontConfig`, `FontDisplay`, `GoogleFont`, `LocalFontFace`, `MobileConfig`, `Mode`, `NavigationConfig`, `ObservabilityConfig`, `PerformanceConfig`, `PwaConfig`, `ResolvedConfigLayers`, `ResolvedTheme`, `StyleProcessContext`, `StyleSource`, `StylesConfig`, `StylesMode`, `THEME_COOKIE`, `THEME_CSS_HREF`, `THEME_CSS_PREFIX`, `THEME_JS_HREF`, `THEME_PALETTES`, `THEME_PALETTE_NAMES`, `TenancyConfig`, `ThemeAccentConfig`, `ThemeConfig`, `ThemeCookieConfig`, `ThemePaletteName`, `ThemeSemanticColor`, `ThemeToken`, `ThemeTokens`, `activeThemeCssHref`, `auditCssPerformance`, `auditWrnTokens`, `bundleCss`, `contrast`, `defineConfig`, `defineThemeTokens`, `explainAppConfig`, `findStyleEntry`, `fontCspSources`, `headToString`, `loadAppConfig`, `loadEnv`, `loadRawConfig`, `normalizeStyleSources`, `renderActiveThemeCss`, `renderFontHead`, `renderProductionFontHead`, `renderStyles`, `renderThemeCss`, `renderThemeRuntime`, `resolveAccentName`, `resolveBrowserCookieOptions`, `resolveConfigLayers`, `resolveProfile`, `resolveThemeConfig`, `resolveThemeName`, `tailwindSourceDirectives`, `themeVar`, `validateAppConfig`
|
||||
|
||||
### `@wrnexus/syntax` (141 symbols)
|
||||
|
||||
|
||||
@@ -1084,6 +1084,7 @@
|
||||
"BackoffStrategy",
|
||||
"Bucket",
|
||||
"BudgetViolation",
|
||||
"BuiltApiRequest",
|
||||
"Bulkhead",
|
||||
"BulkheadOptions",
|
||||
"CSRF_COOKIE",
|
||||
@@ -1204,6 +1205,7 @@
|
||||
"UploadScanner",
|
||||
"assertTenantAccess",
|
||||
"bridgeRealtime",
|
||||
"buildApiRequest",
|
||||
"cacheControl",
|
||||
"checkPerformanceBudgets",
|
||||
"collectUploads",
|
||||
@@ -1229,6 +1231,7 @@
|
||||
"escapeHtml",
|
||||
"etag",
|
||||
"executionContextFromHttp",
|
||||
"getRequestContext",
|
||||
"getUser",
|
||||
"hashPassword",
|
||||
"isRoomDefinition",
|
||||
@@ -1260,9 +1263,11 @@
|
||||
"requestId",
|
||||
"requestLogger",
|
||||
"requireAuth",
|
||||
"requireRequestContext",
|
||||
"requireTenant",
|
||||
"resilientCall",
|
||||
"resolveRequestUrl",
|
||||
"runWithRequestContext",
|
||||
"sanitizeFilename",
|
||||
"saveUpload",
|
||||
"saveUploadSecure",
|
||||
@@ -1465,6 +1470,7 @@
|
||||
"startServer",
|
||||
"toRequest",
|
||||
"validateRpcCsrf",
|
||||
"withServerFnRequestContext",
|
||||
"writeResponse"
|
||||
],
|
||||
"./serve-entry": []
|
||||
@@ -1834,7 +1840,7 @@
|
||||
"Position",
|
||||
"Range",
|
||||
"TextDocument",
|
||||
"WRN_COMPLETIONS",
|
||||
"WRN_KEYWORDS",
|
||||
"WorkspaceCompletionItem",
|
||||
"clearWorkspaceIndexCache",
|
||||
"completionItems",
|
||||
@@ -1855,7 +1861,11 @@
|
||||
"workspaceCompletionItems",
|
||||
"workspaceSymbolLocations"
|
||||
],
|
||||
"./server": []
|
||||
"./server": [
|
||||
"ApiCallCompletionItem",
|
||||
"apiCallCompletions",
|
||||
"apiCallHover"
|
||||
]
|
||||
},
|
||||
"@wrnexus/mcp": {
|
||||
".": [
|
||||
@@ -2633,10 +2643,6 @@
|
||||
"BrowserCookiePreference",
|
||||
"BrowserCookiesConfig",
|
||||
"BuildConfig",
|
||||
"CURRENT_COMPATIBILITY_DATE",
|
||||
"CURRENT_FRAMEWORK_BEHAVIOUR",
|
||||
"CompatibilityPolicy",
|
||||
"CompatibilityReport",
|
||||
"ConfigIssue",
|
||||
"ContrastResult",
|
||||
"CssPerformanceAuditIssue",
|
||||
@@ -2687,7 +2693,6 @@
|
||||
"findStyleEntry",
|
||||
"fontCspSources",
|
||||
"headToString",
|
||||
"isCompatibilityDate",
|
||||
"loadAppConfig",
|
||||
"loadEnv",
|
||||
"loadRawConfig",
|
||||
@@ -2700,7 +2705,6 @@
|
||||
"renderThemeRuntime",
|
||||
"resolveAccentName",
|
||||
"resolveBrowserCookieOptions",
|
||||
"resolveCompatibility",
|
||||
"resolveConfigLayers",
|
||||
"resolveProfile",
|
||||
"resolveThemeConfig",
|
||||
@@ -2788,6 +2792,7 @@
|
||||
"parseStructuredImports",
|
||||
"positionAt",
|
||||
"runtimeTypeOf",
|
||||
"skipLiteralOrComment",
|
||||
"sliceSource",
|
||||
"stripRuntimeFunctionModifiers",
|
||||
"supportsSyntaxFeature",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,400 @@
|
||||
# Editor Tooling Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** The language server and VS Code extension understand `apis { }`, complete `api.<name>()`, flag the removed constructs, and stop offering syntax the compiler rejects.
|
||||
|
||||
**Architecture:** Four surfaces change independently — the TextMate grammar, the keyword and snippet completions, the server's call completion and hover, and diagnostics for removed constructs. A final task answers by observation whether generated type errors surface inside `.wrn`, which has never been verified.
|
||||
|
||||
**Tech Stack:** Bun, TypeScript, `bun:test`, `node --test`, LSP, TextMate grammars.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-19-editor-tooling-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `client` is one word with several jobs: `client state { }`, `runtime = "client"`, and the `client function` modifier all survive. **Only the `client { }` / `ssr { }` data-block patterns are removed.** Blanket removal would un-highlight constructs that still exist.
|
||||
- Offering a construct the compiler rejects is worse than offering nothing.
|
||||
- **Nothing may claim inline diagnostics work until someone has seen them work.**
|
||||
- The editor bundles embed the compiler and language server — rebuild with `bun run --cwd editors/vscode build`, or the `check:editor-*` gates fail on a stale bundle.
|
||||
- `bun run format` before every commit; the gate is `bun run check:production`.
|
||||
- Do NOT use `node -e`, shell heredocs, or `sed` to write code into files.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Grammar
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `editors/vscode/syntaxes/wrn.tmLanguage.json`
|
||||
- Test: `editors/vscode/test/grammar-apis.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `apis` highlights as a block keyword; entries highlight as declarations.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `editors/vscode/test/grammar-apis.test.js`:
|
||||
|
||||
```js
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { test } = require("node:test");
|
||||
const { readFileSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
|
||||
const grammar = readFileSync(join(__dirname, "../syntaxes/wrn.tmLanguage.json"), "utf8");
|
||||
|
||||
test("the grammar knows the apis block", () => {
|
||||
assert.ok(grammar.includes("apis"), "apis should appear as a block keyword");
|
||||
});
|
||||
|
||||
test("client keeps its highlighting where it is still valid", () => {
|
||||
// client state {}, runtime = "client", and client function all survive.
|
||||
// Only the client {} data block was removed.
|
||||
assert.ok(grammar.includes("client"), "client must still be matched");
|
||||
assert.ok(grammar.includes("shared"), "the shared function modifier must still be matched");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `node --test editors/vscode/test/grammar-apis.test.js`
|
||||
Expected: FAIL on the first assertion — `apis` is absent.
|
||||
|
||||
- [ ] **Step 3: Add `apis`, remove only the data-block patterns**
|
||||
|
||||
Add `apis` to the block-keyword pattern alongside `functions`. Then find the patterns matching `ssr`/`client` as **data blocks** and remove only those. Leave every rule that matches `client` in `client state`, in `runtime` values, and as a function modifier.
|
||||
|
||||
Add a pattern for an entry — `<name> <METHOD> <path>` — so a declaration reads as a declaration.
|
||||
|
||||
- [ ] **Step 4: Run the test and check by eye**
|
||||
|
||||
Run: `node --test editors/vscode/test/grammar-apis.test.js`
|
||||
Expected: PASS. Then open a `.wrn` file using `apis { }`, `client state { }`, `functions { shared function }`, and `runtime = "client"` in VS Code and confirm each still colours correctly. Record what you saw.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
git add editors/vscode
|
||||
git commit -m "feat(editor): highlight the apis block"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Keyword and snippet completion
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/language-server/src/index.ts` (`WRN_KEYWORDS`, ~line 31)
|
||||
- Modify: `editors/vscode/src/completion.js` (block snippets)
|
||||
- Test: `packages/language-server/test/apis-completion.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: nothing.
|
||||
- Produces: `apis` is a known keyword; `ssr` / `client` data-block snippets are gone.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/language-server/test/apis-completion.test.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "bun:test";
|
||||
import { WRN_KEYWORDS } from "../src/index.ts";
|
||||
|
||||
test("apis is a known page-level keyword", () => {
|
||||
expect(WRN_KEYWORDS).toContain("apis");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bun test packages/language-server/test/apis-completion.test.ts`
|
||||
Expected: FAIL — `apis` is missing.
|
||||
|
||||
- [ ] **Step 3: Add the keyword and the snippets**
|
||||
|
||||
Add `"apis"` to `WRN_KEYWORDS`. In `editors/vscode/src/completion.js`, add a container snippet and an entry snippet including the `request` / `response` / `error` sections, and remove any `ssr {` / `client {` data-block snippet.
|
||||
|
||||
- [ ] **Step 4: Run the tests**
|
||||
|
||||
Run: `bun test packages/language-server && bun run --cwd editors/vscode test`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
git add packages/language-server editors/vscode
|
||||
git commit -m "feat(editor): complete the apis block and drop the removed snippets"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `api.` call completion and hover
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/language-server/src/server.ts` (completion and hover handlers)
|
||||
- Test: `packages/language-server/test/api-call-completion.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `ast.dataApis` entries, which carry `name`, `method`, `path`, and `sections`.
|
||||
- Produces: completion items for `api.` and hover detail for a block name.
|
||||
|
||||
This is where the syntax pays off in the editor: the set of legal calls is knowable, so the editor should know it.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/language-server/test/api-call-completion.test.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "bun:test";
|
||||
import { apiCallCompletions, apiCallHover } from "../src/server.ts";
|
||||
|
||||
const SOURCE = `page Search {
|
||||
apis {
|
||||
searchUsers POST /api/users {
|
||||
request { body { name?: string } }
|
||||
response { return data.users }
|
||||
}
|
||||
|
||||
listTeams GET /api/teams {
|
||||
response { return data.teams }
|
||||
}
|
||||
}
|
||||
|
||||
functions {
|
||||
client async function go(): Promise<void> {
|
||||
await api.
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
test("api. offers every declared block with method and path", () => {
|
||||
const items = apiCallCompletions(SOURCE);
|
||||
const labels = items.map((item) => item.label);
|
||||
|
||||
expect(labels).toContain("searchUsers");
|
||||
expect(labels).toContain("listTeams");
|
||||
|
||||
const search = items.find((item) => item.label === "searchUsers")!;
|
||||
expect(search.detail).toContain("POST");
|
||||
expect(search.detail).toContain("/api/users");
|
||||
});
|
||||
|
||||
test("hovering a block name reports its method, path and request fields", () => {
|
||||
const hover = apiCallHover(SOURCE, "searchUsers");
|
||||
|
||||
expect(hover).toContain("POST");
|
||||
expect(hover).toContain("/api/users");
|
||||
expect(hover).toContain("name");
|
||||
});
|
||||
|
||||
test("a page with no apis block offers nothing", () => {
|
||||
expect(apiCallCompletions(`page P { view { <main>x</main> } }`)).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bun test packages/language-server/test/api-call-completion.test.ts`
|
||||
Expected: FAIL — the functions do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement and export both functions**
|
||||
|
||||
Add `apiCallCompletions(source: string)` and `apiCallHover(source: string, name: string)` to `packages/language-server/src/server.ts`, parsing with `@wrnexus/syntax` and reading `ast.dataApis`. Wire `apiCallCompletions` into the `textDocument/completion` handler for positions immediately after `api.`, and `apiCallHover` into `textDocument/hover`.
|
||||
|
||||
The parser must tolerate the half-typed `await api.` in the fixture. If it throws, fall back to returning `[]` rather than failing the request — completion fires while the document does not parse, which is the normal case.
|
||||
|
||||
- [ ] **Step 4: Run the tests**
|
||||
|
||||
Run: `bun test packages/language-server`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
git add packages/language-server
|
||||
git commit -m "feat(language-server): complete and describe api block calls"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: The `api=` attribute in markup
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/language-server/src/html-service.ts`
|
||||
- Test: `packages/language-server/test/api-attribute.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `apiCallCompletions` from Task 3.
|
||||
- Produces: `api="…"` is not flagged as unknown, and completion inside the quotes offers block names.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/language-server/test/api-attribute.test.ts` asserting that (a) an `api="searchUsers"` attribute produces no unknown-attribute diagnostic, and (b) completion inside the quotes offers `searchUsers`. Reuse the fixture shape from Task 3.
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bun test packages/language-server/test/api-attribute.test.ts`
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Teach the HTML service about the attribute**
|
||||
|
||||
Treat `api` as a known attribute on any element, and route completion inside its quotes to `apiCallCompletions`. The value is a call expression, not text — it must not be spell-checked or reformatted as prose.
|
||||
|
||||
- [ ] **Step 4: Run the tests and commit**
|
||||
|
||||
```bash
|
||||
bun test packages/language-server
|
||||
bun run format
|
||||
git add packages/language-server
|
||||
git commit -m "feat(language-server): understand the api binding attribute"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Diagnostics for the removed constructs
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/language-server/src/diagnostics` entry point (wherever `.wrn` diagnostics are produced)
|
||||
- Test: `packages/language-server/test/removed-construct-diagnostics.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: an `ssr { api … }` or `client { api … }` block yields a diagnostic naming `apis { }`, positioned on the block keyword.
|
||||
|
||||
The compiler already rejects these. The editor should say so while typing, and say what to do instead.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/language-server/test/removed-construct-diagnostics.test.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "bun:test";
|
||||
import { diagnoseWrn } from "../src/index.ts";
|
||||
|
||||
test("an ssr data block is flagged and names the replacement", () => {
|
||||
const diagnostics = diagnoseWrn(`page P {
|
||||
ssr { api x GET /api/x { response { return data } } }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`);
|
||||
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0]!.message).toContain("apis");
|
||||
});
|
||||
|
||||
test("client state is not flagged", () => {
|
||||
const diagnostics = diagnoseWrn(`page P {
|
||||
client state { count = 0 }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`);
|
||||
|
||||
expect(diagnostics.filter((item) => item.severity === 1)).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
Use whichever diagnostic entry point the language server exports; keep the assertions identical.
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bun test packages/language-server/test/removed-construct-diagnostics.test.ts`
|
||||
Expected: FAIL — either no diagnostic, or one that does not name `apis`.
|
||||
|
||||
- [ ] **Step 3: Surface the parse error as a diagnostic**
|
||||
|
||||
The parser already throws a message naming `apis { }` for these blocks. Ensure that message reaches the diagnostic with a position on the offending keyword rather than at offset zero.
|
||||
|
||||
- [ ] **Step 4: Run the tests and commit**
|
||||
|
||||
```bash
|
||||
bun test packages/language-server
|
||||
bun run format
|
||||
git add packages/language-server
|
||||
git commit -m "feat(language-server): flag the removed data blocks"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Answer the inline-diagnostics question, then the full gate
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: whatever the observation in Step 2 shows is needed, or none
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: Tasks 1-5.
|
||||
|
||||
The `apis` plan generates type assertions that make `tsc` fail when a block declares a field its endpoint rejects. **Whether that failure appears inside the `.wrn` file has never been confirmed** — it was inferred from reading source. This task settles it by looking.
|
||||
|
||||
- [ ] **Step 1: Rebuild the bundles**
|
||||
|
||||
```bash
|
||||
bun run --cwd editors/vscode build
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Observe, and write down what you see**
|
||||
|
||||
In `examples/basic-app`, add a field to an `apis { }` entry that its endpoint does not accept, and run `bun run --cwd examples/basic-app wrnexus generate types`. Open the page in VS Code and record exactly where the error appears: on the block, only in `app/types/wrnexus.generated.api-checks.ts`, or nowhere.
|
||||
|
||||
Write the answer into the task report. **Do not skip this step and reason about it instead** — that is what left the question open the first time.
|
||||
|
||||
- [ ] **Step 3: Act on what you observed**
|
||||
|
||||
If the error already surfaces usefully on the block, document it and stop.
|
||||
|
||||
If it appears only in the generated file, map the diagnostic back: the generator knows which page and block produced each assertion, so record that mapping when emitting and use it to relocate the diagnostic.
|
||||
|
||||
If that mapping proves larger than this task can hold, **stop and report it as follow-up work** rather than half-building it. Say so plainly in the report.
|
||||
|
||||
- [ ] **Step 4: Remove the temporary field**
|
||||
|
||||
Revert the deliberate error and confirm `bun run typecheck` passes with zero net diff in `examples/basic-app`.
|
||||
|
||||
- [ ] **Step 5: Full gate**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
bun test
|
||||
bun run typecheck
|
||||
bun run --cwd editors/vscode build
|
||||
bun run --cwd editors/vscode test
|
||||
bun run check:production
|
||||
```
|
||||
|
||||
Expected: exit 0 throughout, including `check:editor-compiler`, `check:editor-language-server`, and `check:editor-extension`.
|
||||
|
||||
- [ ] **Step 6: Manual pass, recorded**
|
||||
|
||||
Open the migrated `examples/basic-app` in VS Code and confirm: `apis { }` highlights, `api.` completes with the page's block names, hovering a name shows its method and path, and an `ssr { api … }` block is flagged. Record what you saw in the report — including anything that did not work.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(editor): complete tooling support for the apis block"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes for the executor
|
||||
|
||||
- **`client` is not one thing.** Removing every `client` rule from the grammar would break `client state`, `runtime = "client"`, and `client function`. Only the data-block patterns go.
|
||||
- **The parser must tolerate half-typed input.** Completion fires while the document does not parse; a thrown error must become an empty completion list, not a failed request.
|
||||
- **Step 2 of Task 6 is an observation, not a deduction.** Open the editor and look.
|
||||
- **If a test would still pass with the code it guards deleted, it is not a test.**
|
||||
@@ -0,0 +1,537 @@
|
||||
# Legacy and Config Cleanup Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Delete the compatibility-flag surface, the `"legacy"` function runtime, dead migrations, and deprecated APIs before the framework's first public release.
|
||||
|
||||
**Architecture:** Almost all of this is deletion. Seven config keys are never read by any code, so removing them changes nothing. The one behaviour-sensitive item is the `"legacy"` function runtime, which is mapped to `"shared"` — an equivalent substitution, because an unmarked function is already emitted into both bundles.
|
||||
|
||||
**Tech Stack:** Bun, TypeScript, `bun:test`.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-19-legacy-and-config-cleanup-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- This plan removes configuration. It adds none.
|
||||
- The legacy `api` block forms (bare-body `with ($data)`, and `api` inside `ssr {}` / `client {}`) are **out of scope** — they are replaced by the next plan, not deleted here.
|
||||
- A removed config key must be **rejected loudly**, not silently ignored. Someone with a stale config must be told, not left believing a flag still applies.
|
||||
- `bun run format` before every commit; the repo gate is `bun run check:production`.
|
||||
- The editor bundles embed the compiler — rebuild with `bun run --cwd editors/vscode build` after any `packages/syntax` or `packages/compiler` change, or `check:editor-compiler` fails on a stale bundle.
|
||||
- Do NOT use `node -e`, shell heredocs, or `sed` to write code into files; escaping mangles them silently. Use file editing tools.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Replace the `"legacy"` function runtime with `"shared"`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/syntax/src/v060.ts` (the `FunctionRuntime` type; the default at ~line 209)
|
||||
- Modify: `packages/compiler/src/client-codegen.ts` (membership tests at ~lines 173 and 340)
|
||||
- Modify: `packages/compiler/src/server-codegen.ts` (the `["legacy", "server", "shared"]` list)
|
||||
- Modify: `packages/compiler/src/codegen.ts` (`targetFunctions`, ~line 1310)
|
||||
- Test: `packages/compiler/test/legacy-runtime-removal.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `FunctionRuntime` becomes `"client" | "server" | "shared"`. Later tasks and plans rely on `"legacy"` no longer existing.
|
||||
|
||||
**Why this is equivalent, not a behaviour change:** an unmarked `function foo()` currently parses as `"legacy"`, and both codegens include `"legacy"` in their membership tests — `["legacy", "client", "shared"]` for the browser and `["legacy", "server", "shared"]` for the server. So an unmarked function is already emitted into _both_ bundles, exactly like `shared`. `legacyDefaultRuntime` looks like it should modulate this but is never read.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/compiler/test/legacy-runtime-removal.test.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "bun:test";
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
import { generateTargets } from "../src/targets.ts";
|
||||
|
||||
const SOURCE = `page Probe {
|
||||
functions {
|
||||
function unmarkedHelper() {
|
||||
return "both";
|
||||
}
|
||||
|
||||
client function clientOnly() {
|
||||
return "browser";
|
||||
}
|
||||
|
||||
server function serverOnly() {
|
||||
return "server";
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
test("an unmarked function is emitted into both the browser and server modules", () => {
|
||||
// This is the property the "legacy" runtime provided. Removing the variant
|
||||
// must not change it.
|
||||
const targets = generateTargets(parse(SOURCE));
|
||||
|
||||
expect(targets.browser).toContain("unmarkedHelper");
|
||||
expect(targets.server).toContain("unmarkedHelper");
|
||||
});
|
||||
|
||||
test("marked functions still go only where they belong", () => {
|
||||
const targets = generateTargets(parse(SOURCE));
|
||||
|
||||
expect(targets.browser).toContain("clientOnly");
|
||||
expect(targets.browser).not.toContain("serverOnly");
|
||||
expect(targets.server).toContain("serverOnly");
|
||||
expect(targets.server).not.toContain("clientOnly");
|
||||
});
|
||||
|
||||
test("no emitted target mentions the removed legacy runtime", () => {
|
||||
const targets = generateTargets(parse(SOURCE));
|
||||
|
||||
expect(targets.browser).not.toContain('"legacy"');
|
||||
expect(targets.server).not.toContain('"legacy"');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test and record the baseline**
|
||||
|
||||
Run: `bun test packages/compiler/test/legacy-runtime-removal.test.ts`
|
||||
Expected: the first two tests PASS (they describe current behaviour and must keep passing), the third may already pass. This test file is a **regression guard written before the change**, so a green run here is correct — record the output.
|
||||
|
||||
- [ ] **Step 3: Remove the `"legacy"` variant from the type and parser**
|
||||
|
||||
In `packages/syntax/src/v060.ts`:
|
||||
|
||||
```ts
|
||||
export type FunctionRuntime = "client" | "server" | "shared";
|
||||
```
|
||||
|
||||
And at the parse site (~line 209), change the default:
|
||||
|
||||
```ts
|
||||
let runtime: FunctionRuntime = "shared";
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Drop `"legacy"` from every membership test**
|
||||
|
||||
In `packages/compiler/src/client-codegen.ts`, both occurrences:
|
||||
|
||||
```ts
|
||||
["client", "shared"].includes(fn.runtime),
|
||||
```
|
||||
|
||||
In `packages/compiler/src/server-codegen.ts`:
|
||||
|
||||
```ts
|
||||
const names = ast.runtimeFunctions
|
||||
.filter((fn) => ["server", "shared"].includes(fn.runtime))
|
||||
.map((fn) => fn.name);
|
||||
```
|
||||
|
||||
In `packages/compiler/src/codegen.ts`, `targetFunctions`:
|
||||
|
||||
```ts
|
||||
const runtimes =
|
||||
target === "browser" ? (["client", "shared"] as const) : (["server", "shared"] as const);
|
||||
```
|
||||
|
||||
Search the repo for any remaining `"legacy"` in these packages and remove each — the string must not survive in `packages/syntax` or `packages/compiler`.
|
||||
|
||||
- [ ] **Step 5: Run the tests**
|
||||
|
||||
Run: `bun test packages/syntax packages/compiler`
|
||||
Expected: PASS, including the three guards from Step 1. If the first two now fail, the substitution was not equivalent — stop and report rather than adjusting the test.
|
||||
|
||||
- [ ] **Step 6: Rebuild the editor bundles and commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
bun run --cwd editors/vscode build
|
||||
git add packages/syntax packages/compiler editors/vscode/src
|
||||
git commit -m "refactor: replace the legacy function runtime with shared"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Delete the compatibility surface
|
||||
|
||||
**Files:**
|
||||
|
||||
- Delete: `packages/styles/src/compatibility.ts`
|
||||
- Delete: `packages/cli/src/compatibility-command.ts`
|
||||
- Delete: `packages/cli/test/compatibility.test.ts`
|
||||
- Modify: `packages/styles/src/config.ts` (`FunctionsConfig` ~233, `CompatibilityConfig` ~242, `AppConfig extends CompatibilityPolicy` ~249, the `functions?:` and `compatibility?:` members, the `resolveCompatibility` validation ~619, and the `CompatibilityPolicy` import ~23)
|
||||
- Modify: `packages/styles/src/index.ts` (the `./compatibility.ts` exports at ~lines 39-45)
|
||||
- Modify: `packages/cli/src/index.ts` (dispatch at ~line 295, help text at ~line 75)
|
||||
- Modify: `packages/cli/src/create.ts` (~lines 263, 278-283)
|
||||
- Modify: `packages/cli/src/update.ts` (the config insertion string at ~line 389)
|
||||
- Modify: `packages/styles/test/config.test.ts` (assertions on the removed keys)
|
||||
- Modify: `examples/basic-app/wrnexus.config.ts`
|
||||
- Test: `packages/styles/test/removed-config-keys.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: nothing from Task 1.
|
||||
- Produces: `AppConfig` no longer extends `CompatibilityPolicy` and has no `compatibility` or `functions` members. `@wrnexus/styles` no longer exports `resolveCompatibility`, `isCompatibilityDate`, `CURRENT_COMPATIBILITY_DATE`, `CURRENT_FRAMEWORK_BEHAVIOUR`, `CompatibilityPolicy`, or `CompatibilityReport`.
|
||||
|
||||
**These seven keys are never read.** `legacyEmit`, `legacyEventProps`, `legacyComponentDiscovery`, `stringLayouts`, and `legacyDefaultRuntime` appear only in the type declaration, `create.ts`, and `update.ts`. `compatibilityDate` and `frameworkBehaviour` feed only a printed report and one validation. Removing them changes no behaviour.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/styles/test/removed-config-keys.test.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "bun:test";
|
||||
import { validateConfig } from "../src/config.ts";
|
||||
|
||||
// A stale config must fail loudly. Silently ignoring a removed key leaves
|
||||
// someone believing a flag still applies.
|
||||
const REMOVED = [
|
||||
{ key: "compatibilityDate", config: { compatibilityDate: "2026-08-02" } },
|
||||
{ key: "frameworkBehaviour", config: { frameworkBehaviour: 1 } },
|
||||
{ key: "functions", config: { functions: { legacyDefaultRuntime: "current" } } },
|
||||
{ key: "compatibility", config: { compatibility: { legacyEmit: false } } },
|
||||
];
|
||||
|
||||
for (const { key, config } of REMOVED) {
|
||||
test(`a config still setting "${key}" is rejected with a message naming it`, () => {
|
||||
const issues = validateConfig(config as never);
|
||||
const match = issues.find((issue) => issue.path === key || issue.path.startsWith(`${key}.`));
|
||||
|
||||
expect(match).toBeDefined();
|
||||
expect(match!.severity).toBe("error");
|
||||
expect(match!.message.toLowerCase()).toContain("removed");
|
||||
});
|
||||
}
|
||||
|
||||
test("a config without those keys is accepted", () => {
|
||||
const issues = validateConfig({} as never);
|
||||
|
||||
expect(issues.filter((issue) => issue.severity === "error")).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
If `validateConfig` is not the exported name in `packages/styles/src/config.ts`, use whichever function that module exports for validation and keep the assertions identical.
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bun test packages/styles/test/removed-config-keys.test.ts`
|
||||
Expected: FAIL — the keys are currently accepted, so no issue is produced.
|
||||
|
||||
- [ ] **Step 3: Delete the compatibility module and its command**
|
||||
|
||||
```bash
|
||||
git rm packages/styles/src/compatibility.ts packages/cli/src/compatibility-command.ts packages/cli/test/compatibility.test.ts
|
||||
```
|
||||
|
||||
In `packages/styles/src/index.ts`, remove the whole `./compatibility.ts` export block (both the value exports and the `export type` line).
|
||||
|
||||
In `packages/cli/src/index.ts`, remove the `case "compatibility":` dispatch and the `wrnexus compatibility …` line from the help text.
|
||||
|
||||
- [ ] **Step 4: Remove the config members and add the rejections**
|
||||
|
||||
In `packages/styles/src/config.ts`: delete the `CompatibilityPolicy` import, the `FunctionsConfig` and `CompatibilityConfig` interfaces, the `functions?:` and `compatibility?:` members of `AppConfig`, `extends CompatibilityPolicy` on `AppConfig`, and the `resolveCompatibility` validation block.
|
||||
|
||||
Then add the rejections so a stale config fails loudly:
|
||||
|
||||
```ts
|
||||
const REMOVED_CONFIG_KEYS = [
|
||||
"compatibilityDate",
|
||||
"frameworkBehaviour",
|
||||
"functions",
|
||||
"compatibility",
|
||||
] as const;
|
||||
|
||||
for (const key of REMOVED_CONFIG_KEYS) {
|
||||
if ((config as Record<string, unknown>)[key] !== undefined) {
|
||||
issues.push({
|
||||
path: key,
|
||||
severity: "error",
|
||||
message: "was removed; delete it from the configuration",
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Place this beside the other validation pushes, using whatever local variable that function accumulates issues in.
|
||||
|
||||
- [ ] **Step 5: Stop scaffolding and inserting the keys**
|
||||
|
||||
In `packages/cli/src/create.ts`, delete the `compatibilityDate`, `frameworkBehaviour`, and `functions: { legacyDefaultRuntime: … }` lines from the generated config.
|
||||
|
||||
In `packages/cli/src/update.ts` (~line 389), remove `functions: { legacyDefaultRuntime: "current" },` and the whole `compatibility: { … },` fragment from the insertion string.
|
||||
|
||||
- [ ] **Step 6: Trim the example app config**
|
||||
|
||||
In `examples/basic-app/wrnexus.config.ts`, delete `compatibilityDate`, `frameworkBehaviour`, `functions`, and `compatibility`.
|
||||
|
||||
- [ ] **Step 7: Update the existing config tests**
|
||||
|
||||
`packages/styles/test/config.test.ts` asserts on the removed keys. Remove those assertions. Do not weaken any assertion that is still meaningful — if a test only existed to cover compatibility, delete the whole test.
|
||||
|
||||
- [ ] **Step 8: Run the tests**
|
||||
|
||||
Run: `bun test packages/styles packages/cli`
|
||||
Expected: PASS, including the new rejection tests.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
git add -A packages/styles packages/cli examples/basic-app
|
||||
git commit -m "refactor: delete the compatibility config surface"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Drop migrations below 0.8.0
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/cli/src/update.ts` (all `Migration` entries with `version` below `"0.8.0"`)
|
||||
- Test: `packages/cli/test/update-migration-floor.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: nothing.
|
||||
- Produces: the migration list starts at `0.8.0`.
|
||||
|
||||
`update.ts` holds 111 migrations reaching back to `0.2.8`. The framework is pre-public and the only projects run `0.8.x`, so everything below the floor is unreachable.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/cli/test/update-migration-floor.test.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
test("no migration targets a version below 0.8.0", () => {
|
||||
const source = readFileSync(join(import.meta.dir, "../src/update.ts"), "utf8");
|
||||
const versions = [...source.matchAll(/version:\s*"([0-9.]+)"/g)].map((match) => match[1]!);
|
||||
|
||||
expect(versions.length).toBeGreaterThan(0);
|
||||
|
||||
const belowFloor = versions.filter((version) => {
|
||||
const [major, minor] = version.split(".").map(Number);
|
||||
return major! === 0 && minor! < 8;
|
||||
});
|
||||
|
||||
expect(belowFloor).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bun test packages/cli/test/update-migration-floor.test.ts`
|
||||
Expected: FAIL, listing the `0.2.x`–`0.7.x` versions.
|
||||
|
||||
- [ ] **Step 3: Delete the migrations below the floor**
|
||||
|
||||
Remove every `Migration` object whose `version` is below `"0.8.0"`, along with any helper function that becomes unused as a result. Keep every `0.8.x` entry.
|
||||
|
||||
After deleting, search for now-unreferenced helpers in the file and remove them too — an unused private helper is dead code, and the linter will flag it.
|
||||
|
||||
- [ ] **Step 4: Run the tests**
|
||||
|
||||
Run: `bun test packages/cli`
|
||||
Expected: PASS. Existing update tests that exercised removed migrations should be deleted with them; do not keep a test that asserts nothing.
|
||||
|
||||
- [ ] **Step 5: Verify `update` still runs end to end**
|
||||
|
||||
```bash
|
||||
bun run --cwd examples/basic-app wrnexus update --dry-run
|
||||
```
|
||||
|
||||
Expected: completes without error and reports no pending migrations for an app already at the current version. Paste the output into the commit body if it is short.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
git add packages/cli
|
||||
git commit -m "chore: drop update migrations below 0.8.0"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Remove the deprecated compiler re-export shims
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/compiler/src/codegen.ts` (~line 20, the `./parser.ts` import)
|
||||
- Modify: `packages/compiler/src/native-codegen.ts` (~line 1, the `./parser.ts` import)
|
||||
- Delete: `packages/compiler/src/parser.ts`, `packages/compiler/src/tokenizer.ts`, `packages/compiler/src/types.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: nothing.
|
||||
- Produces: nothing new; imports move to `@wrnexus/syntax`.
|
||||
|
||||
**Order matters.** These three files are two-line re-exports marked deprecated, but `codegen.ts` and `native-codegen.ts` still import from them. Deleting the files first breaks the build.
|
||||
|
||||
- [ ] **Step 1: Repoint the imports**
|
||||
|
||||
In `packages/compiler/src/codegen.ts`, change:
|
||||
|
||||
```ts
|
||||
import { VOID_ELEMENTS, type Attr, type DataMode, type PageAst, type ViewNode } from "./parser.ts";
|
||||
```
|
||||
|
||||
to import the same names from `@wrnexus/syntax`. If the file already imports from `@wrnexus/syntax`, merge them into that one import rather than adding a second.
|
||||
|
||||
In `packages/compiler/src/native-codegen.ts`, change:
|
||||
|
||||
```ts
|
||||
import type { Attr, PageAst, ViewNode } from "./parser.ts";
|
||||
```
|
||||
|
||||
the same way.
|
||||
|
||||
- [ ] **Step 2: Verify nothing else imports the shims**
|
||||
|
||||
```bash
|
||||
grep -rn "from \"./parser.ts\"\|from \"./tokenizer.ts\"\|from \"./types.ts\"" packages/compiler/src/
|
||||
```
|
||||
|
||||
Expected: no output. If anything remains, repoint it before continuing.
|
||||
|
||||
- [ ] **Step 3: Delete the shims**
|
||||
|
||||
```bash
|
||||
git rm packages/compiler/src/parser.ts packages/compiler/src/tokenizer.ts packages/compiler/src/types.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests**
|
||||
|
||||
Run: `bun test packages/compiler && bun run typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Rebuild the editor bundles and commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
bun run --cwd editors/vscode build
|
||||
git add -A packages/compiler editors/vscode/src
|
||||
git commit -m "refactor: drop the deprecated compiler re-export shims"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Remove the deprecated `@wrnexus/auth` options
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/auth/src/http/index.ts` (~lines 56-59)
|
||||
- Modify: `packages/auth/src/plugin.ts` (~lines 55-58)
|
||||
- Modify: `packages/auth/src/types.ts` (~line 423)
|
||||
- Modify: `packages/auth/src/engine.ts` (~lines 163-165 and 179-181)
|
||||
- Modify: `packages/auth/test/http.test.ts`, `packages/auth/test/plugin.test.ts`, `packages/auth/test/engine.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: nothing.
|
||||
- Produces: nothing new. Options are removed, not renamed.
|
||||
|
||||
**These are our own superseded options, not an out-of-date dependency.** The current form is already what `examples/auth-showcase/app/lib/auth.ts` uses — it passes `onSignedIn` / `onSignedOut` to `createAuthEngine`, which is correct and must not change. The deprecated members are the same names on _different_ option objects.
|
||||
|
||||
- [ ] **Step 1: Confirm the blast radius before deleting**
|
||||
|
||||
```bash
|
||||
grep -rn "onSuccessfullSignUp" packages/ examples/ services/ | grep -v dist/
|
||||
grep -rn "onSignedIn\|onSignedOut" packages/ examples/ --include=*.ts | grep -v "packages/auth/src" | grep -v dist/
|
||||
```
|
||||
|
||||
Expected: `onSuccessfullSignUp` has zero references. The `onSignedIn` / `onSignedOut` hits are `packages/auth/test/http.test.ts`, `packages/auth/test/plugin.test.ts`, and `examples/auth-showcase/app/lib/auth.ts`. **The example is the correct `createAuthEngine` form and must be left alone.** Record what you found; if the results differ from this, stop and report before deleting anything.
|
||||
|
||||
- [ ] **Step 2: Remove the option declarations**
|
||||
|
||||
Delete `onSignedIn` and `onSignedOut` (and their `@deprecated` comments) from the options interface in `packages/auth/src/http/index.ts` and from `packages/auth/src/plugin.ts`. Delete `onSuccessfullSignUp` from `packages/auth/src/types.ts`. Delete the `rpId` and `origin` members from both verification signatures in `packages/auth/src/engine.ts`.
|
||||
|
||||
Then remove the code that reads them. The `rpId` / `origin` values are already ignored — verification uses the values bound to the issued challenge — so removing them changes no behaviour.
|
||||
|
||||
- [ ] **Step 3: Update the tests that exercised the deprecated paths**
|
||||
|
||||
`packages/auth/test/http.test.ts` and `plugin.test.ts` pass the deprecated options. Rewrite each to use the `createAuthEngine` form where the test is still meaningful, and delete the test where its only purpose was to cover the deprecated alias.
|
||||
|
||||
`packages/auth/test/engine.test.ts` passes `rpId` / `origin` to verification. Remove those arguments; the assertions on the verification result should be unchanged, which is the evidence that the options were inert.
|
||||
|
||||
- [ ] **Step 4: Run the tests**
|
||||
|
||||
Run: `bun test packages/auth && bun run typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Confirm no `@deprecated` markers remain in auth**
|
||||
|
||||
```bash
|
||||
grep -rn "@deprecated" packages/auth/src/
|
||||
```
|
||||
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
git add -A packages/auth
|
||||
git commit -m "refactor: remove the deprecated auth options"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Full gate
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: whatever the gate reports as stale (generated types, public API baseline, editor bundles)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: Tasks 1-5.
|
||||
- Produces: a green `check:production`.
|
||||
|
||||
- [ ] **Step 1: Rebuild the editor bundles**
|
||||
|
||||
```bash
|
||||
bun run --cwd editors/vscode build
|
||||
```
|
||||
|
||||
The compiler and language server are embedded there and both changed.
|
||||
|
||||
- [ ] **Step 2: Run the full gate**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
bun test
|
||||
bun run typecheck
|
||||
bun run check:production
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Regenerate anything the gate reports as stale**
|
||||
|
||||
`check:public-api` fails when exports change — and this plan removed several from `@wrnexus/styles`. Run `bun run generate:public-api`, then **read the diff and confirm it is removals only**. An unexpected addition means something was exported by accident.
|
||||
|
||||
`check:generated-types` may need `bun run --cwd examples/basic-app wrnexus generate types`.
|
||||
|
||||
- [ ] **Step 4: Re-run the gate until green**
|
||||
|
||||
```bash
|
||||
bun run check:production
|
||||
```
|
||||
|
||||
Expected: exit 0.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: regenerate baselines after the legacy cleanup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes for the executor
|
||||
|
||||
- **The seven config keys are dead.** If you find code that actually reads one, stop and report — the spec's central claim would be wrong and the plan needs revisiting.
|
||||
- **Task 1 is the only behaviour-sensitive change.** Its first two tests describe current behaviour and must pass both before and after. If they fail after, the substitution was not equivalent; report rather than editing the test.
|
||||
- **The auth example is already correct.** `examples/auth-showcase` uses `createAuthEngine({ onSignedIn })`, which is the current API, not the deprecated one.
|
||||
- **If a test would still pass with the code it guards deleted, it is not a test.** Delete the implementation, watch it fail, restore it.
|
||||
@@ -0,0 +1,496 @@
|
||||
# `wrnexus update` Migration Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** One `wrnexus update` carries an existing project from today's syntax to the syntax left by the cleanup and `apis { }` plans — or refuses precisely, naming the file and the reason.
|
||||
|
||||
**Architecture:** These are new `Migration` entries in the existing `update.ts` framework, which already has dry-run support and a report that separates automatic changes from ones needing review. `.wrn` rewriting parses with `@wrnexus/syntax` and re-emits through `formatWrn`, both already imported there.
|
||||
|
||||
**Tech Stack:** Bun, TypeScript, `bun:test`, `@wrnexus/syntax`.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-19-update-migration-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **A file is transformed correctly, or it is left untouched and reported.** There is no third outcome — never a partial rewrite.
|
||||
- Every migration is **idempotent**: running it twice changes nothing the second time.
|
||||
- **Dry-run reports exactly what a real run would change**, and writes nothing.
|
||||
- A run with anything in `needsReview` or `parseFailures` **exits non-zero**, so a scripted upgrade cannot appear to succeed while leaving a project half-migrated.
|
||||
- Migrations attach to the release that ships the breaking change, above the `0.8.0` floor.
|
||||
- `bun run format` before every commit; the gate is `bun run check:production`.
|
||||
- Do NOT use `node -e`, shell heredocs, or `sed` to write code into files.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Remove the dead config keys
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/cli/src/update.ts` (add a `Migration`)
|
||||
- Test: `packages/cli/test/migrate-config-keys.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: a migration with `id: "remove-dead-config-keys"`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/cli/test/migrate-config-keys.test.ts`:
|
||||
|
||||
```ts
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { runMigrations } from "../src/update.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const CONFIG = `export default {
|
||||
compatibilityDate: "2026-08-02",
|
||||
frameworkBehaviour: 1,
|
||||
functions: { legacyDefaultRuntime: "current" },
|
||||
compatibility: { legacyEmit: false, stringLayouts: false },
|
||||
observability: { sampleRate: 1 },
|
||||
};
|
||||
`;
|
||||
|
||||
function project(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-migrate-"));
|
||||
roots.push(root);
|
||||
mkdirSync(join(root, "app"), { recursive: true });
|
||||
writeFileSync(join(root, "wrnexus.config.ts"), CONFIG);
|
||||
return root;
|
||||
}
|
||||
|
||||
test("the removed keys are deleted and the rest is kept", async () => {
|
||||
const root = project();
|
||||
await runMigrations({ appRoot: root, from: "0.8.0", to: "0.9.0", dryRun: false });
|
||||
const config = readFileSync(join(root, "wrnexus.config.ts"), "utf8");
|
||||
|
||||
expect(config).not.toContain("compatibilityDate");
|
||||
expect(config).not.toContain("frameworkBehaviour");
|
||||
expect(config).not.toContain("legacyDefaultRuntime");
|
||||
expect(config).not.toContain("legacyEmit");
|
||||
expect(config).toContain("observability");
|
||||
});
|
||||
|
||||
test("running it twice changes nothing the second time", async () => {
|
||||
const root = project();
|
||||
await runMigrations({ appRoot: root, from: "0.8.0", to: "0.9.0", dryRun: false });
|
||||
const once = readFileSync(join(root, "wrnexus.config.ts"), "utf8");
|
||||
await runMigrations({ appRoot: root, from: "0.8.0", to: "0.9.0", dryRun: false });
|
||||
|
||||
expect(readFileSync(join(root, "wrnexus.config.ts"), "utf8")).toBe(once);
|
||||
});
|
||||
|
||||
test("a dry run writes nothing", async () => {
|
||||
const root = project();
|
||||
await runMigrations({ appRoot: root, from: "0.8.0", to: "0.9.0", dryRun: true });
|
||||
|
||||
expect(readFileSync(join(root, "wrnexus.config.ts"), "utf8")).toBe(CONFIG);
|
||||
});
|
||||
```
|
||||
|
||||
Use whatever entry point `update.ts` exports for running migrations; if the name differs from `runMigrations`, adapt the calls and keep the assertions identical.
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bun test packages/cli/test/migrate-config-keys.test.ts`
|
||||
Expected: FAIL — the keys survive.
|
||||
|
||||
- [ ] **Step 3: Add the migration**
|
||||
|
||||
Append to the migration list in `packages/cli/src/update.ts`:
|
||||
|
||||
```ts
|
||||
{
|
||||
version: "0.9.0",
|
||||
id: "remove-dead-config-keys",
|
||||
description: "Delete compatibilityDate, frameworkBehaviour, functions, and compatibility",
|
||||
apply(ctx) {
|
||||
const file = join(ctx.appRoot, "wrnexus.config.ts");
|
||||
if (!existsSync(file)) return;
|
||||
|
||||
const before = readFileSync(file, "utf8");
|
||||
// Each key is a whole property line or block; removing the line leaves
|
||||
// valid TypeScript because these are always object members.
|
||||
const after = before
|
||||
.replace(/^\s*compatibilityDate:.*\n/m, "")
|
||||
.replace(/^\s*frameworkBehaviour:.*\n/m, "")
|
||||
.replace(/^\s*functions:\s*\{[^}]*\},?\s*\n/m, "")
|
||||
.replace(/^\s*compatibility:\s*\{[^}]*\},?\s*\n/m, "");
|
||||
|
||||
if (after === before) return;
|
||||
|
||||
ctx.report.changedAutomatically.push(`${file}: removed dead compatibility keys`);
|
||||
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests**
|
||||
|
||||
Run: `bun test packages/cli`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
git add packages/cli
|
||||
git commit -m "feat(cli): migrate away the dead config keys"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Move `ssr { api … }` / `client { api … }` into `apis { }`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/cli/src/migrations/apis-block.ts`
|
||||
- Modify: `packages/cli/src/update.ts` (register the migration)
|
||||
- Test: `packages/cli/test/migrate-apis-block.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `migrateApisBlock(source: string): { source: string; changed: boolean } | { skip: string }` — a pure function over `.wrn` text, so it is testable without a filesystem. `skip` carries the human-readable reason.
|
||||
|
||||
Sectioned bodies carry across unchanged, because the payload is already bound to `data`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/cli/test/migrate-apis-block.test.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "bun:test";
|
||||
import { migrateApisBlock } from "../src/migrations/apis-block.ts";
|
||||
|
||||
const SOURCE = `page Search {
|
||||
client {
|
||||
api searchUsers POST /api/users {
|
||||
request { body { name?: string } }
|
||||
response { return data.users }
|
||||
error { return [] }
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
test("a client api entry moves into an apis block", () => {
|
||||
const result = migrateApisBlock(SOURCE) as { source: string; changed: boolean };
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.source).toContain("apis {");
|
||||
expect(result.source).toContain("searchUsers POST /api/users");
|
||||
expect(result.source).not.toContain("client {\n api");
|
||||
});
|
||||
|
||||
test("the sections survive unchanged", () => {
|
||||
const result = migrateApisBlock(SOURCE) as { source: string };
|
||||
|
||||
expect(result.source).toContain("return data.users");
|
||||
expect(result.source).toContain("return []");
|
||||
});
|
||||
|
||||
test("running it on migrated source changes nothing", () => {
|
||||
const once = (migrateApisBlock(SOURCE) as { source: string }).source;
|
||||
const twice = migrateApisBlock(once) as { source: string; changed: boolean };
|
||||
|
||||
expect(twice.changed).toBe(false);
|
||||
expect(twice.source).toBe(once);
|
||||
});
|
||||
|
||||
test("a name declared in both modes is skipped with a reason", () => {
|
||||
const clash = `page P {
|
||||
ssr { api dup GET /api/a { response { return data } } }
|
||||
client { api dup GET /api/a { response { return data } } }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
const result = migrateApisBlock(clash) as { skip: string };
|
||||
|
||||
expect(result.skip).toContain("dup");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bun test packages/cli/test/migrate-apis-block.test.ts`
|
||||
Expected: FAIL — the module does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the transform**
|
||||
|
||||
Create `packages/cli/src/migrations/apis-block.ts`. Parse with `parse` from `@wrnexus/syntax` to find the entries and validate the file, collect every `api` entry from `ssr` / `client` blocks, detect duplicate names across modes and return `{ skip }` when found, then emit one `apis { }` block and delete the now-empty mode blocks. Re-emit through `formatWrn`.
|
||||
|
||||
Detect already-migrated input by checking whether the source has an `apis` block and no mode data blocks; return `{ source, changed: false }`.
|
||||
|
||||
- [ ] **Step 4: Register it**
|
||||
|
||||
Add a `Migration` with `id: "move-api-blocks"` that walks `app/**/*.wrn`, calls `migrateApisBlock`, and routes the outcome: a change goes to `changedAutomatically`, a `skip` goes to `needsReview` with the file and reason, and a `parse` failure goes to `parseFailures` with the file left untouched.
|
||||
|
||||
- [ ] **Step 5: Run the tests**
|
||||
|
||||
Run: `bun test packages/cli`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
git add packages/cli
|
||||
git commit -m "feat(cli): migrate api entries into the apis block"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Move mode-scoped helpers into `functions { shared … }`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/cli/src/migrations/mode-functions.ts`
|
||||
- Modify: `packages/cli/src/update.ts`
|
||||
- Test: `packages/cli/test/migrate-mode-functions.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `migrateModeFunctions(source: string): { source: string; changed: boolean } | { skip: string }`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/cli/test/migrate-mode-functions.test.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "bun:test";
|
||||
import { migrateModeFunctions } from "../src/migrations/mode-functions.ts";
|
||||
|
||||
const SOURCE = `page Hello {
|
||||
ssr {
|
||||
functions {
|
||||
function userNames(users) {
|
||||
return users.map((user) => user.name).join(", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
test("a mode helper becomes a shared function", () => {
|
||||
const result = migrateModeFunctions(SOURCE) as { source: string; changed: boolean };
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.source).toContain("shared function userNames");
|
||||
expect(result.source).not.toContain("ssr {");
|
||||
});
|
||||
|
||||
test("running it again changes nothing", () => {
|
||||
const once = (migrateModeFunctions(SOURCE) as { source: string }).source;
|
||||
const twice = migrateModeFunctions(once) as { changed: boolean; source: string };
|
||||
|
||||
expect(twice.changed).toBe(false);
|
||||
expect(twice.source).toBe(once);
|
||||
});
|
||||
|
||||
test("a name that already exists at page level is skipped with a reason", () => {
|
||||
const clash = `page P {
|
||||
functions { shared function userNames() { return "" } }
|
||||
ssr { functions { function userNames(users) { return "" } } }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
const result = migrateModeFunctions(clash) as { skip: string };
|
||||
|
||||
expect(result.skip).toContain("userNames");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bun test packages/cli/test/migrate-mode-functions.test.ts`
|
||||
Expected: FAIL — the module does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement and register**
|
||||
|
||||
Create the module following Task 2's shape: relocate each mode-scoped function into the page-level `functions { }` with the `shared` modifier, skipping the file with a reason when a name already exists there. Register a `Migration` with `id: "move-mode-functions"` that routes outcomes to the same three report buckets.
|
||||
|
||||
- [ ] **Step 4: Run the tests**
|
||||
|
||||
Run: `bun test packages/cli`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
git add packages/cli
|
||||
git commit -m "feat(cli): migrate mode-scoped helpers to shared functions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Detect legacy bare-body blocks and report them — do not rewrite
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/cli/src/migrations/legacy-api-body.ts`
|
||||
- Modify: `packages/cli/src/update.ts`
|
||||
- Test: `packages/cli/test/migrate-legacy-api-body.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `detectLegacyApiBodies(source: string): { name: string; freeIdentifiers: string[] }[]`.
|
||||
|
||||
**This transform is deliberately manual, and the test pins that.** A legacy bare body is evaluated inside `with ($data ?? {})`, so it references payload fields as bare identifiers. Converting `return userNames(users)` needs `data.users` — but **nothing in the source distinguishes `users` (payload) from `userNames` (page helper)**. The response shape belongs to the route, which may not be typed. A migration that guessed would emit code that compiles and is wrong.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/cli/test/migrate-legacy-api-body.test.ts`:
|
||||
|
||||
```ts
|
||||
import { expect, test } from "bun:test";
|
||||
import { detectLegacyApiBodies } from "../src/migrations/legacy-api-body.ts";
|
||||
|
||||
const SOURCE = `page Hello {
|
||||
ssr {
|
||||
api ssrUsers GET /api/users/ssr {
|
||||
return userNames(users)
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
test("a legacy bare body is detected with its free identifiers", () => {
|
||||
const found = detectLegacyApiBodies(SOURCE);
|
||||
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]!.name).toBe("ssrUsers");
|
||||
expect(found[0]!.freeIdentifiers).toContain("users");
|
||||
expect(found[0]!.freeIdentifiers).toContain("userNames");
|
||||
});
|
||||
|
||||
test("a sectioned block is not reported", () => {
|
||||
const sectioned = `page P {
|
||||
apis { x GET /api/x { response { return data.users } } }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(detectLegacyApiBodies(sectioned)).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bun test packages/cli/test/migrate-legacy-api-body.test.ts`
|
||||
Expected: FAIL — the module does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement detection only**
|
||||
|
||||
Create the module. Find `api` entries whose `sections` is absent (the bare-body form), and collect the free identifiers in the body — identifiers that are not declared locally and are not JavaScript globals. Return them. **Write no transform.**
|
||||
|
||||
- [ ] **Step 4: Register a report-only migration**
|
||||
|
||||
Add a `Migration` with `id: "report-legacy-api-bodies"` that pushes one `needsReview` entry per block, naming the file, the block, and the identifiers, and leaves the file byte-identical. Have `wrnexus update` print a short line explaining why this one is manual: the payload fields cannot be told apart from page helpers without knowing the route's response shape.
|
||||
|
||||
- [ ] **Step 5: Write the byte-identical test**
|
||||
|
||||
Add a test that runs the full migration over a fixture project containing a legacy bare body and asserts the file's contents are unchanged afterwards, and that the report names the block. **This is the most important test in the plan** — it pins that the migration does not attempt the rewrite.
|
||||
|
||||
- [ ] **Step 6: Run the tests**
|
||||
|
||||
Run: `bun test packages/cli`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
git add packages/cli
|
||||
git commit -m "feat(cli): report legacy api bodies for manual migration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Exit code, output order, and the end-to-end run
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/cli/src/update.ts` (the command's output and exit code)
|
||||
- Test: `packages/cli/test/update-exit-code.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: Tasks 1-4.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/cli/test/update-exit-code.test.ts` asserting that a project with a legacy bare body produces a non-zero exit, and a fully-migratable project produces zero. Use the same temp-project pattern as Task 1.
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bun test packages/cli/test/update-exit-code.test.ts`
|
||||
Expected: FAIL — the command currently exits zero regardless.
|
||||
|
||||
- [ ] **Step 3: Implement the output and exit code**
|
||||
|
||||
Print, in order: what changed, what needs review and why, what failed to parse. Exit non-zero when `needsReview` or `parseFailures` is non-empty.
|
||||
|
||||
- [ ] **Step 4: Migrate the example app with the command alone**
|
||||
|
||||
```bash
|
||||
bun run --cwd examples/basic-app wrnexus update
|
||||
```
|
||||
|
||||
Expected: the `.wrn` pages are migrated by the tool, not by hand. **If the framework's own example cannot be migrated by the tool, the tool is not finished** — report that rather than editing the example manually.
|
||||
|
||||
- [ ] **Step 5: Verify the migrated example**
|
||||
|
||||
```bash
|
||||
bun run --cwd examples/basic-app build
|
||||
bun test
|
||||
bun run typecheck
|
||||
bun run check:production
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
git add -A
|
||||
git commit -m "feat(cli): fail the update when a project needs manual review"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes for the executor
|
||||
|
||||
- **Never half-rewrite a file.** Parse first; on failure, record and move on. If any part of a file's transform cannot complete, skip the whole file and report it.
|
||||
- **Idempotency is not optional.** Every transform detects already-migrated input.
|
||||
- **Task 4 writes no transform.** If you find yourself building one, stop — the spec explains why a correct automatic answer does not exist.
|
||||
|
||||
---
|
||||
|
||||
## Post-execution note (2026-08-20)
|
||||
|
||||
**Step 4 of Task 5 did not prove what it was written to prove.** By the time it ran,
|
||||
`examples/basic-app` had already been moved to the current syntax by hand in commit
|
||||
`890d6106`, so `wrnexus update` migrated a no-op target: 0 changed, 0 needing review,
|
||||
0 parse failures. The framework's own example therefore does NOT demonstrate the tool
|
||||
against real legacy syntax.
|
||||
|
||||
The guarantee instead rests on fixture-based tests that drive the real `updateApp` /
|
||||
`runUpdate` entry points over projects containing genuine `ssr {}` / `client {}` source
|
||||
-- including the mixed-content page that only converges because `move-mode-functions`
|
||||
is registered before `move-api-blocks`. That is adequate coverage, but it is a weaker
|
||||
kind of evidence than the plan intended, and it is recorded here rather than quietly
|
||||
counted as a pass.
|
||||
@@ -0,0 +1,250 @@
|
||||
# The `apis { }` block and location-transparent dispatch — Design
|
||||
|
||||
**Date:** 2026-08-19
|
||||
**Status:** Approved for implementation
|
||||
**Scope:** One container block for API declarations, callable from anywhere as `api.<name>(input)`,
|
||||
dispatched in-process on the server and over `fetch` in the browser.
|
||||
|
||||
**Depends on:** `2026-08-19-legacy-and-config-cleanup-design.md`. That spec removes the
|
||||
compatibility surface and the `"legacy"` function runtime; this one removes the legacy `api` forms
|
||||
by replacing them.
|
||||
|
||||
## Goal
|
||||
|
||||
Today a `.wrn` page has several unrelated ways to reach data: an `api` block inside `ssr {}`, an
|
||||
`api` block inside `client {}`, a `server function` over RPC, a `load server` block, and hand-written
|
||||
`fetch`. Each has different placement, different capabilities, and a different call shape. The
|
||||
result is that "how do I fetch this?" has no single answer, and the answer that is right depends on
|
||||
where the code happens to sit.
|
||||
|
||||
This collapses the API half of that into one declaration and one call, and draws a line a developer
|
||||
can hold in their head:
|
||||
|
||||
- **`api.<name>()`** — call an API route that exists as a real HTTP endpoint.
|
||||
- **`server.<name>()`** — call server-side logic that has no public surface.
|
||||
|
||||
The question becomes "is there a route?", not "where am I running?". `server.<name>()` is unchanged
|
||||
by this spec.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Changing `server function`, actions, or `load` blocks. They keep working exactly as they do.
|
||||
- External or third-party API targets. Still this app's `/api/*` routes only, preserving
|
||||
`isSafeApiPath`.
|
||||
- Any new configuration key. This spec adds none.
|
||||
- Author-settable request headers, still excluded.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Question | Decision |
|
||||
| ---------------------- | ---------------------------------------------------------------------- |
|
||||
| Container | Page-level `apis { }`, matching `functions { }` |
|
||||
| Declaration | Mode-less — no `ssr` / `client` prefix |
|
||||
| Call | `api.<name>(input)` from any context |
|
||||
| Dispatch | Chosen at build time: in-process on the server, `fetch` in the browser |
|
||||
| Server request context | `AsyncLocalStorage` |
|
||||
| Browser emission | Only blocks the client actually calls |
|
||||
| Render binding | `api="name"`, `api="name()"`, `api="name({ … })"` |
|
||||
| Old forms | Removed and replaced |
|
||||
|
||||
## Syntax
|
||||
|
||||
```wrn
|
||||
apis {
|
||||
searchUsers POST /api/users {
|
||||
request {
|
||||
body {
|
||||
name?: string
|
||||
age?: number
|
||||
}
|
||||
}
|
||||
|
||||
response { return data.data.users }
|
||||
error { return [] }
|
||||
}
|
||||
|
||||
listTeams GET /api/teams {
|
||||
response { return data.data.teams }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`GET` and `HEAD` declare `parameters`, which become a query string; other methods declare `body`,
|
||||
sent as JSON. The path stays a plain literal so `isSafeApiPath` is satisfied without relaxing it.
|
||||
|
||||
Names are unique per page — `codegen.ts` already rejects duplicates, and that stays.
|
||||
|
||||
### Why the container
|
||||
|
||||
`functions { }` puts the modifier first inside a container named for the concept:
|
||||
`functions { client function x() }`. Today's api form inverts that — `client { api x … }` — and is
|
||||
the only construct in the language shaped that way, which is why APIs are hard to find in a page.
|
||||
`apis` (plural) is the container; `api` remains the call namespace and the binding attribute.
|
||||
|
||||
## Calling
|
||||
|
||||
```wrn
|
||||
functions {
|
||||
client async function search(): Promise<void> {
|
||||
users = await api.searchUsers({ name: nameFilter })
|
||||
}
|
||||
}
|
||||
|
||||
load server directory {
|
||||
return await api.searchUsers({ name: ctx.url.searchParams.get("name") ?? "" })
|
||||
}
|
||||
```
|
||||
|
||||
The same call works in client functions, `load` blocks, actions, and server functions. Nothing at
|
||||
the call site says where it runs.
|
||||
|
||||
### Dispatch
|
||||
|
||||
Dispatch is decided at build time, not by a runtime check. The compiler emits an `api` object into
|
||||
each execution context, with the same member names and different transports behind them:
|
||||
|
||||
- **Browser** — the existing `callApi` transport: query string or JSON body, `credentials:
|
||||
"same-origin"`, `x-csrf-token` on non-GET, the established failure contract.
|
||||
- **Server** — `callApiFromContext`, which dispatches to the route in-process. No network hop, no
|
||||
serialisation round trip, and the caller's cookies, session, and locals are already forwarded.
|
||||
|
||||
Two objects that never meet, so nothing ships to the browser that only the server uses, and the
|
||||
runtime never branches on `typeof window` for something known at compile time.
|
||||
|
||||
### `callApiFromContext` must learn to carry input
|
||||
|
||||
It currently builds `new Request(apiUrl, { method, headers })` — no body, no query string. It has to
|
||||
assemble the request the same way the browser transport does, from the same rules, or the two sides
|
||||
will disagree about what an identical call sends. **The assembly rules must be shared, not
|
||||
reimplemented**: a second copy will drift, and the drift will be silent because each side is tested
|
||||
separately.
|
||||
|
||||
### The request context
|
||||
|
||||
A server-side call needs `ctx` to resolve the URL and forward cookies and session. `ctx` is not
|
||||
uniformly available: `load` blocks have it, schema actions have it as a second parameter, plain
|
||||
actions and server functions have neither.
|
||||
|
||||
The server stores the request context in an `AsyncLocalStorage` at request entry, and the server
|
||||
`api` object reads it. This is new machinery — the framework uses none today — and it must be
|
||||
established in both the dev server and the production server, or a call that works in development
|
||||
fails in production.
|
||||
|
||||
When no context is present, the call throws with a message naming the block and explaining that an
|
||||
API call needs a request context — never a silent `undefined`.
|
||||
|
||||
## Emission
|
||||
|
||||
A block's `response` and `error` bodies are page code. They ship to the browser **only when a
|
||||
client-side call to that block exists**. The compiler already knows which `api.<name>()` calls
|
||||
appear in client functions.
|
||||
|
||||
This keeps server-only transforms off the wire and the client bundle proportional to what it uses.
|
||||
The consequence to know: adding the first client call to a block starts shipping that block's
|
||||
bodies. Anything secret belongs in the endpoint, not in a `response` body.
|
||||
|
||||
## Render binding
|
||||
|
||||
A block can be bound into markup, which calls it during render and substitutes the result:
|
||||
|
||||
```wrn
|
||||
<p api="listTeams">loading…</p>
|
||||
<ul api="searchUsers({ name: nameFilter })">
|
||||
{#each searchUsers as person}<li>{person.name}</li>{/each}
|
||||
</ul>
|
||||
```
|
||||
|
||||
Three accepted forms: `api="name"`, `api="name()"` — equivalent — and `api="name({ … })"`, which
|
||||
passes arguments. The argument expression is evaluated in the same scope as other view expressions
|
||||
at render time. This mirrors `@click="search()"`, so it introduces no new escaping or naming rules.
|
||||
|
||||
### The edge this creates, stated plainly
|
||||
|
||||
A block that is both render-bound and called from code **runs twice** — once for the binding, once
|
||||
for the call. They are two different lifecycles wearing one name, and no deduplication is attempted:
|
||||
a render-time fetch and a user-triggered fetch are usually meant to be different requests, and
|
||||
silently collapsing them would be worse than the duplication. Authors binding a block _and_ calling
|
||||
it should expect two requests.
|
||||
|
||||
## Replacing the old forms
|
||||
|
||||
`ssr { … }` and `client { … }` data blocks are removed. Each could contain only two things, and both
|
||||
have a home:
|
||||
|
||||
| Old | New |
|
||||
| ------------------------------------------------ | ------------------------------------------------------ |
|
||||
| `ssr { api x … }` / `client { api x … }` | `apis { x … }` |
|
||||
| `ssr { functions { function helper() } }` | `functions { shared function helper() }` |
|
||||
| legacy bare-body `api x GET /p { return users }` | `apis { x GET /p { response { return data.users } } }` |
|
||||
|
||||
The legacy bare body injected the payload with `with ($data ?? {})`, which is **untypeable** —
|
||||
TypeScript cannot see through `with`, and that is the entire reason sectioned blocks bind a named
|
||||
`data`. Removing the legacy form removes that fork: one payload binding, typed.
|
||||
|
||||
`client state { }` is a different construct that shares the keyword and is **not** affected.
|
||||
|
||||
`examples/basic-app/app/pages/hello.wrn` uses both old forms and is the migration's worked example.
|
||||
|
||||
## Type safety
|
||||
|
||||
Assertions are generated into `app/types/wrnexus.generated.api-checks.ts` and checked by the
|
||||
project's own `tsc`, as established. Two changes follow from mode-less declarations:
|
||||
|
||||
- The current generator skips blocks whose `mode !== "client"`. That skip exists because an `ssr`
|
||||
block could never declare a `request`. Mode-less blocks invalidate the reasoning, so **every block
|
||||
with declared fields gets an assertion**.
|
||||
- The zero-field skip stays: a block with no declared fields has nothing to check, and asserting
|
||||
`Record<string, never>` against a contract fails spuriously.
|
||||
|
||||
## Known edges
|
||||
|
||||
- **`state api` stops working.** The name is currently excluded from destructuring only when a page
|
||||
has client api blocks, so pages without them keep using it. Once `api` is universal that
|
||||
protection goes, and a page with `state api` breaks. It is a build-time failure, not silent.
|
||||
- **Every block is browser-reachable in principle.** The routes were already publicly reachable by
|
||||
`fetch`, so this exposes no new surface — but a block is no longer implicitly server-only by
|
||||
virtue of its placement.
|
||||
|
||||
## Testing
|
||||
|
||||
**Parser**
|
||||
|
||||
- `apis { }` parses multiple mode-less entries; duplicate names are rejected.
|
||||
- `ssr { api … }` and `client { api … }` are rejected with a message naming the replacement.
|
||||
- All three binding forms parse, including an argument expression containing a nested object.
|
||||
|
||||
**Dispatch**
|
||||
|
||||
- A client-side call issues one `fetch` with the expected URL, method, body, and CSRF header.
|
||||
- A server-side call dispatches in-process and issues **no** network request — asserted by observing
|
||||
that no fetch occurs, not merely that the result is right.
|
||||
- A server-side call with no request context throws a message naming the block.
|
||||
- The same declared input produces the same request on both sides — the shared-assembly guard.
|
||||
|
||||
**Emission**
|
||||
|
||||
- A block called only from server code does not appear in the browser module.
|
||||
- A block called from a client function does.
|
||||
|
||||
**Render binding**
|
||||
|
||||
- Each of the three forms renders the resolved value.
|
||||
- A bound block that is also called issues two requests, pinning the documented edge.
|
||||
|
||||
**Type safety**
|
||||
|
||||
- A block declaring a field its endpoint rejects fails `bun run typecheck`, asserted by running
|
||||
`tsc` and reading its diagnostics — not by matching generated text.
|
||||
|
||||
**End to end**
|
||||
|
||||
- `examples/basic-app` migrated to `apis { }`, driven in a browser: a client call updates state, a
|
||||
render-bound block appears in the served HTML, and a deliberately failing call takes the `error`
|
||||
path.
|
||||
|
||||
## Deferred
|
||||
|
||||
- External API targets, and the allowlist and credential handling they need.
|
||||
- Author-settable headers.
|
||||
- Deduplicating a render-bound block against a code call.
|
||||
- Response caching and request de-duplication.
|
||||
@@ -0,0 +1,131 @@
|
||||
# Editor support for the new syntax — Design
|
||||
|
||||
**Date:** 2026-08-19
|
||||
**Status:** Approved for implementation
|
||||
**Scope:** Language server and VS Code extension updated for `apis { }`, the `api.<name>()` call, and
|
||||
the removal of the mode data blocks.
|
||||
|
||||
**Depends on:** `2026-08-19-apis-block-design.md`. The syntax must exist before the editor can
|
||||
describe it.
|
||||
|
||||
## Goal
|
||||
|
||||
A syntax change that the editor does not know about is worse than no change: valid code is
|
||||
red-underlined, removed constructs still autocomplete, and the new block gets no highlighting. This
|
||||
spec keeps the tooling level with the language.
|
||||
|
||||
It also settles a question left open by earlier work: whether type errors from the generated
|
||||
assertions actually appear inside the `.wrn` file, or only in the generated file. That was never
|
||||
verified — it was inferred from reading source — and inference is not good enough for the thing
|
||||
developers rely on to tell them their code is wrong.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- New editor features unrelated to this syntax change.
|
||||
- Editors other than VS Code beyond what standard LSP provides.
|
||||
- HTML formatting — `formatWrn` still owns markup.
|
||||
|
||||
## What exists
|
||||
|
||||
- **Grammar:** `editors/vscode/syntaxes/wrn.tmLanguage.json` names block keywords directly —
|
||||
`api` appears 4 times, `client` 6, `server` 5, `ssr` once.
|
||||
- **Keyword list:** `WRN_KEYWORDS` in `packages/language-server/src/index.ts` drives completion and
|
||||
includes `api` but not `apis`.
|
||||
- **Extension completions:** `editors/vscode/src/completion.js` carries block snippets.
|
||||
- **Server features:** completion, hover, folding, linked editing, tag completion, and TypeScript
|
||||
diagnostics over a virtual document.
|
||||
|
||||
## Changes by surface
|
||||
|
||||
### Grammar
|
||||
|
||||
Add `apis` as a block keyword. Remove the `ssr` / `client` **data block** patterns, keeping `client`
|
||||
and `server` where they mean other things — `client state { }`, the `runtime` values, and the
|
||||
function modifiers in `functions { }`. This is the change most likely to over-reach: `client` is one
|
||||
word with several jobs in this language, and blanket removal would un-highlight constructs that
|
||||
still exist.
|
||||
|
||||
Highlight an `apis` entry's shape — name, method, path — so a declaration reads as a declaration
|
||||
rather than as loose identifiers.
|
||||
|
||||
### Keyword and block completion
|
||||
|
||||
- `apis` joins `WRN_KEYWORDS`.
|
||||
- A snippet for the container and a snippet for an entry, including the `request` / `response` /
|
||||
`error` sections, so the shape is discoverable without the docs.
|
||||
- `ssr` and `client` data-block snippets are removed from `completion.js`. Offering a construct the
|
||||
compiler rejects is worse than offering nothing.
|
||||
|
||||
### Call completion — the feature worth building
|
||||
|
||||
Inside a function body, `api.` should complete to the names declared in that page's `apis { }`
|
||||
block, with the method and path as detail. The server already indexes the document to build
|
||||
completions, and the block names are in the AST.
|
||||
|
||||
This is where the syntax pays off in the editor: the set of legal calls is knowable, so the editor
|
||||
should know it. Without it, `api.` is an empty namespace and every call is typed from memory.
|
||||
|
||||
Hovering a name inside `api.<name>()` shows its method, path, and declared request fields.
|
||||
|
||||
### The `api=` attribute in markup
|
||||
|
||||
`api="searchUsers({ name: nameFilter })"` is an attribute whose value is a call expression. The HTML
|
||||
service must not flag it as an unknown attribute, and the expression must not be treated as plain
|
||||
text. Completion inside the quotes offers the page's block names, matching the `api.` behaviour.
|
||||
|
||||
### Diagnostics for removed constructs
|
||||
|
||||
An `ssr { api … }` or `client { api … }` block gets a diagnostic naming `apis { }` as the
|
||||
replacement, positioned on the block keyword. The compiler already rejects these; the editor should
|
||||
say so while typing rather than at build time, and it should say what to do instead.
|
||||
|
||||
## Inline type errors — verifying, not assuming
|
||||
|
||||
The `apis` spec generates assertions into `app/types/wrnexus.generated.api-checks.ts`, and `tsc`
|
||||
fails when a block declares a field its endpoint rejects. Whether that failure surfaces **inside the
|
||||
`.wrn` file** has never been confirmed.
|
||||
|
||||
This spec resolves it in two steps, in order:
|
||||
|
||||
1. **Observe the current behaviour.** With a deliberately wrong field in place, open the page in VS
|
||||
Code and record where the error appears: on the block, only in the generated file, or nowhere.
|
||||
2. **Act on what is observed.** If the error already surfaces usefully, document it and stop. If it
|
||||
appears only in the generated file, map the diagnostic back to the block that produced it — the
|
||||
generator knows which page and block each assertion came from, so the mapping is available if it
|
||||
is recorded rather than discarded.
|
||||
|
||||
If step 2 proves larger than this spec can hold, it becomes its own work, and the spec says so
|
||||
plainly rather than leaving an unfinished feature implied. **Nothing here should claim inline
|
||||
diagnostics work until someone has seen them work.**
|
||||
|
||||
## Testing
|
||||
|
||||
**Grammar** — a fixture page using `apis { }`, `client state { }`, `functions { shared function }`,
|
||||
and `runtime = "client"` tokenizes correctly; `client` keeps its highlighting everywhere it is still
|
||||
valid. This is the guard against over-reaching removal.
|
||||
|
||||
**Completion**
|
||||
|
||||
- `apis` is offered at page level; `ssr` / `client` data blocks are not.
|
||||
- `api.` inside a function body offers the page's declared names with method and path.
|
||||
- Inside `api="…"` in markup, the same names are offered.
|
||||
- Outside those contexts, completion is unchanged — the guard that non-API editing is undisturbed.
|
||||
|
||||
**Hover** — a name inside `api.<name>()` reports its method, path, and request fields.
|
||||
|
||||
**Diagnostics** — an `ssr { api … }` block produces a diagnostic naming `apis { }`, positioned on
|
||||
the block keyword.
|
||||
|
||||
**Bundles** — `check:editor-compiler`, `check:editor-language-server`, and
|
||||
`check:editor-extension` pass. These embed the compiler and language server, so they must be rebuilt
|
||||
after the syntax change; a stale bundle fails the gate.
|
||||
|
||||
**Manual, and recorded in the implementation notes** — open the migrated `examples/basic-app` in VS
|
||||
Code: `apis { }` highlights, `api.` completes, a removed construct is flagged, and the inline
|
||||
type-error question above is answered by observation.
|
||||
|
||||
## Deferred
|
||||
|
||||
- Mapping generated assertion diagnostics back into `.wrn`, if step 2 above proves too large.
|
||||
- Moving the remaining component intelligence out of `completion.js` and into the server.
|
||||
- Editors other than VS Code.
|
||||
@@ -0,0 +1,149 @@
|
||||
# Legacy, deprecated, and unused-config cleanup — Design
|
||||
|
||||
**Date:** 2026-08-19
|
||||
**Status:** Approved for implementation
|
||||
**Scope:** Remove the compatibility-flag surface, the `"legacy"` function runtime, dead migrations,
|
||||
and deprecated APIs — before the framework's first public release.
|
||||
|
||||
## Goal
|
||||
|
||||
WRNexus carries compatibility machinery for a public it does not yet have. Every branch of it is
|
||||
either switched off in the only apps that exist, or wired to nothing at all. Removing it now costs
|
||||
almost nothing; removing it after release costs a major version and other people's time.
|
||||
|
||||
## Why this is safe now
|
||||
|
||||
Two pieces of evidence, both verified rather than assumed:
|
||||
|
||||
**The only consumers already run without it**, which matters for the config files themselves even
|
||||
though the keys are inert. `D:\Company\wrnexus\apps\admin` and
|
||||
`D:\Company\wrnexus\apps\web` — both test projects, neither deployed — set every compatibility flag
|
||||
to `false` and `legacyDefaultRuntime: "current"`. They are already on the modern path; removing the
|
||||
flags means deleting the lines that say "off".
|
||||
|
||||
**None of the seven keys change any behaviour.** Verified by tracing every reference, not by
|
||||
reading the types. `legacyEmit`, `legacyEventProps`, `legacyComponentDiscovery`, `stringLayouts`,
|
||||
and `legacyDefaultRuntime` appear in exactly three places each: the type declaration in
|
||||
`config.ts`, the scaffolder in `create.ts`, and the insertion in `update.ts`. **No compiler,
|
||||
codegen, or runtime code reads any of them.** They are written into every generated config and then
|
||||
ignored.
|
||||
|
||||
The remaining two are the same story with more machinery. `resolveCompatibility` (`packages/styles/src/compatibility.ts`)
|
||||
produces a report — an effective date, a behaviour number, and advisory strings. Tracing every
|
||||
consumer: the `wrnexus compatibility` command prints it, and `config.ts` raises one validation error
|
||||
when the configured date is _newer_ than the CLI supports. **No compiler branch and no runtime
|
||||
behaviour reads `effectiveDate` or `effectiveBehaviour`.** The mechanism is scaffolding that was
|
||||
never connected.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- **The legacy `api` block forms.** Bare-body blocks using `with ($data)`, and `api` entries inside
|
||||
`ssr {}` / `client {}`, are _replaced_ rather than deleted — that is the next spec's job. Removing
|
||||
them here would leave a gap with no working mechanism.
|
||||
- Adding any configuration key. This spec only removes them.
|
||||
- Changing any behaviour that is currently switched on.
|
||||
|
||||
## What gets removed
|
||||
|
||||
| Item | Where | Why it goes |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- |
|
||||
| `compatibility: { legacyEmit, legacyEventProps, legacyComponentDiscovery, stringLayouts }` | `packages/styles/src/config.ts` (`CompatibilityConfig`) | Never read by any code |
|
||||
| `functions: { legacyDefaultRuntime }` | `packages/styles/src/config.ts` (`FunctionsConfig`) | Never read by any code |
|
||||
| `compatibilityDate` | `packages/styles/src/compatibility.ts` (`CompatibilityPolicy`) | Gates nothing |
|
||||
| `frameworkBehaviour` | same | Gates nothing |
|
||||
| `resolveCompatibility`, `CompatibilityReport`, `isCompatibilityDate`, `CURRENT_COMPATIBILITY_DATE`, `CURRENT_FRAMEWORK_BEHAVIOUR` | `packages/styles/src/compatibility.ts` | Whole module serves only the two dead keys |
|
||||
| `wrnexus compatibility <check\|explain\|upgrade>` | `packages/cli/src/compatibility-command.ts`, dispatch at `packages/cli/src/index.ts:295`, help text at `:75` | Reports on removed keys |
|
||||
| `"legacy"` variant of `FunctionRuntime` | `packages/syntax/src/v060.ts`, branches in `packages/compiler/src/client-codegen.ts` and `server-codegen.ts` | An unmarked function becomes `shared` (see below) |
|
||||
| Migrations below `0.8.0` | `packages/cli/src/update.ts` | 111 migrations reach back to `0.2.8`; no project exists below `0.8.x` |
|
||||
| Deprecated re-export shims | `packages/compiler/src/{parser,tokenizer,types}.ts` | Two lines each, re-exporting `@wrnexus/syntax` |
|
||||
| Deprecated `@wrnexus/auth` options | `engine.ts` (4 sites), `http/index.ts` (2), `plugin.ts` (2), `types.ts` (1) | See "Deprecated auth options" |
|
||||
|
||||
## The `"legacy"` function runtime
|
||||
|
||||
`FunctionRuntime` is `"legacy" | "client" | "server" | "shared"`. `"legacy"` is what an _unmarked_
|
||||
`function foo()` gets, and `legacyDefaultRuntime` decides how it behaves. Both codegens then test
|
||||
membership: `["legacy", "client", "shared"]` for the browser and `["legacy", "server", "shared"]`
|
||||
for the server — which is to say **an unmarked function is currently emitted into both bundles,
|
||||
exactly like `shared`.**
|
||||
|
||||
`legacyDefaultRuntime` looks like it should modulate this, but it is never read (above), so the
|
||||
mapping is unconditional: unmarked is always `"legacy"`, and `"legacy"` is always emitted to both
|
||||
bundles.
|
||||
|
||||
So the removal is mechanical: delete the `"legacy"` variant, and parse an unmarked function as
|
||||
`"shared"`. The emitted output for every existing unmarked function is unchanged, in every
|
||||
configuration. `FunctionRuntime`
|
||||
becomes `"client" | "server" | "shared"`, and the membership tests lose one element each.
|
||||
|
||||
This is the one item where behaviour could drift if done carelessly, so its test is explicit: an
|
||||
unmarked function must still appear in both the browser and server modules.
|
||||
|
||||
## Deprecated re-export shims
|
||||
|
||||
`packages/compiler/src/parser.ts`, `tokenizer.ts`, and `types.ts` are two-line files re-exporting
|
||||
`@wrnexus/syntax`. They are marked deprecated, but **`codegen.ts` and `native-codegen.ts` still
|
||||
import from them**, so deleting the files is not enough — those imports must be repointed at
|
||||
`@wrnexus/syntax` first. Removing the files without that step breaks the build.
|
||||
|
||||
## Deprecated auth options
|
||||
|
||||
`@wrnexus/auth` carries nine `@deprecated` markers. These are **our own superseded options**, not an
|
||||
out-of-date dependency — there is no newer version to move to, only newer options we already added.
|
||||
Removing them means deleting the old aliases and moving the few call sites that still use them.
|
||||
|
||||
| Group | Where | Replacement | Still used? |
|
||||
| --------------------------------------------------------------------- | ---------------------------- | ------------------------------------------- | ----------------------------------------------------------- |
|
||||
| `onSignedIn` / `onSignedOut` on HTTP route options and plugin options | `http/index.ts`, `plugin.ts` | the same names on `createAuthEngine({ … })` | only `packages/auth/test/http.test.ts` and `plugin.test.ts` |
|
||||
| `onSuccessfullSignUp` (misspelled alias) | `types.ts` | `onSuccessfulSignUp` | nowhere — zero references |
|
||||
| `rpId` / `origin` on passkey verification | `engine.ts` (4 sites) | values bound to the issued challenge | `packages/auth/test/engine.test.ts` |
|
||||
|
||||
Two things this table settles:
|
||||
|
||||
- **The recommended form is already in use.** `examples/auth-showcase/app/lib/auth.ts` passes
|
||||
`onSignedIn` / `onSignedOut` to `createAuthEngine`, which is the _current_ API. The deprecated
|
||||
members are the same names on different option objects, so the example needs no change.
|
||||
- **Neither test app uses any of them.** Nothing in `D:\Company\wrnexuspps` references these
|
||||
options.
|
||||
|
||||
So the blast radius is three test files inside `packages/auth`, which are exercising the deprecated
|
||||
paths and are updated or removed alongside them. The `rpId` / `origin` options are already ignored at
|
||||
runtime — verification uses the values bound to the issued challenge — so removing them changes no
|
||||
behaviour, only the shape of the call.
|
||||
|
||||
## Migration
|
||||
|
||||
`examples/basic-app` and both test apps need one pass each:
|
||||
|
||||
1. Delete `compatibility`, `functions`, `compatibilityDate`, and `frameworkBehaviour` from
|
||||
`wrnexus.config.ts` — seven lines per app.
|
||||
2. `packages/cli/src/create.ts` stops scaffolding those keys, so new apps get a shorter config.
|
||||
|
||||
No `.wrn` source changes. Nothing in this spec alters page syntax.
|
||||
|
||||
The removed `0.2.x`–`0.7.x` migrations mean a project below `0.8.0` can no longer be upgraded by
|
||||
`wrnexus update`. No such project exists, and rescuing one would be a manual job either way.
|
||||
|
||||
## Testing
|
||||
|
||||
- **The `"legacy"` runtime removal is behaviour-preserving**: an unmarked function still appears in
|
||||
both the browser and server modules. This is the assertion most worth writing, because it is the
|
||||
only removal that could silently change output.
|
||||
- **Config rejects the removed keys** rather than ignoring them, so a stale config fails loudly with
|
||||
a message naming the key. A silently-ignored key would leave someone believing a flag still
|
||||
applies.
|
||||
- **`create.ts` scaffolds a config without them**, asserted against the generated file.
|
||||
- **`wrnexus update` still runs** with the pre-`0.8.0` migrations gone, and reports correctly for an
|
||||
app already at the current version.
|
||||
- **`examples/basic-app` builds and its suite passes** after its config is trimmed — the end-to-end
|
||||
guard that nothing depended on the removed surface.
|
||||
- **The full gate** (`bun run check:production`) passes, including the editor bundles, which embed
|
||||
the compiler and must be rebuilt after `FunctionRuntime` changes.
|
||||
|
||||
## What we give up
|
||||
|
||||
Deleting `compatibilityDate` and `frameworkBehaviour` removes the standard escape hatch for changing
|
||||
a default after going public — the mechanism that lets an existing app keep old behaviour by pinning
|
||||
a date. Today it is wired to nothing, so it protects nobody, and an unused mechanism rots rather
|
||||
than matures. If a gate is needed later it can be reintroduced deliberately, against a real
|
||||
behaviour change, instead of being carried empty. This is a considered trade rather than a free
|
||||
deletion.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Carrying projects to the new syntax with `wrnexus update` — Design
|
||||
|
||||
**Date:** 2026-08-19
|
||||
**Status:** Approved for implementation
|
||||
**Scope:** Migrations that take an existing project from today's syntax to the syntax left by the
|
||||
cleanup and `apis { }` specs.
|
||||
|
||||
**Depends on:** `2026-08-19-legacy-and-config-cleanup-design.md` and
|
||||
`2026-08-19-apis-block-design.md`. Both define the target this migrates to, so both must land first.
|
||||
|
||||
## Goal
|
||||
|
||||
After the two preceding specs, every existing project is written in a syntax the framework no longer
|
||||
accepts. One `wrnexus update` should carry a project across — config keys removed, `api` blocks
|
||||
moved into `apis { }`, mode-scoped helpers relocated — and, where it cannot do that safely, say so
|
||||
precisely instead of guessing.
|
||||
|
||||
## What already exists
|
||||
|
||||
This is an extension of working machinery, not a new subsystem:
|
||||
|
||||
- `Migration { version, id, description, apply(ctx) }`, run when `from < version <= to`.
|
||||
- `MigrationCtx` carries `appRoot`, `from`, `to`, **`dryRun`**, a report, and a logger.
|
||||
- `MigrationReport` already separates `changedAutomatically`, `needsReview`, `parseFailures`,
|
||||
`ambiguousFunctions`, and `unresolvedImports`.
|
||||
- `update.ts` already imports `parse` and `formatWrn` from `@wrnexus/syntax`, so parsing a `.wrn`
|
||||
file, transforming it, and re-emitting formatted source is an established pattern here.
|
||||
|
||||
The report's shape matters: it was built around the idea that some changes are safe to make and
|
||||
others must be handed back to a human. That distinction is the backbone of this spec.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Migrating projects below `0.8.0`. The cleanup spec removes those migrations; no such project
|
||||
exists.
|
||||
- Rewriting application logic. Only the constructs these specs changed.
|
||||
|
||||
## The safety contract
|
||||
|
||||
**A file is transformed correctly, or it is left untouched and reported.** There is no third
|
||||
outcome. Concretely:
|
||||
|
||||
1. Parse the file. A parse failure records the path in `parseFailures` and moves on — the file is
|
||||
never partially rewritten.
|
||||
2. Transform, then re-emit through `formatWrn`.
|
||||
3. If any part of a file's transform cannot be completed, **the whole file is skipped** and recorded
|
||||
in `needsReview` with the reason and the construct involved.
|
||||
|
||||
Running the migration twice must be a no-op: every transform detects already-migrated input and
|
||||
does nothing. Dry-run must report exactly what a real run would change.
|
||||
|
||||
## The migrations
|
||||
|
||||
### 1. Remove the dead config keys
|
||||
|
||||
Delete `compatibility`, `functions`, `compatibilityDate`, and `frameworkBehaviour` from
|
||||
`wrnexus.config.ts`. Mechanical, fully automatic, and safe because none of them was ever read.
|
||||
|
||||
### 2. `ssr { api … }` / `client { api … }` → `apis { … }`
|
||||
|
||||
Move each `api` entry into a page-level `apis { }` block, dropping the mode. Sectioned bodies —
|
||||
those already using `request` / `response` / `error` — carry across unchanged, because the payload
|
||||
is already bound to `data`.
|
||||
|
||||
Fully automatic. If a page has entries in both an `ssr` and a `client` block sharing a name, that is
|
||||
a duplicate under the new rules and the **file is skipped and reported**, since choosing which one
|
||||
survives is a decision about intent.
|
||||
|
||||
### 3. `ssr { functions { … } }` → `functions { shared function … }`
|
||||
|
||||
Relocate mode-scoped helpers to the page-level `functions { }` block with the `shared` modifier.
|
||||
Automatic. If the page already has a function of the same name, the file is skipped and reported.
|
||||
|
||||
### 4. Legacy bare-body `api` blocks — **needs review, not automatic**
|
||||
|
||||
This is the one transform that cannot be done safely, and the spec is explicit about it rather than
|
||||
attempting a best effort.
|
||||
|
||||
A legacy bare body is evaluated inside `with ($data ?? {})`, so it references payload fields as bare
|
||||
identifiers:
|
||||
|
||||
```wrn
|
||||
api ssrUsers GET /api/users/ssr {
|
||||
return userNames(users)
|
||||
}
|
||||
```
|
||||
|
||||
The sectioned form binds the payload to `data`, so this must become `data.users`. But **which free
|
||||
identifiers are payload fields is not knowable from the source.** In the example, `users` comes from
|
||||
the response and `userNames` is a page helper — and nothing in the file distinguishes them. The
|
||||
response shape belongs to the route, and the route may not even be typed.
|
||||
|
||||
A migration that guessed would produce code that compiles and is wrong: `data.userNames(...)` or an
|
||||
untouched `users` that silently resolves to `undefined`. That is precisely the silent-wrong-answer
|
||||
failure this project keeps paying for.
|
||||
|
||||
So: legacy bare-body blocks are **detected, reported in `needsReview` with the file, the block name,
|
||||
and the free identifiers found**, and left untouched. The report tells the author exactly what to
|
||||
decide. `wrnexus update` prints a short explanation of why this one is manual.
|
||||
|
||||
### 5. Deprecated `@wrnexus/auth` options
|
||||
|
||||
Only if the cleanup spec's optional auth section is included. Rename call sites of the superseded
|
||||
options. Automatic where the rename is unambiguous; reported otherwise.
|
||||
|
||||
## Version
|
||||
|
||||
All of these attach to the release that ships the breaking change. After the cleanup spec the
|
||||
migration floor is `0.8.0`, so the list is short and every entry is reachable.
|
||||
|
||||
## Output
|
||||
|
||||
At the end of a run the command prints, in this order: what it changed, what needs review and why,
|
||||
and what failed to parse. A run with anything in `needsReview` or `parseFailures` exits non-zero, so
|
||||
a scripted upgrade cannot appear to succeed while leaving a project half-migrated.
|
||||
|
||||
## Testing
|
||||
|
||||
Each migration gets a fixture project and three assertions: the transform produces the expected
|
||||
source, running it a second time changes nothing, and a dry run reports the same set without writing.
|
||||
|
||||
- **Config removal** — keys gone, rest of the config untouched.
|
||||
- **`api` relocation** — a page with both `ssr` and `client` api blocks lands in one `apis { }`;
|
||||
entries keep their names, methods, paths, and sections.
|
||||
- **Name collision across modes** — the file is skipped and reported, not silently merged.
|
||||
- **Mode functions** — relocated with the `shared` modifier; a name collision skips and reports.
|
||||
- **Legacy bare body** — reported in `needsReview` with the block name and free identifiers, and the
|
||||
file is byte-identical afterwards. This is the most important test in the spec: it pins that the
|
||||
migration does _not_ attempt the rewrite.
|
||||
- **Parse failure** — a malformed `.wrn` is recorded in `parseFailures` and left untouched.
|
||||
- **Exit code** — non-zero when anything needs review.
|
||||
- **End to end** — `examples/basic-app` migrated by the command alone, then built and tested. If the
|
||||
framework's own example cannot be migrated by the tool, the tool is not finished.
|
||||
|
||||
## What this does not promise
|
||||
|
||||
Automated source rewriting cannot be promised as "perfect". What is promised is bounded: every file
|
||||
is either correctly transformed or untouched and named in the report, with the reason. Nothing is
|
||||
half-rewritten, and nothing is guessed. The legacy bare-body case is deliberately manual because a
|
||||
correct automatic answer does not exist.
|
||||
+294
-217
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
||||
// WRN editor compiler source hash: b0e3094d8c2a70ee2b34fe961c186b58de9527aa072ca12292b715d3d5f51c87
|
||||
// WRN editor compiler source hash: 2987e8e9d91b793818a195395e740d35d0d5c2a320ff1c3a5ae77363850c7b8e
|
||||
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
||||
// Generated with TypeScript: 6.0.3
|
||||
const __nodeRequire = require;
|
||||
@@ -600,7 +600,7 @@ function selectedBrowserImports(ast, functions) {
|
||||
.filter((entry) => entry !== null);
|
||||
}
|
||||
function browserModuleRequired(ast) {
|
||||
const functions = ast.runtimeFunctions.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime));
|
||||
const functions = ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime));
|
||||
return functions.length > 0 || selectedBrowserImports(ast, functions).length > 0;
|
||||
}
|
||||
function _functionEntry(ast, fn, availableFunctions) {
|
||||
@@ -713,15 +713,111 @@ function _functionEntry(ast, fn, availableFunctions) {
|
||||
}`;
|
||||
}
|
||||
/**
|
||||
* Client-mode api blocks become members of an `api` object in client scope.
|
||||
* Blank out string/template literals and comments in a raw JS body, preserving
|
||||
* length and newlines, so a scanner walking the result never mistakes text
|
||||
* inside a string or comment for real code. Reuses the tokenizer's
|
||||
* comment/string-skipping rules (`skipLiteralOrComment`) instead of
|
||||
* reimplementing them — a second hand-rolled scanner is how apostrophes in
|
||||
* prose used to swallow braces elsewhere in this codebase.
|
||||
*/
|
||||
function maskStringsAndComments(src) {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
let atLineStart = true;
|
||||
while (i < src.length) {
|
||||
const c = src[i];
|
||||
if (c === "\n") {
|
||||
out += c;
|
||||
atLineStart = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const skipped = (0, syntax_1.skipLiteralOrComment)(src, i, atLineStart);
|
||||
if (skipped !== null) {
|
||||
out += src.slice(i, skipped).replace(/[^\n]/g, " ");
|
||||
i = skipped;
|
||||
atLineStart = false;
|
||||
continue;
|
||||
}
|
||||
if (c !== " " && c !== "\t" && c !== "\r")
|
||||
atLineStart = false;
|
||||
out += c;
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
/**
|
||||
* Block names the page's client functions actually call.
|
||||
*
|
||||
* A block's response and error bodies are page code. Emitting one the browser
|
||||
* never calls would ship a server-only transform to every visitor and grow the
|
||||
* bundle for nothing.
|
||||
*/
|
||||
function clientCalledApiNames(ast) {
|
||||
const called = new Set();
|
||||
for (const fn of ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime))) {
|
||||
const masked = maskStringsAndComments(fn.body);
|
||||
for (const match of masked.matchAll(/\bapi\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)/g)) {
|
||||
called.add(match[1]);
|
||||
}
|
||||
}
|
||||
return called;
|
||||
}
|
||||
/**
|
||||
* Refuse to compile a client/shared function whose body references `api` in
|
||||
* any form other than `api.<identifier>` — e.g. `api["searchUsers"]()`, or
|
||||
* passing `api` to a helper. Usage-driven emission (see `clientCalledApiNames`
|
||||
* above) can only see `api.<identifier>` calls; a dynamic or indirect
|
||||
* reference is invisible to it, so the referenced block would be silently
|
||||
* dropped from the browser bundle and the call would fail at runtime with
|
||||
* "api.<name> is not a function". That failure direction is worse than a
|
||||
* loud compile error, so it is caught here instead.
|
||||
*
|
||||
* Strings and comments are masked out first so `api` appearing in prose or in
|
||||
* a quoted value never trips this check, and every `api.<identifier>` access
|
||||
* is stripped before the standalone-word scan so a real, well-formed call
|
||||
* never does either.
|
||||
*/
|
||||
function assertNoDynamicApiAccess(ast) {
|
||||
// Only pages with a sectioned api block have anything at stake here: those
|
||||
// blocks are emitted solely because usage detection saw `api.<name>`, so a
|
||||
// dynamic reference this scan can't see is the one that silently drops a
|
||||
// block from the bundle. A page with no api blocks at all may still declare
|
||||
// an ordinary `state api` (see the B5 regression test) where a bare "api"
|
||||
// identifier is just that state, not a missed block reference.
|
||||
if (!ast.dataApis.some((block) => block.sections))
|
||||
return;
|
||||
for (const fn of ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime))) {
|
||||
const masked = maskStringsAndComments(fn.body);
|
||||
const withoutCalls = masked.replace(/\bapi\s*\.\s*[A-Za-z_$][A-Za-z0-9_$]*/g, (match) => match.replace(/[^\n]/g, " "));
|
||||
if (/\bapi\b/.test(withoutCalls)) {
|
||||
throw new Error(`.wrn ${fn.runtime} function "${fn.name}" in page "${ast.name}" references "api" in a form other than "api.<name>(...)". ` +
|
||||
`API blocks must be called as api.name(...) so the compiler can tell which ones the browser needs to receive; ` +
|
||||
`dynamic or indirect access (e.g. api["name"](), or passing api to a helper) cannot be detected and would silently drop the block from the browser bundle.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* A block is emitted into the browser module when it declares typed sections
|
||||
* and a client function actually calls it. `hasClientApi` below must use this
|
||||
* exact predicate so the `api` reserved-binding exclusion and the emitted
|
||||
* object can never disagree.
|
||||
*/
|
||||
function isClientEmittedApiBlock(block, called) {
|
||||
return Boolean(block.sections) && called.has(block.name);
|
||||
}
|
||||
/**
|
||||
* Client-mode and client-called any-mode api blocks become members of an
|
||||
* `api` object in client scope.
|
||||
*
|
||||
* Only the response and error bodies are emitted; the declared field types are
|
||||
* type-only and are consumed by the types generator instead. Anything
|
||||
* TypeScript reaching this module would be a syntax error in the .mjs artifact.
|
||||
*/
|
||||
function apiBindings(ast) {
|
||||
const called = clientCalledApiNames(ast);
|
||||
const members = ast.dataApis
|
||||
.filter((block) => block.mode === "client" && block.sections)
|
||||
.filter((block) => isClientEmittedApiBlock(block, called))
|
||||
.map((block) => {
|
||||
const sections = block.sections;
|
||||
const response = (0, syntax_1.eraseFunctionTypes)(sections.response).trim() || "return data;";
|
||||
@@ -734,7 +830,8 @@ function apiBindings(ast) {
|
||||
return members.length ? `const api = {\n${members.join(",\n")}\n };` : "";
|
||||
}
|
||||
function generateBrowserModule(ast) {
|
||||
const functions = ast.runtimeFunctions.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime));
|
||||
assertNoDynamicApiAccess(ast);
|
||||
const functions = ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime));
|
||||
const functionNames = functions.map((fn) => fn.name);
|
||||
const state = ast.states.filter((entry) => entry.runtime !== "server").map((entry) => entry.name);
|
||||
const selectedImports = selectedBrowserImports(ast, functions);
|
||||
@@ -745,7 +842,7 @@ function generateBrowserModule(ast) {
|
||||
// `state api` without any client api blocks must keep reading/writing that
|
||||
// state as before, so only exclude the "api" name from destructuring when
|
||||
// there is a real `api` binding to shadow it.
|
||||
const hasClientApi = ast.dataApis.some((block) => block.mode === "client" && block.sections);
|
||||
const hasClientApi = ast.dataApis.some((block) => isClientEmittedApiBlock(block, clientCalledApiNames(ast)));
|
||||
const localRuntimeBindings = hasClientApi
|
||||
? RUNTIME_BINDINGS
|
||||
: new Set([...RUNTIME_BINDINGS].filter((name) => name !== "api"));
|
||||
@@ -844,8 +941,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.generate = generate;
|
||||
exports.parseForExpr = parseForExpr;
|
||||
const node_buffer_1 = require("node:buffer");
|
||||
const parser_ts_1 = require("./parser.js");
|
||||
const types_ts_1 = require("./types.js");
|
||||
const syntax_1 = require("@wrnexus/syntax");
|
||||
const store_codegen_ts_1 = require("./store-codegen.js");
|
||||
const analysis_ts_1 = require("./analysis.js");
|
||||
@@ -1245,7 +1340,7 @@ function renderLoopBody(node) {
|
||||
inner +
|
||||
escLit("</div>"));
|
||||
}
|
||||
if (parser_ts_1.VOID_ELEMENTS.has(node.tag.toLowerCase())) {
|
||||
if (syntax_1.VOID_ELEMENTS.has(node.tag.toLowerCase())) {
|
||||
return escLit(`<${node.tag}`) + attrs + escLit(">");
|
||||
}
|
||||
return escLit(`<${node.tag}`) + attrs + escLit(">") + inner + escLit(`</${node.tag}>`);
|
||||
@@ -1288,32 +1383,6 @@ function compileIfExpr(node) {
|
||||
}
|
||||
return "${" + expr + "}";
|
||||
}
|
||||
/**
|
||||
* Collect every server-control expression in a view (recursively): `{#each}` list
|
||||
* expressions and `{#if}` conditions. Used to wrn up raw SSR data consts.
|
||||
*/
|
||||
function collectControlExprs(nodes, out = []) {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "text")
|
||||
continue;
|
||||
if (node.type === "each") {
|
||||
out.push(node.list);
|
||||
collectControlExprs(node.body, out);
|
||||
collectControlExprs(node.empty, out);
|
||||
}
|
||||
else if (node.type === "if") {
|
||||
for (const b of node.branches) {
|
||||
if (b.cond)
|
||||
out.push(b.cond);
|
||||
collectControlExprs(b.body, out);
|
||||
}
|
||||
}
|
||||
else if (node.type === "element") {
|
||||
collectControlExprs(node.children, out);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive = null) {
|
||||
if (node.type === "text")
|
||||
return substituteReactiveText(node.value, reactive, loops); // {t:key} + state baking
|
||||
@@ -1408,31 +1477,33 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
|
||||
if (isComponentTag(node.tag)) {
|
||||
return renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive);
|
||||
}
|
||||
const apiName = attrValue(node.attrs, "api");
|
||||
const apiBinding = apiName ? apiBindings.get(apiName) : undefined;
|
||||
if (apiName && !apiBinding) {
|
||||
throw new Error(`Unknown .wrn api binding "${apiName}"`);
|
||||
const apiAttr = attrValue(node.attrs, "api");
|
||||
const parsedApi = apiAttr ? parseApiBinding(apiAttr) : null;
|
||||
if (apiAttr && !parsedApi) {
|
||||
throw new Error(`Invalid .wrn api binding "${apiAttr}"`);
|
||||
}
|
||||
const apiBinding = parsedApi ? apiBindings.get(parsedApi.name) : undefined;
|
||||
if (parsedApi && !apiBinding) {
|
||||
throw new Error(`Unknown .wrn api binding "${parsedApi.name}"`);
|
||||
}
|
||||
const ssrGet = attrValue(node.attrs, "ssrGet");
|
||||
const ssrText = attrValue(node.attrs, "ssrText");
|
||||
const csrGet = attrValue(node.attrs, "csrGet");
|
||||
const csrText = attrValue(node.attrs, "csrText");
|
||||
const csrId = apiBinding?.mode === "client"
|
||||
? csrMarker(csrBindings, renderBinding(apiBinding))
|
||||
: csrGet && csrText
|
||||
? csrMarker(csrBindings, {
|
||||
method: "GET",
|
||||
path: apiRoutePath(csrGet),
|
||||
body: expressionBody(csrText),
|
||||
helpers: "",
|
||||
})
|
||||
: undefined;
|
||||
const csrId = csrGet && csrText
|
||||
? csrMarker(csrBindings, {
|
||||
method: "GET",
|
||||
path: apiRoutePath(csrGet),
|
||||
body: expressionBody(csrText),
|
||||
helpers: "",
|
||||
})
|
||||
: undefined;
|
||||
// Void elements (<br>, <img>, …) have no closing tag and no children.
|
||||
if (parser_ts_1.VOID_ELEMENTS.has(node.tag.toLowerCase())) {
|
||||
if (syntax_1.VOID_ELEMENTS.has(node.tag.toLowerCase())) {
|
||||
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive, loops)}>`;
|
||||
}
|
||||
const inner = apiBinding?.mode === "ssr"
|
||||
? ssrMarker(ssrBindings, renderBinding(apiBinding))
|
||||
const inner = apiBinding
|
||||
? apiCallMarker(loops, parsedApi.name, parsedApi.args)
|
||||
: ssrGet && ssrText
|
||||
? ssrMarker(ssrBindings, {
|
||||
method: "GET",
|
||||
@@ -1505,6 +1576,33 @@ function renderNestedComponentInvocation(node, ctx) {
|
||||
`${ctx.forwardRestAttrs ? "${__wrnSpreadAttrs(__attrs)}" : ""}` +
|
||||
`${attrs}>${inner}</div>`);
|
||||
}
|
||||
/**
|
||||
* Parses the three `api="…"` render-binding forms: a bare name, an empty call,
|
||||
* or a call carrying an argument expression. Mirrors the shape of `@click="fn()"`,
|
||||
* so no new escaping or attribute-naming rules are introduced. The argument
|
||||
* capture is greedy up to the outer parens, so nested braces/parens/quotes in
|
||||
* the argument expression (object literals, arrays, strings) are carried
|
||||
* through untouched rather than truncated at the first `)`.
|
||||
*/
|
||||
function parseApiBinding(value) {
|
||||
const match = /^\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:\(([\s\S]*)\))?\s*$/.exec(value);
|
||||
if (!match)
|
||||
return null;
|
||||
return { name: match[1], args: (match[2] ?? "").trim() };
|
||||
}
|
||||
/**
|
||||
* Emit a render-time call into the server `api` object (Task 4's generated
|
||||
* transport) for an `apis {}` (mode "any") binding, and return the sentinel
|
||||
* that the loop/expression-splicing mechanism swaps for the real `${…}` code.
|
||||
* This calls the same server `api.<name>()` member a `load`/action block would
|
||||
* call -- it does not reimplement fetch/response handling -- so a block that is
|
||||
* both render-bound and called from code runs its own call each time (no
|
||||
* dedup is attempted; see apis-render-binding.test.ts).
|
||||
*/
|
||||
function apiCallMarker(loops, name, args) {
|
||||
loops.push(`\${__wrnexusEscapeHtml(await api.${name}(${args}))}`);
|
||||
return `\x00WRNEACH${loops.length - 1}\x00`;
|
||||
}
|
||||
function ssrMarker(bindings, binding) {
|
||||
const marker = `<!--wrnexus-ssr:${bindings.length}-->`;
|
||||
bindings.push({ marker, ...binding });
|
||||
@@ -1515,15 +1613,6 @@ function csrMarker(bindings, binding) {
|
||||
bindings.push({ id, ...binding });
|
||||
return id;
|
||||
}
|
||||
function renderBinding(binding) {
|
||||
return {
|
||||
method: binding.method,
|
||||
path: binding.path,
|
||||
body: binding.body,
|
||||
helpers: binding.helpers,
|
||||
...(binding.errorBody ? { errorBody: binding.errorBody } : {}),
|
||||
};
|
||||
}
|
||||
function hasClientBehavior(nodes) {
|
||||
return nodes.some((node) => {
|
||||
// `{t:key}` is i18n sugar resolved server-side — not client reactivity.
|
||||
@@ -1561,17 +1650,6 @@ function dataBody(source) {
|
||||
return "return undefined;";
|
||||
return /\breturn\b/.test(trimmed) ? trimmed : expressionBody(trimmed);
|
||||
}
|
||||
function modeHelpers(ast, mode, sharedHelpers) {
|
||||
return [
|
||||
sharedHelpers,
|
||||
...ast.modeFunctions
|
||||
.filter((block) => block.mode === mode)
|
||||
.map((block) => block.body.trim())
|
||||
.filter(Boolean),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
function apiBindingMap(ast, sharedHelpers) {
|
||||
const bindings = new Map();
|
||||
for (const block of ast.dataApis) {
|
||||
@@ -1593,11 +1671,42 @@ function apiBindingMap(ast, sharedHelpers) {
|
||||
// legacy blocks and sectioned blocks without `error` keep failures
|
||||
// propagating exactly as before.
|
||||
...(errorSection ? { errorBody: errorSection } : {}),
|
||||
helpers: modeHelpers(ast, block.mode, sharedHelpers),
|
||||
helpers: sharedHelpers,
|
||||
});
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
/**
|
||||
* Server-side `api` object.
|
||||
*
|
||||
* The transport dispatches in-process, so a call from a load block or an action
|
||||
* costs a function call rather than a network round trip. The request context
|
||||
* comes from AsyncLocalStorage because `ctx` is not in scope everywhere server
|
||||
* code runs.
|
||||
*/
|
||||
function serverApiBindings(ast) {
|
||||
const members = ast.dataApis
|
||||
.filter((block) => block.mode === "any")
|
||||
.map((block) => {
|
||||
const sections = block.sections;
|
||||
const response = sections.response.trim() || "return data;";
|
||||
const error = sections.error.trim();
|
||||
const failure = error
|
||||
? `const status = (err as { status?: unknown } | null | undefined)?.status; const message = err instanceof Error ? err.message : String(err); const data = (err as { data?: unknown } | null | undefined)?.data; ${error}`
|
||||
: `throw err;`;
|
||||
return ` ${JSON.stringify(block.name)}: async (input?: unknown) => {
|
||||
const ctx = __wrnexusRequireRequestContext(${JSON.stringify(`api.${block.name}`)}) as __WrnexusContext;
|
||||
let data: any;
|
||||
try {
|
||||
data = await __wrnexusCallApi(${JSON.stringify(apiRoutePath(block.path))}, ${JSON.stringify(block.method)}, ctx, input);
|
||||
} catch (err) {
|
||||
${failure}
|
||||
}
|
||||
${response}
|
||||
}`;
|
||||
});
|
||||
return members.length ? `const api = {\n${members.join(",\n")}\n};` : "";
|
||||
}
|
||||
function ssrRuntimeSource() {
|
||||
return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
||||
function __wrnexusEscapeHtml(value: unknown): string {
|
||||
@@ -1605,7 +1714,7 @@ function __wrnexusEscapeHtml(value: unknown): string {
|
||||
}
|
||||
|
||||
type __WrnexusContext = import("@wrnexus/core").Context & {
|
||||
__wrnexusCallApi?: (path: string, method: string) => Promise<unknown>;
|
||||
__wrnexusCallApi?: (path: string, method: string, input?: unknown) => Promise<unknown>;
|
||||
localStorage?: unknown;
|
||||
};
|
||||
|
||||
@@ -1652,13 +1761,27 @@ function __wrnexusPropAttr(
|
||||
);
|
||||
}
|
||||
|
||||
async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusContext): Promise<unknown> {
|
||||
async function __wrnexusCallApi(
|
||||
path: string,
|
||||
method: string,
|
||||
ctx: __WrnexusContext,
|
||||
input?: unknown,
|
||||
): Promise<unknown> {
|
||||
if (typeof ctx.__wrnexusCallApi === "function") {
|
||||
return await ctx.__wrnexusCallApi(path, method);
|
||||
return await ctx.__wrnexusCallApi(path, method, input);
|
||||
}
|
||||
|
||||
const url = new URL(path, ctx.req.url);
|
||||
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
|
||||
const built = __wrnexusBuildApiRequest(path, method, input as Record<string, unknown> | undefined);
|
||||
const url = new URL(built.url, ctx.req.url);
|
||||
const headers = new Headers(ctx.req.headers);
|
||||
if (built.contentType) headers.set("content-type", built.contentType);
|
||||
const res = await fetch(
|
||||
new Request(url, {
|
||||
method,
|
||||
headers,
|
||||
...(built.body === undefined ? {} : { body: built.body }),
|
||||
}),
|
||||
);
|
||||
const type = res.headers.get("content-type") || "";
|
||||
if (!res.ok) {
|
||||
const data = type.includes("application/json")
|
||||
@@ -1900,9 +2023,7 @@ function hydrationAttribute(ast) {
|
||||
return ` data-wrn-hydration="${attrEscape(hydrationId(ast))}" data-wrn-hydrate="${attrEscape(strategy)}" data-wrn-runtime="${attrEscape(ast.runtime ?? "universal")}"${moduleAttribute}`;
|
||||
}
|
||||
function targetFunctions(ast, target) {
|
||||
const runtimes = target === "browser"
|
||||
? ["legacy", "client", "shared"]
|
||||
: ["legacy", "server", "shared"];
|
||||
const runtimes = target === "browser" ? ["client", "shared"] : ["server", "shared"];
|
||||
return ast.functions
|
||||
.map((body) => (0, syntax_1.stripRuntimeFunctionModifiers)(body, [...runtimes]))
|
||||
.map((body) => body.trim())
|
||||
@@ -2003,6 +2124,10 @@ function generateInner(ast) {
|
||||
}
|
||||
if (ast.imports.length > 0)
|
||||
out.push(generatedImports(ast).join("\n"));
|
||||
const hasServerApis = ast.dataApis.some((block) => block.mode === "any");
|
||||
if (hasServerApis) {
|
||||
out.push(`import { requireRequestContext as __wrnexusRequireRequestContext } from "@wrnexus/core";`);
|
||||
}
|
||||
const ssrBindings = [];
|
||||
const csrBindings = [];
|
||||
const helpers = targetFunctions(ast, "server");
|
||||
@@ -2126,26 +2251,18 @@ function generateInner(ast) {
|
||||
staticShellBody = staticShellBody.replaceAll(`\x00WRNEACH${idx}\x00`, code);
|
||||
}
|
||||
}
|
||||
// Server loops iterate raw SSR data. Declare a named const for every `ssr` data
|
||||
// binding a loop references, so `{#each <name> as …}` can iterate the real value.
|
||||
const loopConsts = [];
|
||||
if (loops.length > 0) {
|
||||
const lists = collectControlExprs(ast.view);
|
||||
for (const [name, binding] of apiBindings) {
|
||||
if (binding.mode !== "ssr")
|
||||
continue;
|
||||
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr)))
|
||||
continue;
|
||||
const errorBodyProp = binding.errorBody
|
||||
? `, errorBody: ${JSON.stringify(binding.errorBody)}`
|
||||
: "";
|
||||
loopConsts.push(` const ${name} = await __wrnexusResolveApiBinding({ path: ${JSON.stringify(binding.path)}, method: ${JSON.stringify(binding.method)}, body: ${JSON.stringify(binding.body)}, helpers: ${JSON.stringify(binding.helpers)}${errorBodyProp} }, ctx);`);
|
||||
}
|
||||
}
|
||||
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
|
||||
if (needsSsrRuntime) {
|
||||
const needsRuntimeHelpers = needsSsrRuntime || hasServerApis;
|
||||
if (needsRuntimeHelpers) {
|
||||
out.push(`import { buildApiRequest as __wrnexusBuildApiRequest } from "@wrnexus/core";`);
|
||||
out.push(ssrRuntimeSource());
|
||||
out.push(`const __wrnexusSsrBindings: __WrnexusSsrBinding[] = ${JSON.stringify(ssrBindings, null, 2)};`);
|
||||
}
|
||||
if (hasServerApis) {
|
||||
out.push(serverApiBindings(ast));
|
||||
}
|
||||
if (needsSsrRuntime) {
|
||||
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
|
||||
out.push(`export default async function ${ast.name}(ctx: __WrnexusContext) {
|
||||
${storeDeclarations}
|
||||
@@ -2409,7 +2526,7 @@ function escLit(s) {
|
||||
return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
|
||||
}
|
||||
function componentBehavior(ast) {
|
||||
const functions = (0, types_ts_1.eraseFunctionTypes)(targetFunctions(ast, "browser"));
|
||||
const functions = (0, syntax_1.eraseFunctionTypes)(targetFunctions(ast, "browser"));
|
||||
const computed = ast.computed.map((entry) => ({ name: entry.name, expr: entry.expr.trim() }));
|
||||
const effects = ast.effects.map((entry) => entry.body.trim()).filter(Boolean);
|
||||
const lifecycle = {
|
||||
@@ -2621,7 +2738,7 @@ function renderClientControlTemplate(nodes) {
|
||||
return children;
|
||||
if (componentTag)
|
||||
return `<div data-component="${attrEscape(node.tag)}"${attrs}>${children}</div>`;
|
||||
if (parser_ts_1.VOID_ELEMENTS.has(node.tag.toLowerCase()))
|
||||
if (syntax_1.VOID_ELEMENTS.has(node.tag.toLowerCase()))
|
||||
return `<${node.tag}${attrs}>`;
|
||||
return `<${node.tag}${attrs}>${children}</${node.tag}>`;
|
||||
};
|
||||
@@ -2906,7 +3023,7 @@ function renderComponentNode(node, ctx) {
|
||||
`${classReactiveBinding}` +
|
||||
`${classBindings}` +
|
||||
`${attrs}`;
|
||||
if (parser_ts_1.VOID_ELEMENTS.has(node.tag.toLowerCase())) {
|
||||
if (syntax_1.VOID_ELEMENTS.has(node.tag.toLowerCase())) {
|
||||
return `<${node.tag}${allAttrs}>`;
|
||||
}
|
||||
const inner = node.children.map((child) => renderComponentNode(child, elementContext)).join("");
|
||||
@@ -2994,7 +3111,7 @@ function generateComponent(ast) {
|
||||
if (prop.required) {
|
||||
decls.push(` if (__p[${JSON.stringify(prop.name)}] === undefined) throw new TypeError(${JSON.stringify(`${ast.name} requires prop '${prop.name}' (${prop.valueType ?? "unknown"})`)});`);
|
||||
}
|
||||
decls.push(` const ${nameRefs.get(prop.name)}: ${prop.valueType ?? "unknown"} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}), ${JSON.stringify((0, types_ts_1.runtimeTypeOf)(prop.valueType))}, ${JSON.stringify(prop.name)}) as ${prop.valueType ?? "unknown"};`);
|
||||
decls.push(` const ${nameRefs.get(prop.name)}: ${prop.valueType ?? "unknown"} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}), ${JSON.stringify((0, syntax_1.runtimeTypeOf)(prop.valueType))}, ${JSON.stringify(prop.name)}) as ${prop.valueType ?? "unknown"};`);
|
||||
}
|
||||
if (!effectiveProps.some((prop) => prop.name === "attrs")) {
|
||||
decls.push(` const __attrs = __restProps(__p, new Set(${JSON.stringify(effectiveProps.map((prop) => prop.name))}));`);
|
||||
@@ -4059,27 +4176,6 @@ function generateNative(ast) {
|
||||
return `// generated from .wrn for Expo/React Native\nimport React, { useState } from "react";\nimport { ActivityIndicator, FlatList, Image, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";\nimport { useRouter } from "expo-router";\n${ast.imports.join("\n")}\n\n${typeSource}\n\nexport default function ${ast.name}() {\n const router = useRouter();\n${hooks}\n return <>${body}</>;\n}\n\n${nativeStyles(ast.styles)}\n`;
|
||||
}
|
||||
|
||||
},
|
||||
"packages/compiler/src/parser.ts": function (module, exports, require, __filename, __dirname) {
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
||||
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
/** @deprecated Import parser APIs from @wrnexus/syntax. */
|
||||
__exportStar(require("@wrnexus/syntax/parser"), exports);
|
||||
|
||||
},
|
||||
"packages/compiler/src/runtime-capabilities.ts": function (module, exports, require, __filename, __dirname) {
|
||||
"use strict";
|
||||
@@ -4163,7 +4259,7 @@ function stableId(value) {
|
||||
*/
|
||||
function remotelyReferencedServerFunctions(ast) {
|
||||
const browserSources = ast.runtimeFunctions
|
||||
.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime))
|
||||
.filter((fn) => ["client", "shared"].includes(fn.runtime))
|
||||
.map((fn) => fn.body);
|
||||
for (const [hook, body] of Object.entries(ast.storeLifecycle)) {
|
||||
if (hook !== "serverInit" && body)
|
||||
@@ -4195,11 +4291,11 @@ function rpcManifest(ast) {
|
||||
}
|
||||
function generateServerFunctionsModule(ast) {
|
||||
const source = ast.functions
|
||||
.map((body) => (0, syntax_1.stripRuntimeFunctionModifiers)(body, ["legacy", "server", "shared"]))
|
||||
.map((body) => (0, syntax_1.stripRuntimeFunctionModifiers)(body, ["server", "shared"]))
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const names = ast.runtimeFunctions
|
||||
.filter((fn) => ["legacy", "server", "shared"].includes(fn.runtime))
|
||||
.filter((fn) => ["server", "shared"].includes(fn.runtime))
|
||||
.map((fn) => fn.name);
|
||||
const manifest = rpcManifest(ast);
|
||||
return `// generated WRNexusJS server module for ${ast.name}\n${source}\n\nexport const __wrnexusServerFunctions = { ${[...new Set(names)].join(", ")} };\nexport const __wrnexusRpcManifest = ${JSON.stringify(manifest, null, 2)};\n`;
|
||||
@@ -4381,7 +4477,7 @@ function generateStoreBrowserModule(ast) {
|
||||
.map((entry) => `${JSON.stringify(entry.name)}: (state) => { ${safeStateNames.length ? `const { ${safeStateNames.join(", ")} } = state;` : ""} return (${entry.expr}); }`)
|
||||
.join(",\n");
|
||||
const groups = new Map();
|
||||
for (const fn of ast.runtimeFunctions.filter((entry) => ["client", "shared", "legacy"].includes(entry.runtime))) {
|
||||
for (const fn of ast.runtimeFunctions.filter((entry) => ["client", "shared"].includes(entry.runtime))) {
|
||||
const group = groups.get(fn.name) ?? [];
|
||||
group.push(fn);
|
||||
groups.set(fn.name, group);
|
||||
@@ -4496,7 +4592,7 @@ function __create(definition) {
|
||||
Object.keys(actions).forEach(function (name) { delete actions[name]; });
|
||||
Object.entries(currentDefinition.actions || {}).forEach(function (pair) {
|
||||
const name = pair[0], candidates = pair[1];
|
||||
const selected = candidates.find(function (entry) { return entry.runtime === "client"; }) || candidates.find(function (entry) { return entry.runtime === "shared"; }) || candidates.find(function (entry) { return entry.runtime === "legacy"; });
|
||||
const selected = candidates.find(function (entry) { return entry.runtime === "client"; }) || candidates.find(function (entry) { return entry.runtime === "shared"; });
|
||||
if (!selected) return;
|
||||
actions[name] = async function () {
|
||||
const args = Array.prototype.slice.call(arguments);
|
||||
@@ -4629,27 +4725,6 @@ function generateTargets(ast) {
|
||||
};
|
||||
}
|
||||
|
||||
},
|
||||
"packages/compiler/src/tokenizer.ts": function (module, exports, require, __filename, __dirname) {
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
||||
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
/** @deprecated Import tokenizer APIs from @wrnexus/syntax. */
|
||||
__exportStar(require("@wrnexus/syntax/tokenizer"), exports);
|
||||
|
||||
},
|
||||
"packages/compiler/src/type-codegen.ts": function (module, exports, require, __filename, __dirname) {
|
||||
"use strict";
|
||||
@@ -4689,7 +4764,7 @@ function generateDeclarations(ast) {
|
||||
.map((output) => ` ${member(output.name)}(${output.payload ? `${member(output.payload.name)}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}): void;`)
|
||||
.join("\n");
|
||||
const clientFunctions = ast.runtimeFunctions
|
||||
.filter((fn) => fn.runtime === "client" || fn.runtime === "shared" || fn.runtime === "legacy")
|
||||
.filter((fn) => fn.runtime === "client" || fn.runtime === "shared")
|
||||
.map((fn) => ` ${member(fn.name)}(${params(fn.parameters)}): ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")};`)
|
||||
.join("\n");
|
||||
const serverFunctions = ast.runtimeFunctions
|
||||
@@ -4699,33 +4774,13 @@ function generateDeclarations(ast) {
|
||||
return `${inline ? `${inline}\n\n` : ""}export interface ${ast.name}Props {\n${props}\n}\n\nexport interface ${ast.name}Outputs {\n${outputs}\n}\n\nexport interface ${ast.name}ClientFunctions {\n${clientFunctions}\n}\n\nexport interface ${ast.name}ServerCalls {\n${serverFunctions}\n}\n`;
|
||||
}
|
||||
|
||||
},
|
||||
"packages/compiler/src/types.ts": function (module, exports, require, __filename, __dirname) {
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
||||
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
/** @deprecated Import language type utilities from @wrnexus/syntax. */
|
||||
__exportStar(require("@wrnexus/syntax/types"), exports);
|
||||
|
||||
},
|
||||
"packages/syntax/src/api-sections.ts": function (module, exports, require, __filename, __dirname) {
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.parseApiSections = parseApiSections;
|
||||
exports.hasRequestSection = hasRequestSection;
|
||||
exports.parseApiEntries = parseApiEntries;
|
||||
/**
|
||||
* Parse the sectioned form of an `api` block body.
|
||||
*
|
||||
@@ -4864,6 +4919,42 @@ function parseApiSections(source) {
|
||||
function hasRequestSection(source) {
|
||||
return scanTopLevelBlocks(source, SECTION_NAMES).has("request");
|
||||
}
|
||||
/**
|
||||
* Parse the body of a page-level `apis { }` container: a sequence of
|
||||
* `<name> <METHOD> <path> { ... }` entries with no leading `api` keyword
|
||||
* (the container supplies it). Each entry's braces are sliced with the
|
||||
* tokenizer's own `Lexer.readBalancedBraces()`, so the same string- and
|
||||
* comment-aware rules that protect `parseApiSections` apply here too.
|
||||
*/
|
||||
function parseApiEntries(source) {
|
||||
const entries = [];
|
||||
const lx = new tokenizer_ts_1.Lexer(source);
|
||||
while (lx.peek().type !== "eof") {
|
||||
const nameToken = lx.next();
|
||||
if (nameToken.type !== "ident") {
|
||||
throw new tokenizer_ts_1.LexError(`Expected an api entry name at offset ${nameToken.pos}`);
|
||||
}
|
||||
const methodToken = lx.next();
|
||||
if (methodToken.type !== "ident") {
|
||||
throw new tokenizer_ts_1.LexError(`Expected an HTTP method after "${nameToken.value}"`);
|
||||
}
|
||||
const path = lx.readPath();
|
||||
const entryBody = lx.readBalancedBraces();
|
||||
const sections = parseApiSections(entryBody);
|
||||
if (sections === null) {
|
||||
throw new tokenizer_ts_1.LexError(`Api entry "${nameToken.value}" has a bare body; declare a "response { }" section instead`);
|
||||
}
|
||||
entries.push({
|
||||
mode: "any",
|
||||
name: nameToken.value,
|
||||
method: methodToken.value.toUpperCase(),
|
||||
path,
|
||||
body: "",
|
||||
sections,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
},
|
||||
"packages/syntax/src/diagnostics.ts": function (module, exports, require, __filename, __dirname) {
|
||||
@@ -6161,10 +6252,11 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
||||
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.stripRuntimeFunctionModifiers = exports.parseStructuredImports = exports.parseStoreLifecycle = exports.parseStateDeclarations = exports.parseRuntimeFunctions = exports.parsePersist = exports.parseOutputs = exports.parseComputedDeclarations = exports.supportsSyntaxFeature = exports.diagnosticSummary = exports.sliceSource = exports.createSourceRange = exports.WRN_SYNTAX_FEATURES = exports.WRN_SYNTAX_VERSION = exports.positionAt = exports.isRuntimeTarget = exports.isHydrationStrategy = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.containsReadonlyPropMutation = exports.classifyParseError = exports.assertValidAst = exports.validateTypedInitializer = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.VOID_ELEMENTS = exports.ParseError = exports.parseHtmlView = exports.parse = exports.formatWrn = exports.LexError = exports.Lexer = void 0;
|
||||
exports.stripRuntimeFunctionModifiers = exports.parseStructuredImports = exports.parseStoreLifecycle = exports.parseStateDeclarations = exports.parseRuntimeFunctions = exports.parsePersist = exports.parseOutputs = exports.parseComputedDeclarations = exports.supportsSyntaxFeature = exports.diagnosticSummary = exports.sliceSource = exports.createSourceRange = exports.WRN_SYNTAX_FEATURES = exports.WRN_SYNTAX_VERSION = exports.positionAt = exports.isRuntimeTarget = exports.isHydrationStrategy = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.containsReadonlyPropMutation = exports.classifyParseError = exports.assertValidAst = exports.validateTypedInitializer = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.VOID_ELEMENTS = exports.ParseError = exports.parseHtmlView = exports.parse = exports.formatWrn = exports.skipLiteralOrComment = exports.LexError = exports.Lexer = void 0;
|
||||
var tokenizer_ts_1 = require("./tokenizer.js");
|
||||
Object.defineProperty(exports, "Lexer", { enumerable: true, get: function () { return tokenizer_ts_1.Lexer; } });
|
||||
Object.defineProperty(exports, "LexError", { enumerable: true, get: function () { return tokenizer_ts_1.LexError; } });
|
||||
Object.defineProperty(exports, "skipLiteralOrComment", { enumerable: true, get: function () { return tokenizer_ts_1.skipLiteralOrComment; } });
|
||||
var formatter_ts_1 = require("./formatter.js");
|
||||
Object.defineProperty(exports, "formatWrn", { enumerable: true, get: function () { return formatter_ts_1.formatWrn; } });
|
||||
var parser_ts_1 = require("./parser.js");
|
||||
@@ -6654,7 +6746,6 @@ function parse(source) {
|
||||
case "client":
|
||||
case "server": {
|
||||
const rawMode = kw.value;
|
||||
const mode = rawMode === "client" ? "client" : "ssr";
|
||||
lx.next();
|
||||
if ((rawMode === "client" || rawMode === "server") &&
|
||||
lx.peek().type === "ident" &&
|
||||
@@ -6665,52 +6756,15 @@ function parse(source) {
|
||||
states.push(...(0, v060_ts_1.parseStateDeclarations)(lx.readBalancedBraces(), rawMode));
|
||||
break;
|
||||
}
|
||||
if (mode === "client" && lx.peek().type === "eq") {
|
||||
if (rawMode === "client" && lx.peek().type === "eq") {
|
||||
lx.next();
|
||||
hydrate = expect("string").value;
|
||||
break;
|
||||
}
|
||||
expect("lbrace");
|
||||
while (lx.peek().type !== "rbrace") {
|
||||
const member = lx.peek();
|
||||
if (member.type === "eof") {
|
||||
throw new ParseError(`Unexpected end of input inside ${mode} block`);
|
||||
}
|
||||
if (member.type !== "ident") {
|
||||
throw new ParseError(`Expected a ${mode} member keyword at offset ${member.pos}`);
|
||||
}
|
||||
switch (member.value) {
|
||||
case "api": {
|
||||
lx.next();
|
||||
const name = expect("ident").value;
|
||||
const method = expect("ident").value.toUpperCase();
|
||||
const path = lx.readPath();
|
||||
const body = lx.readBalancedBraces();
|
||||
const sections = (0, api_sections_ts_1.parseApiSections)(body);
|
||||
if (sections && mode !== "client" && (0, api_sections_ts_1.hasRequestSection)(body)) {
|
||||
throw new ParseError(`An ssr api block cannot declare "request": there is no caller at render time to supply it. Use a client block, or a server function.`);
|
||||
}
|
||||
dataApis.push({
|
||||
mode,
|
||||
name,
|
||||
method,
|
||||
path,
|
||||
body: sections ? "" : body,
|
||||
...(sections ? { sections } : {}),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "functions": {
|
||||
lx.next();
|
||||
modeFunctions.push({ mode, body: lx.readBalancedBraces() });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new ParseError(`Unknown ${mode} member '${member.value}' at offset ${member.pos}`);
|
||||
}
|
||||
if (lx.peek().type === "lbrace") {
|
||||
throw new ParseError(`"${rawMode} { … }" data blocks were removed. Declare API calls in a page-level "apis { }" block, and move mode-scoped helpers into "functions { shared function … }".`);
|
||||
}
|
||||
expect("rbrace");
|
||||
break;
|
||||
throw new ParseError(`Expected a ${rawMode} state block or hydrate assignment`);
|
||||
}
|
||||
case "shared": {
|
||||
lx.next();
|
||||
@@ -6823,6 +6877,12 @@ function parse(source) {
|
||||
persist = (0, v060_ts_1.parsePersist)(lx.readBalancedBraces());
|
||||
break;
|
||||
}
|
||||
case "apis": {
|
||||
lx.next();
|
||||
const body = lx.readBalancedBraces();
|
||||
dataApis.push(...(0, api_sections_ts_1.parseApiEntries)(body));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new ParseError(`Unknown page member '${kw.value}' at offset ${kw.pos}`);
|
||||
}
|
||||
@@ -6894,6 +6954,13 @@ function parse(source) {
|
||||
};
|
||||
for (const name of namedLoads.keys())
|
||||
visitLoad(name);
|
||||
const seenApiNames = new Set();
|
||||
for (const block of dataApis) {
|
||||
if (seenApiNames.has(block.name)) {
|
||||
throw new ParseError(`Duplicate api entry "${block.name}"`, "WRN-API-DUPLICATE");
|
||||
}
|
||||
seenApiNames.add(block.name);
|
||||
}
|
||||
return {
|
||||
type: "page",
|
||||
imports,
|
||||
@@ -7002,12 +7069,22 @@ function parseHtmlView(src, pos) {
|
||||
if (quote !== '"' && quote !== "'")
|
||||
return fail("Expected a quoted attribute value");
|
||||
i++;
|
||||
const start = i;
|
||||
while (i < src.length && src[i] !== quote)
|
||||
// Backslash escapes the delimiter -- so an attribute value such as the
|
||||
// call expression `api="fn({ a: \"b\" })"` can carry the same quote
|
||||
// character it's wrapped in -- and escapes itself. Every other backslash
|
||||
// is a literal one, so a value like "C:\Users\name" survives intact.
|
||||
let value = "";
|
||||
while (i < src.length && src[i] !== quote) {
|
||||
if (src[i] === "\\" && (src[i + 1] === quote || src[i + 1] === "\\")) {
|
||||
value += src[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
value += src[i];
|
||||
i++;
|
||||
}
|
||||
if (i >= src.length)
|
||||
return fail("Unterminated attribute value");
|
||||
const value = src.slice(start, i);
|
||||
i++; // closing quote
|
||||
return value;
|
||||
};
|
||||
@@ -7973,7 +8050,7 @@ function parseRuntimeFunctions(source) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
let runtime = "legacy";
|
||||
let runtime = "shared";
|
||||
if (["client", "server", "shared"].includes(token.word)) {
|
||||
runtime = token.word;
|
||||
i = skipTrivia(source, token.end);
|
||||
|
||||
@@ -306,6 +306,54 @@ const BLOCK_COMPLETIONS = [
|
||||
"}",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
label: "apis",
|
||||
detail: "Page-level API call declarations block",
|
||||
documentation:
|
||||
"Declare the API calls available to this page as named entries, each callable via api.<name>() from functions and the `api=` view binding.",
|
||||
snippet: [
|
||||
"apis {",
|
||||
" ${1:searchUsers} ${2|GET,POST,PUT,PATCH,DELETE|} ${3:/api/users} {",
|
||||
" request {",
|
||||
" body {",
|
||||
" ${4:name}?: ${5:string}",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" response {",
|
||||
" return ${6:data}",
|
||||
" }",
|
||||
"",
|
||||
" error {",
|
||||
" return ${7:null}",
|
||||
" }",
|
||||
" }",
|
||||
"}",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
label: "apis entry",
|
||||
detail: "Single API call declaration",
|
||||
documentation:
|
||||
"Declare a single named API call inside an apis { } block: <name> <METHOD> <path> { request/response/error }.",
|
||||
snippet: [
|
||||
"${1:searchUsers} ${2|GET,POST,PUT,PATCH,DELETE|} ${3:/api/users} {",
|
||||
" request {",
|
||||
" body {",
|
||||
" ${4:name}?: ${5:string}",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" response {",
|
||||
" return ${6:data}",
|
||||
" }",
|
||||
"",
|
||||
" error {",
|
||||
" return ${7:null}",
|
||||
" }",
|
||||
"}",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
label: "persist",
|
||||
detail: "Include-only store persistence",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// WRN editor extension source hash: 548e7e0c27d5c951325c977cc22f2ee0340dc3e34bd896904e519ee357ca37a8
|
||||
// WRN editor extension source hash: d0ca0feee6516ae43e902e6aea0424bc5c3d05790708eb8a8c68c98162eb99bb
|
||||
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
||||
"use strict";
|
||||
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
// WRN editor language server source hash: cf98947fd66392200bdfb18524a13e8bbb77d4920ba65a4d724e1124152571a3
|
||||
// WRN editor language server source hash: 70d2454817cc3aa546304c880e0a82313bb73b50051556bf7febd12e6a46c78c
|
||||
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
|
||||
// @bun @bun-cjs
|
||||
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
function __accessProp(key) {
|
||||
return this[key];
|
||||
@@ -32,7 +33,37 @@ var __toESM = (mod, isNodeMode, target) => {
|
||||
cache.set(mod, to);
|
||||
return to;
|
||||
};
|
||||
var __toCommonJS = (from) => {
|
||||
var entry = (__moduleCache ??= new WeakMap).get(from), desc;
|
||||
if (entry)
|
||||
return entry;
|
||||
entry = __defProp({}, "__esModule", { value: true });
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (var key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(entry, key))
|
||||
__defProp(entry, key, {
|
||||
get: __accessProp.bind(from, key),
|
||||
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
||||
});
|
||||
}
|
||||
__moduleCache.set(from, entry);
|
||||
return entry;
|
||||
};
|
||||
var __moduleCache;
|
||||
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||||
var __returnValue = (v) => v;
|
||||
function __exportSetter(name, newValue) {
|
||||
this[name] = __returnValue.bind(null, newValue);
|
||||
}
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, {
|
||||
get: all[name],
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
set: __exportSetter.bind(all, name)
|
||||
});
|
||||
};
|
||||
|
||||
// node_modules/.bun/typescript@6.0.3/node_modules/typescript/lib/typescript.js
|
||||
var require_typescript = __commonJS((exports2, module2) => {
|
||||
@@ -54,10 +85,10 @@ var require_typescript = __commonJS((exports2, module2) => {
|
||||
var ts = {};
|
||||
((module3) => {
|
||||
var __defProp2 = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames2 = Object.getOwnPropertyNames;
|
||||
var __hasOwnProp2 = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
var __export2 = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp2(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
@@ -65,13 +96,13 @@ var require_typescript = __commonJS((exports2, module2) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames2(from))
|
||||
if (!__hasOwnProp2.call(to, key) && key !== except)
|
||||
__defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
__defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toCommonJS = (mod) => (__copyProps, mod);
|
||||
var __toCommonJS2 = (mod) => (__copyProps, mod);
|
||||
var typescript_exports = {};
|
||||
__export(typescript_exports, {
|
||||
__export2(typescript_exports, {
|
||||
ANONYMOUS: () => ANONYMOUS,
|
||||
AccessFlags: () => AccessFlags,
|
||||
AssertionLevel: () => AssertionLevel,
|
||||
@@ -2321,7 +2352,7 @@ var require_typescript = __commonJS((exports2, module2) => {
|
||||
writeFileEnsuringDirectories: () => writeFileEnsuringDirectories,
|
||||
zipWith: () => zipWith
|
||||
});
|
||||
module3.exports = __toCommonJS(typescript_exports);
|
||||
module3.exports = __toCommonJS2(typescript_exports);
|
||||
var versionMajorMinor = "6.0";
|
||||
var version = "6.0.3";
|
||||
var Comparison = /* @__PURE__ */ ((Comparison3) => {
|
||||
@@ -4851,8 +4882,8 @@ ${lanes.join(`
|
||||
return compareValues(left.length, right.length);
|
||||
}
|
||||
var VersionRange = class _VersionRange {
|
||||
constructor(spec2) {
|
||||
this._alternatives = spec2 ? Debug.checkDefined(parseRange(spec2), "Invalid range spec.") : emptyArray;
|
||||
constructor(spec3) {
|
||||
this._alternatives = spec3 ? Debug.checkDefined(parseRange(spec3), "Invalid range spec.") : emptyArray;
|
||||
}
|
||||
static tryParse(text) {
|
||||
const sets = parseRange(text);
|
||||
@@ -5055,7 +5086,7 @@ ${lanes.join(`
|
||||
}
|
||||
var timestamp = nativePerformanceTime ? () => nativePerformanceTime.now() : Date.now;
|
||||
var ts_performance_exports = {};
|
||||
__export(ts_performance_exports, {
|
||||
__export2(ts_performance_exports, {
|
||||
clearMarks: () => clearMarks,
|
||||
clearMeasures: () => clearMeasures,
|
||||
createTimer: () => createTimer,
|
||||
@@ -21581,19 +21612,19 @@ ${lanes.join(`
|
||||
if (specs === undefined || specs.length === 0) {
|
||||
return;
|
||||
}
|
||||
return flatMap(specs, (spec2) => spec2 && getSubPatternFromSpec(spec2, basePath, usage, wildcardMatchers[usage]));
|
||||
return flatMap(specs, (spec3) => spec3 && getSubPatternFromSpec(spec3, basePath, usage, wildcardMatchers[usage]));
|
||||
}
|
||||
function isImplicitGlob(lastPathComponent) {
|
||||
return !/[.*?]/.test(lastPathComponent);
|
||||
}
|
||||
function getPatternFromSpec(spec2, basePath, usage) {
|
||||
const pattern = spec2 && getSubPatternFromSpec(spec2, basePath, usage, wildcardMatchers[usage]);
|
||||
function getPatternFromSpec(spec3, basePath, usage) {
|
||||
const pattern = spec3 && getSubPatternFromSpec(spec3, basePath, usage, wildcardMatchers[usage]);
|
||||
return pattern && `^(?:${pattern})${usage === "exclude" ? "(?:$|/)" : "$"}`;
|
||||
}
|
||||
function getSubPatternFromSpec(spec2, basePath, usage, { singleAsteriskRegexFragment, doubleAsteriskRegexFragment, replaceWildcardCharacter: replaceWildcardCharacter2 } = wildcardMatchers[usage]) {
|
||||
function getSubPatternFromSpec(spec3, basePath, usage, { singleAsteriskRegexFragment, doubleAsteriskRegexFragment, replaceWildcardCharacter: replaceWildcardCharacter2 } = wildcardMatchers[usage]) {
|
||||
let subpattern = "";
|
||||
let hasWrittenComponent = false;
|
||||
const components = getNormalizedPathComponents(spec2, basePath);
|
||||
const components = getNormalizedPathComponents(spec3, basePath);
|
||||
const lastComponent = last(components);
|
||||
if (usage !== "exclude" && lastComponent === "**") {
|
||||
return;
|
||||
@@ -40722,8 +40753,8 @@ ${lanes.join(`
|
||||
const wildcardFiles = arrayFrom(wildcardFileMap.values());
|
||||
return literalFiles.concat(wildcardFiles, arrayFrom(wildCardJsonFileMap.values()));
|
||||
}
|
||||
function isExcludedFile(pathToCheck, spec2, basePath, useCaseSensitiveFileNames2, currentDirectory) {
|
||||
const { validatedFilesSpec, validatedIncludeSpecs, validatedExcludeSpecs } = spec2;
|
||||
function isExcludedFile(pathToCheck, spec3, basePath, useCaseSensitiveFileNames2, currentDirectory) {
|
||||
const { validatedFilesSpec, validatedIncludeSpecs, validatedExcludeSpecs } = spec3;
|
||||
if (!length(validatedIncludeSpecs) || !length(validatedExcludeSpecs))
|
||||
return false;
|
||||
basePath = normalizePath(basePath);
|
||||
@@ -40745,7 +40776,7 @@ ${lanes.join(`
|
||||
return lastDotIndex > wildcardIndex;
|
||||
}
|
||||
function matchesExclude(pathToCheck, excludeSpecs, useCaseSensitiveFileNames2, currentDirectory) {
|
||||
return matchesExcludeWorker(pathToCheck, filter(excludeSpecs, (spec2) => !invalidDotDotAfterRecursiveWildcard(spec2)), useCaseSensitiveFileNames2, currentDirectory);
|
||||
return matchesExcludeWorker(pathToCheck, filter(excludeSpecs, (spec3) => !invalidDotDotAfterRecursiveWildcard(spec3)), useCaseSensitiveFileNames2, currentDirectory);
|
||||
}
|
||||
function matchesExcludeWorker(pathToCheck, excludeSpecs, useCaseSensitiveFileNames2, currentDirectory, basePath) {
|
||||
const excludePattern = getRegularExpressionForWildcard(excludeSpecs, combinePaths(normalizePath(currentDirectory), basePath), "exclude");
|
||||
@@ -40757,26 +40788,26 @@ ${lanes.join(`
|
||||
return !hasExtension(pathToCheck) && excludeRegex.test(ensureTrailingDirectorySeparator(pathToCheck));
|
||||
}
|
||||
function validateSpecs(specs, errors, disallowTrailingRecursion, jsonSourceFile, specKey) {
|
||||
return specs.filter((spec2) => {
|
||||
if (!isString(spec2))
|
||||
return specs.filter((spec3) => {
|
||||
if (!isString(spec3))
|
||||
return false;
|
||||
const diag2 = specToDiagnostic(spec2, disallowTrailingRecursion);
|
||||
const diag2 = specToDiagnostic(spec3, disallowTrailingRecursion);
|
||||
if (diag2 !== undefined) {
|
||||
errors.push(createDiagnostic(...diag2));
|
||||
}
|
||||
return diag2 === undefined;
|
||||
});
|
||||
function createDiagnostic(message, spec2) {
|
||||
const element = getTsConfigPropArrayElementValue(jsonSourceFile, specKey, spec2);
|
||||
return createDiagnosticForNodeInSourceFileOrCompilerDiagnostic(jsonSourceFile, element, message, spec2);
|
||||
function createDiagnostic(message, spec3) {
|
||||
const element = getTsConfigPropArrayElementValue(jsonSourceFile, specKey, spec3);
|
||||
return createDiagnosticForNodeInSourceFileOrCompilerDiagnostic(jsonSourceFile, element, message, spec3);
|
||||
}
|
||||
}
|
||||
function specToDiagnostic(spec2, disallowTrailingRecursion) {
|
||||
Debug.assert(typeof spec2 === "string");
|
||||
if (disallowTrailingRecursion && invalidTrailingRecursionPattern.test(spec2)) {
|
||||
return [Diagnostics.File_specification_cannot_end_in_a_recursive_directory_wildcard_Asterisk_Asterisk_Colon_0, spec2];
|
||||
} else if (invalidDotDotAfterRecursiveWildcard(spec2)) {
|
||||
return [Diagnostics.File_specification_cannot_contain_a_parent_directory_that_appears_after_a_recursive_directory_wildcard_Asterisk_Asterisk_Colon_0, spec2];
|
||||
function specToDiagnostic(spec3, disallowTrailingRecursion) {
|
||||
Debug.assert(typeof spec3 === "string");
|
||||
if (disallowTrailingRecursion && invalidTrailingRecursionPattern.test(spec3)) {
|
||||
return [Diagnostics.File_specification_cannot_end_in_a_recursive_directory_wildcard_Asterisk_Asterisk_Colon_0, spec3];
|
||||
} else if (invalidDotDotAfterRecursiveWildcard(spec3)) {
|
||||
return [Diagnostics.File_specification_cannot_contain_a_parent_directory_that_appears_after_a_recursive_directory_wildcard_Asterisk_Asterisk_Colon_0, spec3];
|
||||
}
|
||||
}
|
||||
function getWildcardDirectories({ validatedIncludeSpecs: include, validatedExcludeSpecs: exclude }, basePath, useCaseSensitiveFileNames2) {
|
||||
@@ -40787,11 +40818,11 @@ ${lanes.join(`
|
||||
if (include !== undefined) {
|
||||
const recursiveKeys = [];
|
||||
for (const file of include) {
|
||||
const spec2 = normalizePath(combinePaths(basePath, file));
|
||||
if (excludeRegex && excludeRegex.test(spec2)) {
|
||||
const spec3 = normalizePath(combinePaths(basePath, file));
|
||||
if (excludeRegex && excludeRegex.test(spec3)) {
|
||||
continue;
|
||||
}
|
||||
const match = getWildcardDirectoryFromSpec(spec2, useCaseSensitiveFileNames2);
|
||||
const match = getWildcardDirectoryFromSpec(spec3, useCaseSensitiveFileNames2);
|
||||
if (match) {
|
||||
const { key, path, flags } = match;
|
||||
const existingPath = wildCardKeyToPath.get(key);
|
||||
@@ -40822,20 +40853,20 @@ ${lanes.join(`
|
||||
function toCanonicalKey(path, useCaseSensitiveFileNames2) {
|
||||
return useCaseSensitiveFileNames2 ? path : toFileNameLowerCase(path);
|
||||
}
|
||||
function getWildcardDirectoryFromSpec(spec2, useCaseSensitiveFileNames2) {
|
||||
const match = wildcardDirectoryPattern.exec(spec2);
|
||||
function getWildcardDirectoryFromSpec(spec3, useCaseSensitiveFileNames2) {
|
||||
const match = wildcardDirectoryPattern.exec(spec3);
|
||||
if (match) {
|
||||
const questionWildcardIndex = spec2.indexOf("?");
|
||||
const starWildcardIndex = spec2.indexOf("*");
|
||||
const lastDirectorySeperatorIndex = spec2.lastIndexOf(directorySeparator);
|
||||
const questionWildcardIndex = spec3.indexOf("?");
|
||||
const starWildcardIndex = spec3.indexOf("*");
|
||||
const lastDirectorySeperatorIndex = spec3.lastIndexOf(directorySeparator);
|
||||
return {
|
||||
key: toCanonicalKey(match[0], useCaseSensitiveFileNames2),
|
||||
path: match[0],
|
||||
flags: questionWildcardIndex !== -1 && questionWildcardIndex < lastDirectorySeperatorIndex || starWildcardIndex !== -1 && starWildcardIndex < lastDirectorySeperatorIndex ? 1 : 0
|
||||
};
|
||||
}
|
||||
if (isImplicitGlob(spec2.substring(spec2.lastIndexOf(directorySeparator) + 1))) {
|
||||
const path = removeTrailingDirectorySeparator(spec2);
|
||||
if (isImplicitGlob(spec3.substring(spec3.lastIndexOf(directorySeparator) + 1))) {
|
||||
const path = removeTrailingDirectorySeparator(spec3);
|
||||
return {
|
||||
key: toCanonicalKey(path, useCaseSensitiveFileNames2),
|
||||
path,
|
||||
@@ -45962,7 +45993,7 @@ ${lanes.join(`
|
||||
}
|
||||
}
|
||||
var ts_moduleSpecifiers_exports = {};
|
||||
__export(ts_moduleSpecifiers_exports, {
|
||||
__export2(ts_moduleSpecifiers_exports, {
|
||||
RelativePreference: () => RelativePreference,
|
||||
countPathComponents: () => countPathComponents,
|
||||
forEachFileNameOfModule: () => forEachFileNameOfModule,
|
||||
@@ -118075,7 +118106,7 @@ ${lanes.join(`
|
||||
}
|
||||
}
|
||||
var ts_JsTyping_exports = {};
|
||||
__export(ts_JsTyping_exports, {
|
||||
__export2(ts_JsTyping_exports, {
|
||||
NameValidationResult: () => NameValidationResult,
|
||||
discoverTypings: () => discoverTypings,
|
||||
isTypingUpToDate: () => isTypingUpToDate,
|
||||
@@ -121532,8 +121563,8 @@ ${lanes.join(`
|
||||
}
|
||||
}
|
||||
function getIsExcludedPatterns(preferences, useCaseSensitiveFileNames2) {
|
||||
return mapDefined(preferences.autoImportFileExcludePatterns, (spec2) => {
|
||||
const pattern = getSubPatternFromSpec(spec2, "", "exclude");
|
||||
return mapDefined(preferences.autoImportFileExcludePatterns, (spec3) => {
|
||||
const pattern = getSubPatternFromSpec(spec3, "", "exclude");
|
||||
return pattern ? getRegexFromPattern(pattern, useCaseSensitiveFileNames2) : undefined;
|
||||
});
|
||||
}
|
||||
@@ -124366,7 +124397,7 @@ interface Symbol {
|
||||
return options;
|
||||
}
|
||||
var ts_NavigateTo_exports = {};
|
||||
__export(ts_NavigateTo_exports, {
|
||||
__export2(ts_NavigateTo_exports, {
|
||||
getNavigateToItems: () => getNavigateToItems
|
||||
});
|
||||
function getNavigateToItems(sourceFiles, checker, cancellationToken, searchValue, maxResultCount, excludeDtsFiles, excludeLibFiles, program) {
|
||||
@@ -124471,7 +124502,7 @@ interface Symbol {
|
||||
};
|
||||
}
|
||||
var ts_NavigationBar_exports = {};
|
||||
__export(ts_NavigationBar_exports, {
|
||||
__export2(ts_NavigationBar_exports, {
|
||||
getNavigationBarItems: () => getNavigationBarItems,
|
||||
getNavigationTree: () => getNavigationTree
|
||||
});
|
||||
@@ -125200,7 +125231,7 @@ interface Symbol {
|
||||
return text.replace(/\\?(?:\r?\n|[\r\u2028\u2029])/g, "");
|
||||
}
|
||||
var ts_refactor_exports = {};
|
||||
__export(ts_refactor_exports, {
|
||||
__export2(ts_refactor_exports, {
|
||||
addExportsInOldFile: () => addExportsInOldFile,
|
||||
addImportsForMovedSymbols: () => addImportsForMovedSymbols,
|
||||
addNewFileToTsconfig: () => addNewFileToTsconfig,
|
||||
@@ -125345,8 +125376,8 @@ interface Symbol {
|
||||
if (wasDefault) {
|
||||
if (isExportAssignment(exportNode) && !exportNode.isExportEquals) {
|
||||
const exp = exportNode.expression;
|
||||
const spec2 = makeExportSpecifier(exp.text, exp.text);
|
||||
changes.replaceNode(exportingSourceFile, exportNode, factory.createExportDeclaration(undefined, false, factory.createNamedExports([spec2])));
|
||||
const spec3 = makeExportSpecifier(exp.text, exp.text);
|
||||
changes.replaceNode(exportingSourceFile, exportNode, factory.createExportDeclaration(undefined, false, factory.createNamedExports([spec3])));
|
||||
} else {
|
||||
changes.delete(exportingSourceFile, Debug.checkDefined(findModifier(exportNode, 90), "Should find a default keyword in modifier list"));
|
||||
}
|
||||
@@ -125397,17 +125428,17 @@ interface Symbol {
|
||||
break;
|
||||
case 277:
|
||||
case 282: {
|
||||
const spec2 = parent2;
|
||||
changes.replaceNode(importingSourceFile, spec2, makeImportSpecifier(exportName, spec2.name.text));
|
||||
const spec3 = parent2;
|
||||
changes.replaceNode(importingSourceFile, spec3, makeImportSpecifier(exportName, spec3.name.text));
|
||||
break;
|
||||
}
|
||||
case 274: {
|
||||
const clause = parent2;
|
||||
Debug.assert(clause.name === ref, "Import clause name should match provided ref");
|
||||
const spec2 = makeImportSpecifier(exportName, ref.text);
|
||||
const spec3 = makeImportSpecifier(exportName, ref.text);
|
||||
const { namedBindings } = clause;
|
||||
if (!namedBindings) {
|
||||
changes.replaceNode(importingSourceFile, ref, factory.createNamedImports([spec2]));
|
||||
changes.replaceNode(importingSourceFile, ref, factory.createNamedImports([spec3]));
|
||||
} else if (namedBindings.kind === 275) {
|
||||
changes.deleteRange(importingSourceFile, { pos: ref.getStart(importingSourceFile), end: namedBindings.getStart(importingSourceFile) });
|
||||
const quotePreference = isStringLiteral(clause.parent.moduleSpecifier) ? quotePreferenceFromString(clause.parent.moduleSpecifier, importingSourceFile) : 1;
|
||||
@@ -125415,7 +125446,7 @@ interface Symbol {
|
||||
changes.insertNodeAfter(importingSourceFile, clause.parent, newImport);
|
||||
} else {
|
||||
changes.delete(importingSourceFile, ref);
|
||||
changes.insertNodeAtEndOfList(importingSourceFile, namedBindings.elements, spec2);
|
||||
changes.insertNodeAtEndOfList(importingSourceFile, namedBindings.elements, spec3);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -128196,7 +128227,7 @@ ${newComment.split(`
|
||||
}
|
||||
}
|
||||
var ts_refactor_extractSymbol_exports = {};
|
||||
__export(ts_refactor_extractSymbol_exports, {
|
||||
__export2(ts_refactor_extractSymbol_exports, {
|
||||
Messages: () => Messages,
|
||||
RangeFacts: () => RangeFacts,
|
||||
getRangeToExtract: () => getRangeToExtract2,
|
||||
@@ -132122,7 +132153,7 @@ ${newComment.split(`
|
||||
return result;
|
||||
}
|
||||
var ts_BreakpointResolver_exports = {};
|
||||
__export(ts_BreakpointResolver_exports, {
|
||||
__export2(ts_BreakpointResolver_exports, {
|
||||
spanInSourceFileAtLocation: () => spanInSourceFileAtLocation
|
||||
});
|
||||
function spanInSourceFileAtLocation(sourceFile, position) {
|
||||
@@ -132600,7 +132631,7 @@ ${newComment.split(`
|
||||
}
|
||||
}
|
||||
var ts_CallHierarchy_exports = {};
|
||||
__export(ts_CallHierarchy_exports, {
|
||||
__export2(ts_CallHierarchy_exports, {
|
||||
createCallHierarchyItem: () => createCallHierarchyItem,
|
||||
getIncomingCalls: () => getIncomingCalls,
|
||||
getOutgoingCalls: () => getOutgoingCalls,
|
||||
@@ -133009,11 +133040,11 @@ ${newComment.split(`
|
||||
return group(collectCallSites(program, declaration), getCallSiteGroupKey, (entries) => convertCallSiteGroupToOutgoingCall(program, entries));
|
||||
}
|
||||
var ts_classifier_exports = {};
|
||||
__export(ts_classifier_exports, {
|
||||
__export2(ts_classifier_exports, {
|
||||
v2020: () => ts_classifier_v2020_exports
|
||||
});
|
||||
var ts_classifier_v2020_exports = {};
|
||||
__export(ts_classifier_v2020_exports, {
|
||||
__export2(ts_classifier_v2020_exports, {
|
||||
TokenEncodingConsts: () => TokenEncodingConsts,
|
||||
TokenModifier: () => TokenModifier,
|
||||
TokenType: () => TokenType,
|
||||
@@ -133021,7 +133052,7 @@ ${newComment.split(`
|
||||
getSemanticClassifications: () => getSemanticClassifications2
|
||||
});
|
||||
var ts_codefix_exports = {};
|
||||
__export(ts_codefix_exports, {
|
||||
__export2(ts_codefix_exports, {
|
||||
PreserveOptionalFlags: () => PreserveOptionalFlags,
|
||||
addNewNodeForMemberSymbol: () => addNewNodeForMemberSymbol,
|
||||
codeFixAll: () => codeFixAll,
|
||||
@@ -136454,13 +136485,13 @@ ${newComment.split(`
|
||||
changes.replaceNode(sourceFile, clause.namedBindings, factory.updateNamedImports(clause.namedBindings, toSorted([...existingSpecifiers.filter((s) => !removeExistingImportSpecifiers.has(s)), ...newSpecifiers], specifierComparer)));
|
||||
} else if ((existingSpecifiers == null ? undefined : existingSpecifiers.length) && isSorted !== false) {
|
||||
const transformedExistingSpecifiers = promoteFromTypeOnly2 && existingSpecifiers ? factory.updateNamedImports(clause.namedBindings, sameMap(existingSpecifiers, (e) => factory.updateImportSpecifier(e, true, e.propertyName, e.name))).elements : existingSpecifiers;
|
||||
for (const spec2 of newSpecifiers) {
|
||||
const insertionIndex = ts_OrganizeImports_exports.getImportSpecifierInsertionIndex(transformedExistingSpecifiers, spec2, specifierComparer);
|
||||
changes.insertImportSpecifierAtIndex(sourceFile, spec2, clause.namedBindings, insertionIndex);
|
||||
for (const spec3 of newSpecifiers) {
|
||||
const insertionIndex = ts_OrganizeImports_exports.getImportSpecifierInsertionIndex(transformedExistingSpecifiers, spec3, specifierComparer);
|
||||
changes.insertImportSpecifierAtIndex(sourceFile, spec3, clause.namedBindings, insertionIndex);
|
||||
}
|
||||
} else if (existingSpecifiers == null ? undefined : existingSpecifiers.length) {
|
||||
for (const spec2 of newSpecifiers) {
|
||||
changes.insertNodeInListAfter(sourceFile, last(existingSpecifiers), spec2, existingSpecifiers);
|
||||
for (const spec3 of newSpecifiers) {
|
||||
changes.insertNodeInListAfter(sourceFile, last(existingSpecifiers), spec3, existingSpecifiers);
|
||||
}
|
||||
} else {
|
||||
if (newSpecifiers.length) {
|
||||
@@ -142220,7 +142251,7 @@ ${newComment.split(`
|
||||
}
|
||||
}
|
||||
var ts_Completions_exports = {};
|
||||
__export(ts_Completions_exports, {
|
||||
__export2(ts_Completions_exports, {
|
||||
CompletionKind: () => CompletionKind,
|
||||
CompletionSource: () => CompletionSource,
|
||||
SortText: () => SortText,
|
||||
@@ -145770,7 +145801,7 @@ ${newComment.split(`
|
||||
return keyword === "abstract" || keyword === "async" || keyword === "await" || keyword === "declare" || keyword === "module" || keyword === "namespace" || keyword === "type" || keyword === "satisfies" || keyword === "as";
|
||||
}
|
||||
var ts_Completions_StringCompletions_exports = {};
|
||||
__export(ts_Completions_StringCompletions_exports, {
|
||||
__export2(ts_Completions_StringCompletions_exports, {
|
||||
getStringLiteralCompletionDetails: () => getStringLiteralCompletionDetails,
|
||||
getStringLiteralCompletions: () => getStringLiteralCompletions
|
||||
});
|
||||
@@ -146603,7 +146634,7 @@ ${newComment.split(`
|
||||
return isCallExpression(node.parent) && firstOrUndefined(node.parent.arguments) === node && isIdentifier(node.parent.expression) && node.parent.expression.escapedText === "require";
|
||||
}
|
||||
var ts_FindAllReferences_exports = {};
|
||||
__export(ts_FindAllReferences_exports, {
|
||||
__export2(ts_FindAllReferences_exports, {
|
||||
Core: () => Core,
|
||||
DefinitionKind: () => DefinitionKind,
|
||||
EntryKind: () => EntryKind,
|
||||
@@ -148818,7 +148849,7 @@ ${newComment.split(`
|
||||
}
|
||||
})(Core || (Core = {}));
|
||||
var ts_GoToDefinition_exports = {};
|
||||
__export(ts_GoToDefinition_exports, {
|
||||
__export2(ts_GoToDefinition_exports, {
|
||||
createDefinitionInfo: () => createDefinitionInfo,
|
||||
getDefinitionAndBoundSpan: () => getDefinitionAndBoundSpan,
|
||||
getDefinitionAtPosition: () => getDefinitionAtPosition,
|
||||
@@ -149314,7 +149345,7 @@ ${newComment.split(`
|
||||
}
|
||||
}
|
||||
var ts_InlayHints_exports = {};
|
||||
__export(ts_InlayHints_exports, {
|
||||
__export2(ts_InlayHints_exports, {
|
||||
provideInlayHints: () => provideInlayHints
|
||||
});
|
||||
var leadingParameterNameCommentRegexFactory = (name) => {
|
||||
@@ -150076,7 +150107,7 @@ ${newComment.split(`
|
||||
}
|
||||
}
|
||||
var ts_JsDoc_exports = {};
|
||||
__export(ts_JsDoc_exports, {
|
||||
__export2(ts_JsDoc_exports, {
|
||||
getDocCommentTemplateAtPosition: () => getDocCommentTemplateAtPosition,
|
||||
getJSDocParameterNameCompletionDetails: () => getJSDocParameterNameCompletionDetails,
|
||||
getJSDocParameterNameCompletions: () => getJSDocParameterNameCompletions,
|
||||
@@ -150496,7 +150527,7 @@ ${newComment.split(`
|
||||
}
|
||||
}
|
||||
var ts_MapCode_exports = {};
|
||||
__export(ts_MapCode_exports, {
|
||||
__export2(ts_MapCode_exports, {
|
||||
mapCode: () => mapCode
|
||||
});
|
||||
function mapCode(sourceFile, contents, focusLocations, host, formatContext, preferences) {
|
||||
@@ -150631,7 +150662,7 @@ ${content}
|
||||
node.forEachChild(resetNodePositions);
|
||||
}
|
||||
var ts_OrganizeImports_exports = {};
|
||||
__export(ts_OrganizeImports_exports, {
|
||||
__export2(ts_OrganizeImports_exports, {
|
||||
compareImportsOrRequireStatements: () => compareImportsOrRequireStatements,
|
||||
compareModuleSpecifiers: () => compareModuleSpecifiers2,
|
||||
getImportDeclarationInsertionIndex: () => getImportDeclarationInsertionIndex,
|
||||
@@ -151214,7 +151245,7 @@ ${content}
|
||||
return compareModuleSpecifiersWorker(m1, m2, comparer);
|
||||
}
|
||||
var ts_OutliningElementsCollector_exports = {};
|
||||
__export(ts_OutliningElementsCollector_exports, {
|
||||
__export2(ts_OutliningElementsCollector_exports, {
|
||||
collectElements: () => collectElements
|
||||
});
|
||||
function collectElements(sourceFile, cancellationToken) {
|
||||
@@ -151532,7 +151563,7 @@ ${content}
|
||||
return findChildOfKind(body, 19, sourceFile);
|
||||
}
|
||||
var ts_Rename_exports = {};
|
||||
__export(ts_Rename_exports, {
|
||||
__export2(ts_Rename_exports, {
|
||||
getRenameInfo: () => getRenameInfo,
|
||||
nodeIsEligibleForRename: () => nodeIsEligibleForRename
|
||||
});
|
||||
@@ -151686,7 +151717,7 @@ ${content}
|
||||
}
|
||||
}
|
||||
var ts_SignatureHelp_exports = {};
|
||||
__export(ts_SignatureHelp_exports, {
|
||||
__export2(ts_SignatureHelp_exports, {
|
||||
getArgumentInfoForCompletions: () => getArgumentInfoForCompletions,
|
||||
getSignatureHelpItems: () => getSignatureHelpItems
|
||||
});
|
||||
@@ -152160,7 +152191,7 @@ ${content}
|
||||
return { name: typeParameter.symbol.name, documentation: typeParameter.symbol.getDocumentationComment(checker), displayParts, isOptional: false, isRest: false };
|
||||
}
|
||||
var ts_SmartSelectionRange_exports = {};
|
||||
__export(ts_SmartSelectionRange_exports, {
|
||||
__export2(ts_SmartSelectionRange_exports, {
|
||||
getSmartSelectionRange: () => getSmartSelectionRange
|
||||
});
|
||||
function getSmartSelectionRange(pos, sourceFile) {
|
||||
@@ -152352,7 +152383,7 @@ ${content}
|
||||
}
|
||||
}
|
||||
var ts_SymbolDisplay_exports = {};
|
||||
__export(ts_SymbolDisplay_exports, {
|
||||
__export2(ts_SymbolDisplay_exports, {
|
||||
getSymbolDisplayPartsDocumentationAndSymbolKind: () => getSymbolDisplayPartsDocumentationAndSymbolKind,
|
||||
getSymbolKind: () => getSymbolKind,
|
||||
getSymbolModifiers: () => getSymbolModifiers
|
||||
@@ -153058,7 +153089,7 @@ ${content}
|
||||
});
|
||||
}
|
||||
var ts_textChanges_exports = {};
|
||||
__export(ts_textChanges_exports, {
|
||||
__export2(ts_textChanges_exports, {
|
||||
ChangeTracker: () => ChangeTracker,
|
||||
LeadingTriviaOption: () => LeadingTriviaOption,
|
||||
TrailingTriviaOption: () => TrailingTriviaOption,
|
||||
@@ -154255,7 +154286,7 @@ ${options.prefix}` : `
|
||||
});
|
||||
}
|
||||
var ts_formatting_exports = {};
|
||||
__export(ts_formatting_exports, {
|
||||
__export2(ts_formatting_exports, {
|
||||
FormattingContext: () => FormattingContext,
|
||||
FormattingRequestKind: () => FormattingRequestKind,
|
||||
RuleAction: () => RuleAction,
|
||||
@@ -156640,7 +156671,7 @@ ${options.prefix}` : `
|
||||
}
|
||||
})(SmartIndenter || (SmartIndenter = {}));
|
||||
var ts_preparePasteEdits_exports = {};
|
||||
__export(ts_preparePasteEdits_exports, {
|
||||
__export2(ts_preparePasteEdits_exports, {
|
||||
preparePasteEdits: () => preparePasteEdits
|
||||
});
|
||||
function preparePasteEdits(sourceFile, copiedFromRange, checker) {
|
||||
@@ -156672,7 +156703,7 @@ ${options.prefix}` : `
|
||||
return shouldProvidePasteEdits;
|
||||
}
|
||||
var ts_PasteEdits_exports = {};
|
||||
__export(ts_PasteEdits_exports, {
|
||||
__export2(ts_PasteEdits_exports, {
|
||||
pasteEditsProvider: () => pasteEditsProvider
|
||||
});
|
||||
var fixId55 = "providePostPasteEdits";
|
||||
@@ -156763,7 +156794,7 @@ ${options.prefix}` : `
|
||||
};
|
||||
}
|
||||
var ts_exports2 = {};
|
||||
__export(ts_exports2, {
|
||||
__export2(ts_exports2, {
|
||||
ANONYMOUS: () => ANONYMOUS,
|
||||
AccessFlags: () => AccessFlags,
|
||||
AssertionLevel: () => AssertionLevel,
|
||||
@@ -159104,7 +159135,7 @@ ${options.prefix}` : `
|
||||
};
|
||||
}
|
||||
var ts_server_exports3 = {};
|
||||
__export(ts_server_exports3, {
|
||||
__export2(ts_server_exports3, {
|
||||
ActionInvalidate: () => ActionInvalidate,
|
||||
ActionPackageInstalled: () => ActionPackageInstalled,
|
||||
ActionSet: () => ActionSet,
|
||||
@@ -159202,7 +159233,7 @@ ${options.prefix}` : `
|
||||
updateProjectIfDirty: () => updateProjectIfDirty
|
||||
});
|
||||
var ts_server_typingsInstaller_exports = {};
|
||||
__export(ts_server_typingsInstaller_exports, {
|
||||
__export2(ts_server_typingsInstaller_exports, {
|
||||
TypingsInstaller: () => TypingsInstaller,
|
||||
getNpmCommandForInstallation: () => getNpmCommandForInstallation,
|
||||
installNpmPackages: () => installNpmPackages,
|
||||
@@ -159757,7 +159788,7 @@ ${options.prefix}` : `
|
||||
return base === "tsconfig.json" || base === "jsconfig.json" ? base : undefined;
|
||||
}
|
||||
var ts_server_protocol_exports = {};
|
||||
__export(ts_server_protocol_exports, {
|
||||
__export2(ts_server_protocol_exports, {
|
||||
ClassificationType: () => ClassificationType,
|
||||
CommandTypes: () => CommandTypes,
|
||||
CompletionTriggerKind: () => CompletionTriggerKind,
|
||||
@@ -169359,7 +169390,7 @@ Additional information: BADCLIENT: Bad error code, ${badCode} not found in range
|
||||
_TypingsInstallerAdapter.requestDelayMillis = 100;
|
||||
var TypingsInstallerAdapter = _TypingsInstallerAdapter;
|
||||
var ts_server_exports4 = {};
|
||||
__export(ts_server_exports4, {
|
||||
__export2(ts_server_exports4, {
|
||||
ActionInvalidate: () => ActionInvalidate,
|
||||
ActionPackageInstalled: () => ActionPackageInstalled,
|
||||
ActionSet: () => ActionSet,
|
||||
@@ -169485,10 +169516,10 @@ Additional information: BADCLIENT: Bad error code, ${badCode} not found in range
|
||||
// node_modules/.bun/@vscode+l10n@0.0.18/node_modules/@vscode/l10n/dist/main.js
|
||||
var require_main = __commonJS((exports2, module2) => {
|
||||
var __defProp2 = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames2 = Object.getOwnPropertyNames;
|
||||
var __hasOwnProp2 = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
var __export2 = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp2(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
@@ -169496,17 +169527,17 @@ var require_main = __commonJS((exports2, module2) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames2(from))
|
||||
if (!__hasOwnProp2.call(to, key) && key !== except)
|
||||
__defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
__defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod);
|
||||
var __toCommonJS2 = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod);
|
||||
var main_exports = {};
|
||||
__export(main_exports, {
|
||||
__export2(main_exports, {
|
||||
config: () => config,
|
||||
t: () => t
|
||||
});
|
||||
module2.exports = __toCommonJS(main_exports);
|
||||
module2.exports = __toCommonJS2(main_exports);
|
||||
var import_fs = require("fs");
|
||||
var import_promises = require("fs/promises");
|
||||
async function readFileFromUri(uri) {
|
||||
@@ -169624,6 +169655,14 @@ var require_main = __commonJS((exports2, module2) => {
|
||||
}
|
||||
});
|
||||
|
||||
// packages/language-server/src/server.ts
|
||||
var exports_server = {};
|
||||
__export(exports_server, {
|
||||
apiCallHover: () => apiCallHover,
|
||||
apiCallCompletions: () => apiCallCompletions
|
||||
});
|
||||
module.exports = __toCommonJS(exports_server);
|
||||
|
||||
// packages/syntax/src/tokenizer.ts
|
||||
class LexError extends Error {
|
||||
}
|
||||
@@ -170866,8 +170905,34 @@ function parseApiSections(source) {
|
||||
error: top.get("error")?.text ?? ""
|
||||
};
|
||||
}
|
||||
function hasRequestSection(source) {
|
||||
return scanTopLevelBlocks(source, SECTION_NAMES).has("request");
|
||||
function parseApiEntries(source) {
|
||||
const entries = [];
|
||||
const lx = new Lexer(source);
|
||||
while (lx.peek().type !== "eof") {
|
||||
const nameToken = lx.next();
|
||||
if (nameToken.type !== "ident") {
|
||||
throw new LexError(`Expected an api entry name at offset ${nameToken.pos}`);
|
||||
}
|
||||
const methodToken = lx.next();
|
||||
if (methodToken.type !== "ident") {
|
||||
throw new LexError(`Expected an HTTP method after "${nameToken.value}"`);
|
||||
}
|
||||
const path = lx.readPath();
|
||||
const entryBody = lx.readBalancedBraces();
|
||||
const sections = parseApiSections(entryBody);
|
||||
if (sections === null) {
|
||||
throw new LexError(`Api entry "${nameToken.value}" has a bare body; declare a "response { }" section instead`);
|
||||
}
|
||||
entries.push({
|
||||
mode: "any",
|
||||
name: nameToken.value,
|
||||
method: methodToken.value.toUpperCase(),
|
||||
path,
|
||||
body: "",
|
||||
sections
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// packages/syntax/src/types.ts
|
||||
@@ -171090,7 +171155,7 @@ function parseRuntimeFunctions(source) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
let runtime = "legacy";
|
||||
let runtime = "shared";
|
||||
if (["client", "server", "shared"].includes(token.word)) {
|
||||
runtime = token.word;
|
||||
i = skipTrivia(source, token.end);
|
||||
@@ -171720,7 +171785,6 @@ function parse(source) {
|
||||
case "client":
|
||||
case "server": {
|
||||
const rawMode = kw.value;
|
||||
const mode = rawMode === "client" ? "client" : "ssr";
|
||||
lx.next();
|
||||
if ((rawMode === "client" || rawMode === "server") && lx.peek().type === "ident" && lx.peek().value === "state") {
|
||||
lx.next();
|
||||
@@ -171729,52 +171793,15 @@ function parse(source) {
|
||||
states.push(...parseStateDeclarations(lx.readBalancedBraces(), rawMode));
|
||||
break;
|
||||
}
|
||||
if (mode === "client" && lx.peek().type === "eq") {
|
||||
if (rawMode === "client" && lx.peek().type === "eq") {
|
||||
lx.next();
|
||||
hydrate = expect("string").value;
|
||||
break;
|
||||
}
|
||||
expect("lbrace");
|
||||
while (lx.peek().type !== "rbrace") {
|
||||
const member = lx.peek();
|
||||
if (member.type === "eof") {
|
||||
throw new ParseError(`Unexpected end of input inside ${mode} block`);
|
||||
}
|
||||
if (member.type !== "ident") {
|
||||
throw new ParseError(`Expected a ${mode} member keyword at offset ${member.pos}`);
|
||||
}
|
||||
switch (member.value) {
|
||||
case "api": {
|
||||
lx.next();
|
||||
const name2 = expect("ident").value;
|
||||
const method = expect("ident").value.toUpperCase();
|
||||
const path = lx.readPath();
|
||||
const body = lx.readBalancedBraces();
|
||||
const sections = parseApiSections(body);
|
||||
if (sections && mode !== "client" && hasRequestSection(body)) {
|
||||
throw new ParseError(`An ssr api block cannot declare "request": there is no caller at render time to supply it. Use a client block, or a server function.`);
|
||||
}
|
||||
dataApis.push({
|
||||
mode,
|
||||
name: name2,
|
||||
method,
|
||||
path,
|
||||
body: sections ? "" : body,
|
||||
...sections ? { sections } : {}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "functions": {
|
||||
lx.next();
|
||||
modeFunctions.push({ mode, body: lx.readBalancedBraces() });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new ParseError(`Unknown ${mode} member '${member.value}' at offset ${member.pos}`);
|
||||
}
|
||||
if (lx.peek().type === "lbrace") {
|
||||
throw new ParseError(`"${rawMode} { … }" data blocks were removed. Declare API calls in a page-level "apis { }" block, and move mode-scoped helpers into "functions { shared function … }".`);
|
||||
}
|
||||
expect("rbrace");
|
||||
break;
|
||||
throw new ParseError(`Expected a ${rawMode} state block or hydrate assignment`);
|
||||
}
|
||||
case "shared": {
|
||||
lx.next();
|
||||
@@ -171881,6 +171908,12 @@ function parse(source) {
|
||||
persist = parsePersist(lx.readBalancedBraces());
|
||||
break;
|
||||
}
|
||||
case "apis": {
|
||||
lx.next();
|
||||
const body = lx.readBalancedBraces();
|
||||
dataApis.push(...parseApiEntries(body));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new ParseError(`Unknown page member '${kw.value}' at offset ${kw.pos}`);
|
||||
}
|
||||
@@ -171950,6 +171983,13 @@ function parse(source) {
|
||||
};
|
||||
for (const name2 of namedLoads.keys())
|
||||
visitLoad(name2);
|
||||
const seenApiNames = new Set;
|
||||
for (const block of dataApis) {
|
||||
if (seenApiNames.has(block.name)) {
|
||||
throw new ParseError(`Duplicate api entry "${block.name}"`, "WRN-API-DUPLICATE");
|
||||
}
|
||||
seenApiNames.add(block.name);
|
||||
}
|
||||
return {
|
||||
type: "page",
|
||||
imports,
|
||||
@@ -172040,12 +172080,18 @@ function parseHtmlView(src, pos) {
|
||||
if (quote !== '"' && quote !== "'")
|
||||
return fail("Expected a quoted attribute value");
|
||||
i++;
|
||||
const start = i;
|
||||
while (i < src.length && src[i] !== quote)
|
||||
let value = "";
|
||||
while (i < src.length && src[i] !== quote) {
|
||||
if (src[i] === "\\" && (src[i + 1] === quote || src[i + 1] === "\\")) {
|
||||
value += src[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
value += src[i];
|
||||
i++;
|
||||
}
|
||||
if (i >= src.length)
|
||||
return fail("Unterminated attribute value");
|
||||
const value = src.slice(start, i);
|
||||
i++;
|
||||
return value;
|
||||
};
|
||||
@@ -172681,7 +172727,7 @@ function componentContract(ast) {
|
||||
`);
|
||||
const outputs = ast.outputs.map((output) => ` ${safe(output.name)}(${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}): void;`).join(`
|
||||
`);
|
||||
const callable = ast.runtimeFunctions.filter((fn) => fn.runtime !== "legacy").map((fn) => ` ${safe(fn.name)}(${fn.parameters.map((param) => `${param.name}${param.optional ? "?" : ""}: ${param.valueType ?? "unknown"}`).join(", ")}): ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")};`).join(`
|
||||
const callable = ast.runtimeFunctions.map((fn) => ` ${safe(fn.name)}(${fn.parameters.map((param) => `${param.name}${param.optional ? "?" : ""}: ${param.valueType ?? "unknown"}`).join(", ")}): ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")};`).join(`
|
||||
`);
|
||||
return `${typeSource ? `${typeSource}
|
||||
|
||||
@@ -172960,7 +173006,7 @@ function virtualTypeScriptModule(source, filePath = "component.wrn", appRoot = f
|
||||
append(ast.kind === "global-store" || ast.kind === "page-store" ? storeContract(ast) : componentContract(ast));
|
||||
append(importedWrnDeclarations(ast, filePath, appRoot));
|
||||
const sharedNames = new Set(ast.runtimeFunctions.filter((fn) => fn.runtime === "shared").map((fn) => fn.name));
|
||||
for (const runtime of ["shared", "client", "server", "legacy"]) {
|
||||
for (const runtime of ["shared", "client", "server"]) {
|
||||
const functions = ast.runtimeFunctions.filter((fn) => fn.runtime === runtime);
|
||||
if (!functions.length)
|
||||
continue;
|
||||
@@ -173583,7 +173629,7 @@ function htmlToWrn(html, name = "ImportedPage") {
|
||||
}
|
||||
|
||||
// packages/language-server/src/index.ts
|
||||
var WRN_COMPLETIONS = [
|
||||
var WRN_KEYWORDS = [
|
||||
"page",
|
||||
"component",
|
||||
"layout",
|
||||
@@ -173597,6 +173643,7 @@ var WRN_COMPLETIONS = [
|
||||
"load",
|
||||
"action",
|
||||
"api",
|
||||
"apis",
|
||||
"realtime",
|
||||
"view",
|
||||
"style",
|
||||
@@ -173759,7 +173806,7 @@ ${declaration[0]}
|
||||
};
|
||||
}
|
||||
function completionItems() {
|
||||
return WRN_COMPLETIONS.map((label) => ({ label, kind: 14, detail: "WRNexus language keyword" }));
|
||||
return WRN_KEYWORDS.map((label) => ({ label, kind: 14, detail: "WRNexus language keyword" }));
|
||||
}
|
||||
var semanticTokenTypes = ["variable"];
|
||||
var semanticTokenModifiers = ["declaration", "modification"];
|
||||
@@ -196156,6 +196203,46 @@ function clearHtmlRegionCache(uri) {
|
||||
}
|
||||
|
||||
// packages/language-server/src/server.ts
|
||||
function dataApisOf(source) {
|
||||
try {
|
||||
return parse(source).dataApis;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
function apiDetail(block) {
|
||||
return `${block.method} ${block.path}`;
|
||||
}
|
||||
function apiCallCompletions(source) {
|
||||
return dataApisOf(source).map((block) => ({
|
||||
label: block.name,
|
||||
kind: 2,
|
||||
detail: apiDetail(block),
|
||||
documentation: block.sections ? [
|
||||
block.sections.body.length ? `body: ${block.sections.body.map((field) => `${field.name}${field.optional ? "?" : ""}: ${field.type}`).join(", ")}` : undefined
|
||||
].filter(Boolean).join(`
|
||||
`) : undefined
|
||||
}));
|
||||
}
|
||||
function apiCallHover(source, name) {
|
||||
const block = dataApisOf(source).find((entry) => entry.name === name);
|
||||
if (!block)
|
||||
return;
|
||||
const lines = [`**${block.name}** \`${block.method} ${block.path}\``];
|
||||
if (block.sections?.body.length) {
|
||||
lines.push(`request body: ${block.sections.body.map((field) => `${field.name}${field.optional ? "?" : ""}: ${field.type}`).join(", ")}`);
|
||||
}
|
||||
if (block.sections?.parameters.length) {
|
||||
lines.push(`parameters: ${block.sections.parameters.map((field) => `${field.name}${field.optional ? "?" : ""}: ${field.type}`).join(", ")}`);
|
||||
}
|
||||
return lines.join(`
|
||||
|
||||
`);
|
||||
}
|
||||
function isApiCallPosition(text, offset) {
|
||||
const before = text.slice(0, offset);
|
||||
return /\bapi\.\w*$/.test(before);
|
||||
}
|
||||
var documents = new Map;
|
||||
var diagnosticTimers = new Map;
|
||||
var MAX_OPEN_DOCUMENTS = 256;
|
||||
@@ -196328,6 +196415,10 @@ async function handle(message) {
|
||||
}
|
||||
case "textDocument/completion": {
|
||||
const document = documents.get(params.textDocument.uri);
|
||||
if (document && isApiCallPosition(document.text, offsetAt(document.text, params.position))) {
|
||||
result(message.id, apiCallCompletions(document.text));
|
||||
break;
|
||||
}
|
||||
const wrnexus = [...completionItems(), ...workspaceCompletionItems(workspaceRoot)];
|
||||
const html = document ? htmlCompletions(document, params.position) : [];
|
||||
result(message.id, html.length ? mergeCompletions(wrnexus, html) : wrnexus);
|
||||
@@ -196345,6 +196436,12 @@ async function handle(message) {
|
||||
}
|
||||
case "textDocument/hover": {
|
||||
const document = documents.get(params.textDocument.uri);
|
||||
const word = document ? wordAt(document.text, params.position)?.word : undefined;
|
||||
const apiHover = word ? apiCallHover(document.text, word) : undefined;
|
||||
if (apiHover) {
|
||||
result(message.id, { contents: apiHover });
|
||||
break;
|
||||
}
|
||||
const html = document ? htmlHover(document, params.position) : null;
|
||||
result(message.id, html ?? (document ? hover(document, params.position) : null));
|
||||
break;
|
||||
|
||||
@@ -113,10 +113,10 @@
|
||||
"include": "#action-block"
|
||||
},
|
||||
{
|
||||
"include": "#mode-block"
|
||||
"include": "#api-block"
|
||||
},
|
||||
{
|
||||
"include": "#api-block"
|
||||
"include": "#apis-block"
|
||||
},
|
||||
{
|
||||
"include": "#functions-block"
|
||||
@@ -513,8 +513,8 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"mode-block": {
|
||||
"begin": "\\b(ssr|client)\\b\\s*(\\{)",
|
||||
"apis-block": {
|
||||
"begin": "\\b(apis)\\b\\s*(\\{)",
|
||||
"beginCaptures": {
|
||||
"1": {
|
||||
"name": "keyword.control.wrn"
|
||||
@@ -534,21 +534,18 @@
|
||||
"include": "#comments"
|
||||
},
|
||||
{
|
||||
"begin": "\\b(api)\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\b\\s*([^\\s{]*)\\s*(\\{)",
|
||||
"begin": "\\b([A-Za-z_$][A-Za-z0-9_$]*)\\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\b\\s*([^\\s{]*)\\s*(\\{)",
|
||||
"beginCaptures": {
|
||||
"1": {
|
||||
"name": "keyword.control.wrn"
|
||||
},
|
||||
"2": {
|
||||
"name": "entity.name.function.wrn"
|
||||
},
|
||||
"3": {
|
||||
"2": {
|
||||
"name": "constant.language.http-method.wrn"
|
||||
},
|
||||
"4": {
|
||||
"3": {
|
||||
"name": "string.unquoted.route.wrn"
|
||||
},
|
||||
"5": {
|
||||
"4": {
|
||||
"name": "punctuation.definition.block.begin.wrn"
|
||||
}
|
||||
},
|
||||
@@ -558,8 +555,36 @@
|
||||
"name": "punctuation.definition.block.end.wrn"
|
||||
}
|
||||
},
|
||||
"contentName": "meta.embedded.block.ts",
|
||||
"patterns": [
|
||||
{
|
||||
"include": "#comments"
|
||||
},
|
||||
{
|
||||
"begin": "\\b(request|response|error)\\b\\s*(\\{)",
|
||||
"beginCaptures": {
|
||||
"1": {
|
||||
"name": "keyword.control.wrn"
|
||||
},
|
||||
"2": {
|
||||
"name": "punctuation.definition.block.begin.wrn"
|
||||
}
|
||||
},
|
||||
"end": "\\}",
|
||||
"endCaptures": {
|
||||
"0": {
|
||||
"name": "punctuation.definition.block.end.wrn"
|
||||
}
|
||||
},
|
||||
"contentName": "meta.embedded.block.ts",
|
||||
"patterns": [
|
||||
{
|
||||
"include": "#ts-braces"
|
||||
},
|
||||
{
|
||||
"include": "source.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"include": "#ts-braces"
|
||||
},
|
||||
@@ -567,9 +592,6 @@
|
||||
"include": "source.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"include": "#functions-block"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const { test } = require("node:test");
|
||||
const { readFileSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
|
||||
const grammarPath = join(__dirname, "../syntaxes/wrn.tmLanguage.json");
|
||||
const grammar = JSON.parse(readFileSync(grammarPath, "utf8"));
|
||||
|
||||
test("the grammar knows the apis block", () => {
|
||||
assert.ok(grammar.repository["apis-block"], "apis should appear as a block keyword");
|
||||
});
|
||||
|
||||
test("client keeps its highlighting where it is still valid", () => {
|
||||
// client state {}, runtime = "client", and client function all survive.
|
||||
// Only the client {} data block was removed.
|
||||
assert.match(JSON.stringify(grammar), /client/, "client must still be matched");
|
||||
assert.match(
|
||||
JSON.stringify(grammar),
|
||||
/shared/,
|
||||
"the shared function modifier must still be matched",
|
||||
);
|
||||
});
|
||||
|
||||
// --- Structural assertions: these fail if the rule they name is deleted, ---
|
||||
// --- even though the substrings "apis"/"client" remain elsewhere in the ---
|
||||
// --- file (e.g. inside unrelated pattern names or comments). ---
|
||||
|
||||
/**
|
||||
* vscode-textmate is not a dependency of this repo (confirmed via
|
||||
* `require.resolve`), so real tokenization is unavailable here. Instead we
|
||||
* pull each rule's own `begin`/`match` regex out of the parsed grammar
|
||||
* object and exercise it directly with the JS regex engine. The grammar's
|
||||
* patterns use only `\b`, character classes, and `(?<=...)` lookbehind --
|
||||
* all supported by native JS regexes -- so this is a faithful proxy for
|
||||
* "does this specific rule match this specific source line" without
|
||||
* needing an Oniguruma-backed tokenizer.
|
||||
*/
|
||||
function ruleByTopLevelBlockName(name) {
|
||||
const entry = grammar.repository[name];
|
||||
assert.ok(entry, `repository.${name} should exist`);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function topLevelBlockIncludes(name) {
|
||||
return grammar.repository.blocks.patterns.some((p) => p.include === `#${name}`);
|
||||
}
|
||||
|
||||
test("blocks includes the apis-block pattern", () => {
|
||||
assert.ok(topLevelBlockIncludes("apis-block"), "#blocks must include #apis-block");
|
||||
});
|
||||
|
||||
test("the apis-block begin pattern matches the container keyword", () => {
|
||||
const rule = ruleByTopLevelBlockName("apis-block");
|
||||
const beginRe = new RegExp(rule.begin);
|
||||
assert.ok(beginRe.test("apis {"), "apis-block begin should match 'apis {'");
|
||||
assert.ok(!beginRe.test("ssr {"), "apis-block begin must not match 'ssr {'");
|
||||
assert.ok(!beginRe.test("client {"), "apis-block begin must not match 'client {'");
|
||||
});
|
||||
|
||||
test("the apis-block declares an entry pattern for <name> <METHOD> <path>", () => {
|
||||
const rule = ruleByTopLevelBlockName("apis-block");
|
||||
const entryPattern = (rule.patterns || []).find(
|
||||
(p) => p.name === "apis-entry" || (p.begin && /GET\|POST/.test(p.begin)),
|
||||
);
|
||||
assert.ok(entryPattern, "apis-block should contain an entry declaration pattern");
|
||||
const entryRe = new RegExp(entryPattern.begin || entryPattern.match);
|
||||
assert.ok(
|
||||
entryRe.test("searchUsers POST /api/users {"),
|
||||
"the entry pattern should match '<name> <METHOD> <path> {'",
|
||||
);
|
||||
});
|
||||
|
||||
test("the ssr/client data-block pattern (mode-block) is gone", () => {
|
||||
assert.strictEqual(
|
||||
grammar.repository["mode-block"],
|
||||
undefined,
|
||||
"mode-block (the removed ssr {}/client {} data-block rule) must be deleted from the repository",
|
||||
);
|
||||
assert.ok(
|
||||
!topLevelBlockIncludes("mode-block"),
|
||||
"#blocks must no longer include the removed #mode-block pattern",
|
||||
);
|
||||
});
|
||||
|
||||
// --- Regression coverage: constructs that must NOT be broken by this change ---
|
||||
|
||||
test("REGRESSION: client state {} still highlights via grouped-state-block", () => {
|
||||
const rule = ruleByTopLevelBlockName("grouped-state-block");
|
||||
const beginRe = new RegExp(rule.begin);
|
||||
assert.ok(
|
||||
beginRe.test("client state {"),
|
||||
"grouped-state-block must still match 'client state {'",
|
||||
);
|
||||
});
|
||||
|
||||
test('REGRESSION: runtime = "client" still highlights via execution-decl', () => {
|
||||
const rule = ruleByTopLevelBlockName("execution-decl");
|
||||
const beginRe = new RegExp(rule.begin);
|
||||
assert.ok(
|
||||
beginRe.test('runtime = "client"'),
|
||||
"execution-decl must still match 'runtime = \"client\"'",
|
||||
);
|
||||
});
|
||||
|
||||
test("REGRESSION: the client function modifier still highlights via functions-block", () => {
|
||||
const rule = ruleByTopLevelBlockName("functions-block");
|
||||
const matchRe = new RegExp(
|
||||
rule.patterns.find(
|
||||
(p) => p.captures && p.captures["1"]?.name === "storage.modifier.runtime.wrn",
|
||||
).match,
|
||||
);
|
||||
assert.ok(
|
||||
matchRe.test("client async function go(): Promise<void> {"),
|
||||
"functions-block must still recognize the 'client' runtime modifier on a function",
|
||||
);
|
||||
});
|
||||
|
||||
test("REGRESSION: the client function modifier also highlights via v060-keywords", () => {
|
||||
const rule = ruleByTopLevelBlockName("v060-keywords");
|
||||
const modifierPattern = rule.patterns.find((p) => p.name === "storage.modifier.runtime.wrn");
|
||||
assert.ok(modifierPattern, "v060-keywords should have a storage.modifier.runtime.wrn rule");
|
||||
const matchRe = new RegExp(modifierPattern.match);
|
||||
assert.ok(
|
||||
matchRe.test("client function go"),
|
||||
"v060-keywords must still recognize 'client' before 'function'",
|
||||
);
|
||||
assert.ok(
|
||||
matchRe.test("client state"),
|
||||
"v060-keywords must still recognize 'client' before 'state'",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
# WrNexus app - instructions for AI coding assistants
|
||||
|
||||
This is a **WrNexus** app. When creating or editing pages, components, API routes,
|
||||
or features, follow the framework conventions below. WrNexus is private and not in
|
||||
your training data, so rely on these rules - do NOT assume React/Next.js/Vue patterns.
|
||||
|
||||
# WrNexus
|
||||
|
||||
> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in
|
||||
> `.wrn` files (its own component language — NOT React/JSX/Vue). Routing is file-based.
|
||||
> This document teaches an AI how to write correct WrNexus code. It is private and
|
||||
> post-dates model training data, so rely on THIS document, not prior web-framework
|
||||
> assumptions.
|
||||
|
||||
## Golden rules
|
||||
|
||||
- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React
|
||||
for UI. Do NOT use `useState`, hooks, JSX, or a client bundler.
|
||||
- **Routing is file-based** under `app/`. The filename is the route. No router config.
|
||||
- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render
|
||||
on the server and hydrate automatically — you never write client-side JS islands.
|
||||
- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported.
|
||||
- To add files, prefer the CLI: `wrnexus generate page <Name>` / `component <name>` / `api <path>` / `schema <name>`.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
app/
|
||||
pages/ *.wrn → routes: index.wrn = "/", about.wrn = "/about", blog/[slug].wrn = "/blog/:slug"
|
||||
components/ *.wrn → reusable UI, mounted in a page/component via <div data-component="name" ...props>
|
||||
layouts/ *.wrn → named layouts; a page opts in with layout = "name"
|
||||
api/ *.ts → HTTP handlers: export const GET/POST/PUT/PATCH/DELETE = async (ctx) => Response
|
||||
middleware/ *.ts → export default async (ctx, next) => next()
|
||||
realtime/ *.ts → export default defineRoom({ ... }) from "@wrnexus/core" (ws://host/realtime/<name>)
|
||||
schemas/ *.ts → validation schemas (the `v` builder), used by forms + parseBody
|
||||
locales/ *.json → i18n messages per language
|
||||
db/ schema.ts, queries/*.sql, migrations/*.sql
|
||||
styles/ global.css → Tailwind (default) or plain CSS
|
||||
wrnexus.config.ts → app config (AppConfig from "@wrnexus/styles")
|
||||
public/ → static assets served at /
|
||||
```
|
||||
|
||||
## `.wrn` page
|
||||
|
||||
```wrn
|
||||
page Home {
|
||||
layout = "public" // optional: a component in app/layouts/<name>.wrn ("none" to skip)
|
||||
|
||||
state count = 0 // optional: seeds client-reactive state (omit for pure SSR)
|
||||
|
||||
seo {
|
||||
title = "Home"
|
||||
description = "..."
|
||||
canonical = "/"
|
||||
}
|
||||
|
||||
view {
|
||||
<h1>Hello</h1>
|
||||
<p>Count is {count}, doubled is {count * 2}.</p>
|
||||
<button @click="count++">Increment</button>
|
||||
<div data-component="counter" start="5" label="Clicks"></div>
|
||||
}
|
||||
|
||||
style {
|
||||
h1 { color: var(--wrn-color-text); }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## `.wrn` component
|
||||
|
||||
```wrn
|
||||
component Counter {
|
||||
props { // props come from mount attributes; each is coerced to the
|
||||
start = 0 // TYPE of its default (so start="5" arrives as the number 5)
|
||||
label = "Count"
|
||||
}
|
||||
state count = start // state may reference props
|
||||
view {
|
||||
<button @click="count++">{label}: {count}</button>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Mount it from any page/component: `<div data-component="counter" start="0" label="Clicks"></div>`.
|
||||
Components render on the server with their props, then hydrate — no per-component JS.
|
||||
|
||||
## The `view { }` block (plain HTML + a few directives)
|
||||
|
||||
- `{expr}` — interpolate a JS expression. Reactive if it references `state`: `{count}`, `{count * 2}`, `{user.name}`.
|
||||
- `@event="expr"` — bind a DOM event; the expression runs in the reactive scope: `@click="count++"`, `@input="name = event.target.value"`.
|
||||
- `<div data-component="name" prop="v">` — mount a component (attrs become string props, coerced).
|
||||
- `<slot></slot>` / `<slot name="x"></slot>` — component/layout slots; fill with `<div data-slot="x">…</div>`.
|
||||
- **Server loop (DB/list/table):** `{#each <list> as <item>[, <i>]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `<list>` is a JS expression, usually an `ssr` data binding (see "Data-driven tables" below). This is how you render a database table in `.wrn`.
|
||||
- **Server conditional:** `{#if <expr>} … {:else if <expr>} … {:else} … {/if}` — renders the first truthy branch on the server. `<expr>` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}<span>●</span>{:else}<span>○</span>{/if}` per row). For client-side show/hide based on reactive `state`, use `data-show="expr"` instead.
|
||||
- i18n: `{t:home.title}` in text, `t:placeholder="form.name"` on attributes — resolved per request from `app/locales/`.
|
||||
- Theme: any element with `data-wrn-theme-toggle` toggles light/dark; `data-wrn-theme-set="dark"` sets it.
|
||||
- Void/self-closing tags are fine: `<br />`, `<img src="..." />`.
|
||||
- Only `{` and `}` are special (interpolation). Don't use a bare `}` in view text.
|
||||
|
||||
## Data-driven tables / lists (server-rendered `.wrn`)
|
||||
|
||||
Use an `ssr` data binding to fetch rows on the server, then `{#each}` to render them.
|
||||
This renders on the **server** (SSR-first) and is HTML-escaped by default.
|
||||
|
||||
```wrn
|
||||
page Admin {
|
||||
layout = "dashboard"
|
||||
|
||||
// Fetch on the server. The api handler at /api/contacts returns { contacts: [...] };
|
||||
// this block's `return contacts` exposes that array (via `$data`) as the binding `rows`.
|
||||
ssr {
|
||||
api rows GET /api/contacts { return contacts }
|
||||
}
|
||||
|
||||
view {
|
||||
<table>
|
||||
<tbody>
|
||||
{#each rows as r, i}
|
||||
<tr>
|
||||
<td>#{i}</td>
|
||||
<td>{r.name}</td>
|
||||
<td><a href="mailto:{r.email}">{r.email}</a></td>
|
||||
</tr>
|
||||
{:empty}
|
||||
<tr><td colspan="3">No submissions yet.</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The matching API returns the array under a key the `ssr` block reads:
|
||||
|
||||
```ts
|
||||
// app/api/contacts.ts → GET /api/contacts
|
||||
import { getDb } from "@wrnexus/db";
|
||||
export const GET = async () => {
|
||||
const contacts = await getDb().all("SELECT id, name, email FROM contacts ORDER BY id DESC");
|
||||
return Response.json({ contacts }); // ssr block does `return contacts`
|
||||
};
|
||||
```
|
||||
|
||||
**Prefer this `.wrn` + `{#each}` approach for DB-backed tables and lists.** (`.ts`/`.tsx`
|
||||
pages returning an HTML string are also supported for fully-custom programmatic rendering,
|
||||
but a `.wrn` page with `ssr` data + `{#each}` is the idiomatic, SSR-first way.)
|
||||
|
||||
## API routes (`app/api/*.ts`)
|
||||
|
||||
```ts
|
||||
// app/api/users/list.ts → GET /api/users/list
|
||||
import { getDb } from "@wrnexus/db";
|
||||
|
||||
export const GET = async (ctx) => {
|
||||
return Response.json({ users: await ListUsers(getDb()) });
|
||||
};
|
||||
|
||||
export const POST = async (ctx) => {
|
||||
const body = await ctx.req.json();
|
||||
return Response.json({ ok: true, body }, { status: 201 });
|
||||
};
|
||||
```
|
||||
|
||||
`ctx` (the `Context` from `@wrnexus/core`) has:
|
||||
`req: Request`, `url: URL`, `params: Record<string,string>` (dynamic route params, e.g. `/users/[id]` → `ctx.params.id`),
|
||||
`lang: string`, `t(key, params?)` (i18n), `cookies` (get/set), `session` (get/set). Auth: `getUser(ctx)` after `sessionAuth`/`logIn`.
|
||||
|
||||
When an SSO forward-auth verifier needs the URL that originally reached the gateway, use
|
||||
`@wrnexus/helpers` instead of constructing it from untrusted headers:
|
||||
|
||||
```ts
|
||||
import { redirectToLogin } from "@wrnexus/helpers";
|
||||
|
||||
return redirectToLogin(ctx, "/login", {
|
||||
allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"],
|
||||
});
|
||||
```
|
||||
|
||||
The package also exports `getOriginalRequestUrl`, `getOriginalRequestOrigin`,
|
||||
`getOriginalRequestPath`, and `getOriginalRequestMethod`. Always pass `allowedHosts` when
|
||||
using forwarded gateway URLs; the helper rejects untrusted redirect destinations.
|
||||
|
||||
## Middleware & realtime
|
||||
|
||||
```ts
|
||||
// app/middleware/logger.ts
|
||||
export default async function logger(ctx, next) {
|
||||
console.log(ctx.req.method, ctx.url.pathname);
|
||||
return next(); // return a Response WITHOUT calling next() to short-circuit
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// app/realtime/chat.ts → ws://host/realtime/chat
|
||||
import { defineRoom } from "@wrnexus/core";
|
||||
export default defineRoom({
|
||||
onConnect(client) {
|
||||
client.send({ type: "system", text: "connected" });
|
||||
},
|
||||
onMessage(client, msg) {
|
||||
client.room.broadcast({ type: "message", data: msg });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Client side: a page opts in with `data-room="chat"` (handled by the realtime runtime).
|
||||
|
||||
## Config (`wrnexus.config.ts`)
|
||||
|
||||
```ts
|
||||
import type { AppConfig } from "@wrnexus/styles";
|
||||
const config: AppConfig = {
|
||||
seo: { title: "App", titleTemplate: "%s | App", description: "..." },
|
||||
styles: {
|
||||
entry: "app/styles/global.css",
|
||||
process: async ({ entryPath, mode }) => /* Tailwind */ "",
|
||||
},
|
||||
fonts: {
|
||||
sans: '"Inter", system-ui, sans-serif',
|
||||
google: [{ family: "Inter", weights: [400, 600] }],
|
||||
},
|
||||
theme: { default: "dark", themes: { light: { "color-primary": "#2563eb" } } },
|
||||
i18n: { default: "en", locales: ["en", "es"] },
|
||||
db: { driver: "sqlite", url: "file:./dev.db" },
|
||||
security: { cors: { enabled: true, origin: ["http://localhost:5173"] } },
|
||||
// profiles: { production: { db: { driver: "postgres", url: process.env.DATABASE_URL } } },
|
||||
};
|
||||
export default config;
|
||||
```
|
||||
|
||||
## Database (`@wrnexus/db`)
|
||||
|
||||
```ts
|
||||
// app/db/schema.ts
|
||||
import { v, table } from "@wrnexus/db";
|
||||
export const users = table("users", {
|
||||
id: v.id(),
|
||||
name: v.string(),
|
||||
email: v.string().unique(),
|
||||
createdAt: v.timestamp(),
|
||||
});
|
||||
```
|
||||
|
||||
- Queries: write `app/db/queries/*.sql` with `-- name: ListUsers :many` blocks; `wrnexus db generate` emits typed functions.
|
||||
- Access at runtime: `import { getDb } from "@wrnexus/db"; const rows = await ListUsers(getDb());`
|
||||
- Migrations in `app/db/migrations/`; run `wrnexus db migrate` (dev auto-migrates sqlite).
|
||||
|
||||
## Validation (`@wrnexus/validation`)
|
||||
|
||||
```ts
|
||||
// app/schemas/login.ts
|
||||
import { v } from "@wrnexus/validation";
|
||||
export default v.object({
|
||||
email: v.string().email(),
|
||||
password: v.string().min(8),
|
||||
});
|
||||
```
|
||||
|
||||
In an API route: `import s from "../schemas/login"; import { parseBody } from "@wrnexus/validation"; const r = await parseBody(s, ctx.req);` → `r.ok ? r.value : r.response`.
|
||||
In a form: `<form data-schema="login" action="/api/login" method="post">` + `<span data-error="email"></span>` (client + server validation connected automatically).
|
||||
|
||||
## AI / LLM (`@wrnexus/ai`)
|
||||
|
||||
```ts
|
||||
// app/api/ai.ts
|
||||
import { createAI } from "@wrnexus/ai";
|
||||
const ai = createAI(); // reads ANTHROPIC_API_KEY; default model claude-opus-4-8
|
||||
export const POST = async (ctx) => {
|
||||
const { prompt } = await ctx.req.json();
|
||||
return ai.streamResponse(prompt); // or: return Response.json({ text: await ai.generate(prompt) })
|
||||
};
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
```
|
||||
wrnexus dev . # dev server + HMR
|
||||
wrnexus build . # production build → dist/server.js
|
||||
bun dist/server.js # run the production server (or npm start)
|
||||
wrnexus create <name> # scaffold a new app
|
||||
wrnexus update --latest # deps + syntax/config migrations + verification
|
||||
wrnexus generate page <Name> # scaffold a page (aliases: g p)
|
||||
wrnexus generate component <name> | api <path> | schema <name>
|
||||
wrnexus db migrate | rollback | status | new [--from-models] | generate | seed
|
||||
wrnexus eject <component> # copy a WrNexus UI component's .wrn into app/components to customize
|
||||
```
|
||||
|
||||
## When asked to "create a page/component/feature"
|
||||
|
||||
1. Create the `.wrn` file under `app/pages/` (or `app/components/`) with a `page`/`component` block — or run `wrnexus generate page <Name>`.
|
||||
2. Put markup in `view { }`, interactive bits in `state` + `{expr}` + `@event`, reusable UI as components mounted via `data-component`.
|
||||
3. For data, add an `app/api/*.ts` route and `getDb()`; for forms, add an `app/schemas/*.ts` and `data-schema`.
|
||||
4. Style with Tailwind utility classes in the view, or theme tokens (`var(--wrn-*)`), or `style { }`.
|
||||
5. Never emit React/JSX, a manual router, or client-side island JS — the framework handles hydration.
|
||||
@@ -3,8 +3,8 @@ page ApiBlockDemo {
|
||||
state found = ""
|
||||
state failed = ""
|
||||
|
||||
client {
|
||||
api searchDirectory POST /api/directory {
|
||||
apis {
|
||||
searchDirectory POST /api/directory {
|
||||
request {
|
||||
body {
|
||||
name?: string
|
||||
@@ -21,6 +21,11 @@ page ApiBlockDemo {
|
||||
}
|
||||
}
|
||||
|
||||
load server serverSearch {
|
||||
const users = await api.searchDirectory({ name: "a" })
|
||||
return { names: users.map((user) => user.name).join(", ") }
|
||||
}
|
||||
|
||||
functions {
|
||||
client async function search(): Promise<void> {
|
||||
const users = await api.searchDirectory({ name: nameFilter })
|
||||
@@ -33,6 +38,12 @@ page ApiBlockDemo {
|
||||
<button @click="search()">Search</button>
|
||||
<p class="found" data-text="found">{found}</p>
|
||||
<p class="failed" data-text="failed">{failed}</p>
|
||||
<Async source="serverSearch">
|
||||
<Loading><p class="server-found">Loading…</p></Loading>
|
||||
<Success data="serverSearch"><p class="server-found">{serverSearch.names}</p></Success>
|
||||
<Error error="error"><p class="server-found">Failed: {error.message}</p></Error>
|
||||
</Async>
|
||||
<p class="bound" api="searchDirectory({ name: 'a' })">loading</p>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,37 +18,32 @@ page Hello {
|
||||
canonical = "/hello"
|
||||
}
|
||||
|
||||
// SSR data bindings run on the server before the HTML is sent.
|
||||
// The API itself lives in app/api/users/ssr.ts; this block only calls it
|
||||
// and renders the response into HTML.
|
||||
ssr {
|
||||
functions {
|
||||
function userNames(users) {
|
||||
return users.map((user) => user.name).join(", ")
|
||||
// API calls used by this page. `ssrUsers` is bound in the initial render
|
||||
// (its endpoint lives in app/api/users/ssr.ts); `csrUsers` is bound after
|
||||
// hydration in the browser (app/api/users/csr.ts). Both just call the same
|
||||
// users endpoint shape, so the response handling looks the same.
|
||||
apis {
|
||||
ssrUsers GET /api/users/ssr {
|
||||
response {
|
||||
const visits = Number(cookies.get("hello_visits") ?? "0") + 1
|
||||
cookies.set("hello_visits", String(visits), { sameSite: "Lax" })
|
||||
session.set("lastHelloVisit", visits)
|
||||
return `${userNames(data.users)} - visit ${visits}`
|
||||
}
|
||||
}
|
||||
|
||||
api ssrUsers GET /api/users/ssr {
|
||||
const visits = Number(cookies.get("hello_visits") ?? "0") + 1
|
||||
cookies.set("hello_visits", String(visits), { sameSite: "Lax" })
|
||||
session.set("lastHelloVisit", visits)
|
||||
return `${userNames(users)} - visit ${visits}`
|
||||
csrUsers GET /api/users/csr {
|
||||
response {
|
||||
const label = localStorage.get("wrnexus.label") ?? "browser"
|
||||
session.set("lastClientLabel", label)
|
||||
return `${userNames(data.users)} - ${label}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Client data bindings hydrate after the first paint. The browser only sees
|
||||
// an opaque data-wrnexus-csr id; WrNexus calls app/api/users/csr.ts on the server.
|
||||
client {
|
||||
functions {
|
||||
function userNames(users) {
|
||||
return users.map((user) => user.name).join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
api csrUsers GET /api/users/csr {
|
||||
const label = localStorage.get("wrnexus.label") ?? "browser"
|
||||
session.set("lastClientLabel", label)
|
||||
return `${userNames(users)} - ${label}`
|
||||
functions {
|
||||
shared function userNames(users) {
|
||||
return users.map((user) => user.name).join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"name": "basic-app",
|
||||
"version": "0.8.0",
|
||||
"wrnexus": {
|
||||
"version": "0.9.0"
|
||||
},
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
# WrNexus
|
||||
|
||||
> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in
|
||||
> `.wrn` files (its own component language — NOT React/JSX/Vue). Routing is file-based.
|
||||
> This document teaches an AI how to write correct WrNexus code. It is private and
|
||||
> post-dates model training data, so rely on THIS document, not prior web-framework
|
||||
> assumptions.
|
||||
|
||||
## Golden rules
|
||||
|
||||
- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React
|
||||
for UI. Do NOT use `useState`, hooks, JSX, or a client bundler.
|
||||
- **Routing is file-based** under `app/`. The filename is the route. No router config.
|
||||
- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render
|
||||
on the server and hydrate automatically — you never write client-side JS islands.
|
||||
- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported.
|
||||
- To add files, prefer the CLI: `wrnexus generate page <Name>` / `component <name>` / `api <path>` / `schema <name>`.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
app/
|
||||
pages/ *.wrn → routes: index.wrn = "/", about.wrn = "/about", blog/[slug].wrn = "/blog/:slug"
|
||||
components/ *.wrn → reusable UI, mounted in a page/component via <div data-component="name" ...props>
|
||||
layouts/ *.wrn → named layouts; a page opts in with layout = "name"
|
||||
api/ *.ts → HTTP handlers: export const GET/POST/PUT/PATCH/DELETE = async (ctx) => Response
|
||||
middleware/ *.ts → export default async (ctx, next) => next()
|
||||
realtime/ *.ts → export default defineRoom({ ... }) from "@wrnexus/core" (ws://host/realtime/<name>)
|
||||
schemas/ *.ts → validation schemas (the `v` builder), used by forms + parseBody
|
||||
locales/ *.json → i18n messages per language
|
||||
db/ schema.ts, queries/*.sql, migrations/*.sql
|
||||
styles/ global.css → Tailwind (default) or plain CSS
|
||||
wrnexus.config.ts → app config (AppConfig from "@wrnexus/styles")
|
||||
public/ → static assets served at /
|
||||
```
|
||||
|
||||
## `.wrn` page
|
||||
|
||||
```wrn
|
||||
page Home {
|
||||
layout = "public" // optional: a component in app/layouts/<name>.wrn ("none" to skip)
|
||||
|
||||
state count = 0 // optional: seeds client-reactive state (omit for pure SSR)
|
||||
|
||||
seo {
|
||||
title = "Home"
|
||||
description = "..."
|
||||
canonical = "/"
|
||||
}
|
||||
|
||||
view {
|
||||
<h1>Hello</h1>
|
||||
<p>Count is {count}, doubled is {count * 2}.</p>
|
||||
<button @click="count++">Increment</button>
|
||||
<div data-component="counter" start="5" label="Clicks"></div>
|
||||
}
|
||||
|
||||
style {
|
||||
h1 { color: var(--wrn-color-text); }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## `.wrn` component
|
||||
|
||||
```wrn
|
||||
component Counter {
|
||||
props { // props come from mount attributes; each is coerced to the
|
||||
start = 0 // TYPE of its default (so start="5" arrives as the number 5)
|
||||
label = "Count"
|
||||
}
|
||||
state count = start // state may reference props
|
||||
view {
|
||||
<button @click="count++">{label}: {count}</button>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Mount it from any page/component: `<div data-component="counter" start="0" label="Clicks"></div>`.
|
||||
Components render on the server with their props, then hydrate — no per-component JS.
|
||||
|
||||
## The `view { }` block (plain HTML + a few directives)
|
||||
|
||||
- `{expr}` — interpolate a JS expression. Reactive if it references `state`: `{count}`, `{count * 2}`, `{user.name}`.
|
||||
- `@event="expr"` — bind a DOM event; the expression runs in the reactive scope: `@click="count++"`, `@input="name = event.target.value"`.
|
||||
- `<div data-component="name" prop="v">` — mount a component (attrs become string props, coerced).
|
||||
- `<slot></slot>` / `<slot name="x"></slot>` — component/layout slots; fill with `<div data-slot="x">…</div>`.
|
||||
- **Server loop (DB/list/table):** `{#each <list> as <item>[, <i>]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `<list>` is a JS expression, usually an `ssr` data binding (see "Data-driven tables" below). This is how you render a database table in `.wrn`.
|
||||
- **Server conditional:** `{#if <expr>} … {:else if <expr>} … {:else} … {/if}` — renders the first truthy branch on the server. `<expr>` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}<span>●</span>{:else}<span>○</span>{/if}` per row). For client-side show/hide based on reactive `state`, use `data-show="expr"` instead.
|
||||
- i18n: `{t:home.title}` in text, `t:placeholder="form.name"` on attributes — resolved per request from `app/locales/`.
|
||||
- Theme: any element with `data-wrn-theme-toggle` toggles light/dark; `data-wrn-theme-set="dark"` sets it.
|
||||
- Void/self-closing tags are fine: `<br />`, `<img src="..." />`.
|
||||
- Only `{` and `}` are special (interpolation). Don't use a bare `}` in view text.
|
||||
|
||||
## Data-driven tables / lists (server-rendered `.wrn`)
|
||||
|
||||
Use an `ssr` data binding to fetch rows on the server, then `{#each}` to render them.
|
||||
This renders on the **server** (SSR-first) and is HTML-escaped by default.
|
||||
|
||||
```wrn
|
||||
page Admin {
|
||||
layout = "dashboard"
|
||||
|
||||
// Fetch on the server. The api handler at /api/contacts returns { contacts: [...] };
|
||||
// this block's `return contacts` exposes that array (via `$data`) as the binding `rows`.
|
||||
ssr {
|
||||
api rows GET /api/contacts { return contacts }
|
||||
}
|
||||
|
||||
view {
|
||||
<table>
|
||||
<tbody>
|
||||
{#each rows as r, i}
|
||||
<tr>
|
||||
<td>#{i}</td>
|
||||
<td>{r.name}</td>
|
||||
<td><a href="mailto:{r.email}">{r.email}</a></td>
|
||||
</tr>
|
||||
{:empty}
|
||||
<tr><td colspan="3">No submissions yet.</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The matching API returns the array under a key the `ssr` block reads:
|
||||
|
||||
```ts
|
||||
// app/api/contacts.ts → GET /api/contacts
|
||||
import { getDb } from "@wrnexus/db";
|
||||
export const GET = async () => {
|
||||
const contacts = await getDb().all("SELECT id, name, email FROM contacts ORDER BY id DESC");
|
||||
return Response.json({ contacts }); // ssr block does `return contacts`
|
||||
};
|
||||
```
|
||||
|
||||
**Prefer this `.wrn` + `{#each}` approach for DB-backed tables and lists.** (`.ts`/`.tsx`
|
||||
pages returning an HTML string are also supported for fully-custom programmatic rendering,
|
||||
but a `.wrn` page with `ssr` data + `{#each}` is the idiomatic, SSR-first way.)
|
||||
|
||||
## API routes (`app/api/*.ts`)
|
||||
|
||||
```ts
|
||||
// app/api/users/list.ts → GET /api/users/list
|
||||
import { getDb } from "@wrnexus/db";
|
||||
|
||||
export const GET = async (ctx) => {
|
||||
return Response.json({ users: await ListUsers(getDb()) });
|
||||
};
|
||||
|
||||
export const POST = async (ctx) => {
|
||||
const body = await ctx.req.json();
|
||||
return Response.json({ ok: true, body }, { status: 201 });
|
||||
};
|
||||
```
|
||||
|
||||
`ctx` (the `Context` from `@wrnexus/core`) has:
|
||||
`req: Request`, `url: URL`, `params: Record<string,string>` (dynamic route params, e.g. `/users/[id]` → `ctx.params.id`),
|
||||
`lang: string`, `t(key, params?)` (i18n), `cookies` (get/set), `session` (get/set). Auth: `getUser(ctx)` after `sessionAuth`/`logIn`.
|
||||
|
||||
When an SSO forward-auth verifier needs the URL that originally reached the gateway, use
|
||||
`@wrnexus/helpers` instead of constructing it from untrusted headers:
|
||||
|
||||
```ts
|
||||
import { redirectToLogin } from "@wrnexus/helpers";
|
||||
|
||||
return redirectToLogin(ctx, "/login", {
|
||||
allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"],
|
||||
});
|
||||
```
|
||||
|
||||
The package also exports `getOriginalRequestUrl`, `getOriginalRequestOrigin`,
|
||||
`getOriginalRequestPath`, and `getOriginalRequestMethod`. Always pass `allowedHosts` when
|
||||
using forwarded gateway URLs; the helper rejects untrusted redirect destinations.
|
||||
|
||||
## Middleware & realtime
|
||||
|
||||
```ts
|
||||
// app/middleware/logger.ts
|
||||
export default async function logger(ctx, next) {
|
||||
console.log(ctx.req.method, ctx.url.pathname);
|
||||
return next(); // return a Response WITHOUT calling next() to short-circuit
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// app/realtime/chat.ts → ws://host/realtime/chat
|
||||
import { defineRoom } from "@wrnexus/core";
|
||||
export default defineRoom({
|
||||
onConnect(client) { client.send({ type: "system", text: "connected" }); },
|
||||
onMessage(client, msg) { client.room.broadcast({ type: "message", data: msg }); },
|
||||
});
|
||||
```
|
||||
Client side: a page opts in with `data-room="chat"` (handled by the realtime runtime).
|
||||
|
||||
## Config (`wrnexus.config.ts`)
|
||||
|
||||
```ts
|
||||
import type { AppConfig } from "@wrnexus/styles";
|
||||
const config: AppConfig = {
|
||||
seo: { title: "App", titleTemplate: "%s | App", description: "..." },
|
||||
styles: { entry: "app/styles/global.css", process: async ({ entryPath, mode }) => /* Tailwind */ "" },
|
||||
fonts: { sans: '"Inter", system-ui, sans-serif', google: [{ family: "Inter", weights: [400, 600] }] },
|
||||
theme: { default: "dark", themes: { light: { "color-primary": "#2563eb" } } },
|
||||
i18n: { default: "en", locales: ["en", "es"] },
|
||||
db: { driver: "sqlite", url: "file:./dev.db" },
|
||||
security: { cors: { enabled: true, origin: ["http://localhost:5173"] } },
|
||||
// profiles: { production: { db: { driver: "postgres", url: process.env.DATABASE_URL } } },
|
||||
};
|
||||
export default config;
|
||||
```
|
||||
|
||||
## Database (`@wrnexus/db`)
|
||||
|
||||
```ts
|
||||
// app/db/schema.ts
|
||||
import { v, table } from "@wrnexus/db";
|
||||
export const users = table("users", {
|
||||
id: v.id(),
|
||||
name: v.string(),
|
||||
email: v.string().unique(),
|
||||
createdAt: v.timestamp(),
|
||||
});
|
||||
```
|
||||
- Queries: write `app/db/queries/*.sql` with `-- name: ListUsers :many` blocks; `wrnexus db generate` emits typed functions.
|
||||
- Access at runtime: `import { getDb } from "@wrnexus/db"; const rows = await ListUsers(getDb());`
|
||||
- Migrations in `app/db/migrations/`; run `wrnexus db migrate` (dev auto-migrates sqlite).
|
||||
|
||||
## Validation (`@wrnexus/validation`)
|
||||
|
||||
```ts
|
||||
// app/schemas/login.ts
|
||||
import { v } from "@wrnexus/validation";
|
||||
export default v.object({
|
||||
email: v.string().email(),
|
||||
password: v.string().min(8),
|
||||
});
|
||||
```
|
||||
In an API route: `import s from "../schemas/login"; import { parseBody } from "@wrnexus/validation"; const r = await parseBody(s, ctx.req);` → `r.ok ? r.value : r.response`.
|
||||
In a form: `<form data-schema="login" action="/api/login" method="post">` + `<span data-error="email"></span>` (client + server validation connected automatically).
|
||||
|
||||
## AI / LLM (`@wrnexus/ai`)
|
||||
|
||||
```ts
|
||||
// app/api/ai.ts
|
||||
import { createAI } from "@wrnexus/ai";
|
||||
const ai = createAI(); // reads ANTHROPIC_API_KEY; default model claude-opus-4-8
|
||||
export const POST = async (ctx) => {
|
||||
const { prompt } = await ctx.req.json();
|
||||
return ai.streamResponse(prompt); // or: return Response.json({ text: await ai.generate(prompt) })
|
||||
};
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
```
|
||||
wrnexus dev . # dev server + HMR
|
||||
wrnexus build . # production build → dist/server.js
|
||||
bun dist/server.js # run the production server (or npm start)
|
||||
wrnexus create <name> # scaffold a new app
|
||||
wrnexus update --latest # deps + syntax/config migrations + verification
|
||||
wrnexus generate page <Name> # scaffold a page (aliases: g p)
|
||||
wrnexus generate component <name> | api <path> | schema <name>
|
||||
wrnexus db migrate | rollback | status | new [--from-models] | generate | seed
|
||||
wrnexus eject <component> # copy a WrNexus UI component's .wrn into app/components to customize
|
||||
```
|
||||
|
||||
## When asked to "create a page/component/feature"
|
||||
|
||||
1. Create the `.wrn` file under `app/pages/` (or `app/components/`) with a `page`/`component` block — or run `wrnexus generate page <Name>`.
|
||||
2. Put markup in `view { }`, interactive bits in `state` + `{expr}` + `@event`, reusable UI as components mounted via `data-component`.
|
||||
3. For data, add an `app/api/*.ts` route and `getDb()`; for forms, add an `app/schemas/*.ts` and `data-schema`.
|
||||
4. Style with Tailwind utility classes in the view, or theme tokens (`var(--wrn-*)`), or `style { }`.
|
||||
5. Never emit React/JSX, a manual router, or client-side island JS — the framework handles hydration.
|
||||
@@ -13,8 +13,6 @@ import type { AppConfig } from "@wrnexus/styles";
|
||||
* framework-level headers and optional CORS.
|
||||
*/
|
||||
const config: AppConfig = {
|
||||
frameworkBehaviour: 1,
|
||||
compatibilityDate: "2026-08-02",
|
||||
head: [
|
||||
// --- Use a CSS framework via CDN (uncomment one) ---
|
||||
// Bootstrap:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
dist/
|
||||
.wrnexus/
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
@@ -0,0 +1,13 @@
|
||||
# Northstar CRM example
|
||||
|
||||
A complete WrNexus example covering public pages, SQLite migrations and seed data, SQL-backed signup/login/session authentication, database-backed roles and permissions, protected pages, and permission-checked CRM APIs.
|
||||
|
||||
```bash
|
||||
bun run db:migrate
|
||||
bun run db:seed
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Open `/signup`, create an account, and the signup hook assigns the `sales-rep` role. The user is automatically signed in and redirected to `/dashboard`. Auth tables come from the `@wrnexus/auth` plugin migrations; CRM and authorization tables come from `app/db/migrations/001_crm.sql`.
|
||||
|
||||
The seed records use the placeholder owner `demo-owner` to demonstrate repeatable data. To attach them to a registered user, update their `owner_id` to that user's ID.
|
||||
@@ -0,0 +1,35 @@
|
||||
import { can } from "@wrnexus/authz";
|
||||
import { getDb } from "@wrnexus/db";
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { ensureCrmWorkspace } from "../lib/workspace.ts";
|
||||
|
||||
const userId = (ctx: Context) => String((ctx.user as { id?: unknown } | undefined)?.id ?? "");
|
||||
|
||||
export async function GET(ctx: Context): Promise<Response> {
|
||||
if (!(await can(ctx, "contact:read")))
|
||||
return Response.json({ error: "Forbidden" }, { status: 403 });
|
||||
await ensureCrmWorkspace(userId(ctx));
|
||||
const rows = await getDb().all(
|
||||
"SELECT id,name,email,company,phone,status,created_at FROM crm_contacts WHERE owner_id = ? ORDER BY name",
|
||||
[userId(ctx)],
|
||||
);
|
||||
return Response.json({ contacts: rows });
|
||||
}
|
||||
|
||||
export async function POST(ctx: Context): Promise<Response> {
|
||||
if (!(await can(ctx, "contact:write")))
|
||||
return Response.json({ error: "Forbidden" }, { status: 403 });
|
||||
const body = (await ctx.req.json()) as Record<string, unknown>;
|
||||
const name = String(body.name ?? "").trim();
|
||||
const email = String(body.email ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!name || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return Response.json({ error: "A name and valid email are required" }, { status: 400 });
|
||||
}
|
||||
const result = await getDb().exec(
|
||||
"INSERT INTO crm_contacts (owner_id,name,email,company,phone,status) VALUES (?,?,?,?,?,?)",
|
||||
[userId(ctx), name, email, String(body.company ?? ""), String(body.phone ?? ""), "lead"],
|
||||
);
|
||||
return Response.json({ id: Number(result.lastInsertId), name, email }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { can } from "@wrnexus/authz";
|
||||
import { getDb } from "@wrnexus/db";
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { ensureCrmWorkspace } from "../lib/workspace.ts";
|
||||
|
||||
export async function GET(ctx: Context): Promise<Response> {
|
||||
if (!(await can(ctx, "crm:dashboard")))
|
||||
return Response.json({ error: "Forbidden" }, { status: 403 });
|
||||
const owner = String((ctx.user as { id?: unknown } | undefined)?.id ?? "");
|
||||
await ensureCrmWorkspace(owner);
|
||||
const metrics = await getDb().one<{
|
||||
pipeline_value_cents: number;
|
||||
open_opportunities: number;
|
||||
contacts: number;
|
||||
}>(
|
||||
`SELECT
|
||||
COALESCE((SELECT SUM(value_cents) FROM crm_deals WHERE owner_id = ? AND stage NOT IN ('won','lost')), 0) pipeline_value_cents,
|
||||
(SELECT COUNT(*) FROM crm_deals WHERE owner_id = ? AND stage NOT IN ('won','lost')) open_opportunities,
|
||||
(SELECT COUNT(*) FROM crm_contacts WHERE owner_id = ?) contacts`,
|
||||
[owner, owner, owner],
|
||||
);
|
||||
return Response.json({
|
||||
metrics: {
|
||||
pipelineValue: new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(Number(metrics?.pipeline_value_cents ?? 0) / 100),
|
||||
openOpportunities: Number(metrics?.open_opportunities ?? 0),
|
||||
contacts: Number(metrics?.contacts ?? 0),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { can } from "@wrnexus/authz";
|
||||
import { getDb } from "@wrnexus/db";
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { ensureCrmWorkspace } from "../lib/workspace.ts";
|
||||
|
||||
export async function GET(ctx: Context): Promise<Response> {
|
||||
if (!(await can(ctx, "deal:read"))) return Response.json({ error: "Forbidden" }, { status: 403 });
|
||||
const owner = String((ctx.user as { id?: unknown } | undefined)?.id ?? "");
|
||||
await ensureCrmWorkspace(owner);
|
||||
const deals = await getDb().all(
|
||||
"SELECT d.id,d.title,d.value_cents,d.stage,d.close_date,c.name contact_name FROM crm_deals d LEFT JOIN crm_contacts c ON c.id=d.contact_id WHERE d.owner_id=? ORDER BY d.updated_at DESC",
|
||||
[owner],
|
||||
);
|
||||
return Response.json({ deals });
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineAuthz } from "@wrnexus/authz";
|
||||
|
||||
export default defineAuthz({
|
||||
permissions: {
|
||||
"crm:dashboard": { title: "View dashboard" },
|
||||
"contact:read": { title: "View contacts" },
|
||||
"contact:write": { title: "Create and edit contacts" },
|
||||
"contact:delete": { title: "Delete contacts", risk: "high" },
|
||||
"deal:read": { title: "View deals" },
|
||||
"deal:write": { title: "Create and edit deals" },
|
||||
"admin:access": { title: "Manage CRM access", risk: "high" },
|
||||
},
|
||||
roles: {
|
||||
viewer: ["crm:dashboard", "contact:read", "deal:read"],
|
||||
"sales-rep": ["role:viewer", "contact:write", "deal:write"],
|
||||
manager: ["role:sales-rep", "contact:delete"],
|
||||
admin: ["role:manager", "admin:access"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
-- +up
|
||||
CREATE TABLE IF NOT EXISTS crm_contacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
company TEXT NOT NULL DEFAULT '',
|
||||
phone TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'lead' CHECK (status IN ('lead', 'customer', 'inactive')),
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS crm_contacts_owner_idx ON crm_contacts(owner_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS crm_contacts_owner_email_uq ON crm_contacts(owner_id, email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crm_deals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_id TEXT NOT NULL,
|
||||
contact_id INTEGER REFERENCES crm_contacts(id) ON DELETE SET NULL,
|
||||
title TEXT NOT NULL,
|
||||
value_cents INTEGER NOT NULL DEFAULT 0 CHECK (value_cents >= 0),
|
||||
stage TEXT NOT NULL DEFAULT 'qualified' CHECK (stage IN ('qualified', 'proposal', 'won', 'lost')),
|
||||
close_date TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS crm_deals_owner_idx ON crm_deals(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS crm_deals_contact_idx ON crm_deals(contact_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crm_activities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
actor_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL CHECK (entity_type IN ('contact', 'deal', 'account')),
|
||||
entity_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
details_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS crm_activities_entity_idx ON crm_activities(entity_type, entity_id);
|
||||
|
||||
-- Database-backed authorization assignments.
|
||||
CREATE TABLE IF NOT EXISTS _wrn_authz_assignment (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
subject_id VARCHAR(255) NOT NULL,
|
||||
scope VARCHAR(255) NOT NULL DEFAULT '',
|
||||
role VARCHAR(255) NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT _wrn_authz_assignment_unique UNIQUE (subject_id, scope, role)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS _wrn_authz_grant (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
subject_id VARCHAR(255) NOT NULL,
|
||||
scope VARCHAR(255) NOT NULL DEFAULT '',
|
||||
permission VARCHAR(255) NOT NULL,
|
||||
effect VARCHAR(16) NOT NULL CHECK (effect IN ('allow', 'deny')),
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT _wrn_authz_grant_unique UNIQUE (subject_id, scope, permission)
|
||||
);
|
||||
|
||||
-- +down
|
||||
DROP TABLE IF EXISTS _wrn_authz_grant;
|
||||
DROP TABLE IF EXISTS _wrn_authz_assignment;
|
||||
DROP TABLE IF EXISTS crm_activities;
|
||||
DROP TABLE IF EXISTS crm_deals;
|
||||
DROP TABLE IF EXISTS crm_contacts;
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Db } from "@wrnexus/db";
|
||||
|
||||
export default async function seed(db: Db): Promise<void> {
|
||||
const owner = "demo-owner";
|
||||
await db.exec("DELETE FROM crm_activities");
|
||||
await db.exec("DELETE FROM crm_deals");
|
||||
await db.exec("DELETE FROM crm_contacts");
|
||||
await db.exec(
|
||||
"INSERT INTO crm_contacts (owner_id,name,email,company,phone,status) VALUES (?,?,?,?,?,?)",
|
||||
[owner, "Ada Lovelace", "ada@example.test", "Analytical Engines", "+1 555 0101", "customer"],
|
||||
);
|
||||
await db.exec(
|
||||
"INSERT INTO crm_contacts (owner_id,name,email,company,phone,status) VALUES (?,?,?,?,?,?)",
|
||||
[owner, "Grace Hopper", "grace@example.test", "Compiler Labs", "+1 555 0102", "lead"],
|
||||
);
|
||||
const contact = await db.one<{ id: number }>("SELECT id FROM crm_contacts WHERE email = ?", [
|
||||
"ada@example.test",
|
||||
]);
|
||||
await db.exec(
|
||||
"INSERT INTO crm_deals (owner_id,contact_id,title,value_cents,stage,close_date) VALUES (?,?,?,?,?,?)",
|
||||
[owner, contact?.id ?? null, "Enterprise rollout", 12500000, "proposal", "2026-12-15"],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createAuthEngine, SqlAuthStore, type AuthStore } from "@wrnexus/auth";
|
||||
import { getDb } from "@wrnexus/db";
|
||||
import { dbPermissionStore } from "@wrnexus/authz/db";
|
||||
import { ensureCrmWorkspace } from "./workspace.ts";
|
||||
|
||||
// Config is imported before the runtime opens its configured database. This
|
||||
// forwarding store resolves getDb() only when an auth operation actually runs.
|
||||
const store = new Proxy({} as AuthStore, {
|
||||
get(_target, property) {
|
||||
const value = Reflect.get(new SqlAuthStore(getDb()), property);
|
||||
return typeof value === "function" ? value.bind(new SqlAuthStore(getDb())) : value;
|
||||
},
|
||||
});
|
||||
|
||||
export const auth = createAuthEngine({
|
||||
store,
|
||||
secret: process.env.AUTH_SECRET ?? "northstar-crm-development-secret-change-me",
|
||||
issuer: "Northstar CRM",
|
||||
onSignedIn(ctx, returnTo) {
|
||||
const destination =
|
||||
returnTo?.startsWith("/") && !returnTo.startsWith("//") ? returnTo : "/dashboard";
|
||||
return Response.redirect(new URL(destination, ctx.url), 303);
|
||||
},
|
||||
onSignedOut(ctx) {
|
||||
return Response.redirect(new URL("/", ctx.url), 303);
|
||||
},
|
||||
async onSuccessfulSignUp(_ctx, user) {
|
||||
await dbPermissionStore(getDb()).assignRole(user.id, "sales-rep");
|
||||
await ensureCrmWorkspace(user.id);
|
||||
return { autoSignIn: true, redirectTo: "/dashboard" };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getDb } from "@wrnexus/db";
|
||||
|
||||
/** Give every real CRM user a useful workspace on first access. */
|
||||
export async function ensureCrmWorkspace(ownerId: string): Promise<void> {
|
||||
if (!ownerId) return;
|
||||
const db = getDb();
|
||||
await db.exec(
|
||||
"INSERT OR IGNORE INTO crm_contacts (owner_id,name,email,company,phone,status) VALUES (?,?,?,?,?,?)",
|
||||
[ownerId, "Ada Lovelace", "ada@example.test", "Analytical Engines", "+1 555 0101", "customer"],
|
||||
);
|
||||
await db.exec(
|
||||
"INSERT OR IGNORE INTO crm_contacts (owner_id,name,email,company,phone,status) VALUES (?,?,?,?,?,?)",
|
||||
[ownerId, "Grace Hopper", "grace@example.test", "Compiler Labs", "+1 555 0102", "lead"],
|
||||
);
|
||||
const existingDeal = await db.one<{ count: number }>(
|
||||
"SELECT COUNT(*) count FROM crm_deals WHERE owner_id = ?",
|
||||
[ownerId],
|
||||
);
|
||||
if (Number(existingDeal?.count ?? 0) === 0) {
|
||||
const contact = await db.one<{ id: number }>(
|
||||
"SELECT id FROM crm_contacts WHERE owner_id = ? AND email = ?",
|
||||
[ownerId, "ada@example.test"],
|
||||
);
|
||||
await db.exec(
|
||||
"INSERT INTO crm_deals (owner_id,contact_id,title,value_cents,stage,close_date) VALUES (?,?,?,?,?,?)",
|
||||
[ownerId, contact?.id ?? null, "Enterprise rollout", 12500000, "proposal", "2026-12-15"],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { authzMiddleware, getAuthzCatalog } from "@wrnexus/authz";
|
||||
import { getDb } from "@wrnexus/db";
|
||||
import { dbPermissionStore } from "@wrnexus/authz/db";
|
||||
import type { Middleware } from "@wrnexus/core";
|
||||
|
||||
// Production imports middleware before it opens configured databases. Resolve
|
||||
// the SQL-backed store on the first request, after runtime initialization.
|
||||
const middleware: Middleware = (ctx, next) =>
|
||||
authzMiddleware({ catalog: getAuthzCatalog(), store: dbPermissionStore(getDb()) })(ctx, next);
|
||||
|
||||
export default middleware;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { requireAuth } from "@wrnexus/auth";
|
||||
import { can } from "@wrnexus/authz";
|
||||
import type { Middleware } from "@wrnexus/core";
|
||||
|
||||
const guard = requireAuth({ loginPath: "/login" });
|
||||
const protectedPrefixes = ["/dashboard", "/contacts", "/deals", "/admin"];
|
||||
|
||||
const middleware: Middleware = (ctx, next) => {
|
||||
if (!protectedPrefixes.some((prefix) => ctx.url.pathname.startsWith(prefix))) return next();
|
||||
return guard(ctx, async () => {
|
||||
if (ctx.url.pathname.startsWith("/admin") && !(await can(ctx, "admin:access"))) {
|
||||
return Response.redirect(new URL("/forbidden", ctx.url), 303);
|
||||
}
|
||||
return next();
|
||||
});
|
||||
};
|
||||
|
||||
export default middleware;
|
||||
@@ -0,0 +1,5 @@
|
||||
page Admin {
|
||||
state user = ctx.user
|
||||
seo { title = "Administration" }
|
||||
view { <main class="crm-shell"><aside class="crm-sidebar"><a class="crm-brand" href="/"><span class="crm-brand-mark">N</span>Northstar CRM</a><nav class="crm-menu"><a href="/dashboard">Overview</a><a href="/contacts">Contacts</a><a href="/deals">Deals</a><a href="/admin" aria-current="page">Administration</a></nav></aside><section class="crm-content"><header class="crm-topbar"><div><h1>Access administration</h1><p>Manage roles and protect high-risk operations.</p></div></header><section class="crm-panel"><span class="crm-eyebrow">Role-based access</span><h2>Signed in as {user.displayName || user.username}</h2><p class="crm-hero-copy">This route requires the <strong>admin:access</strong> permission. Assign manager and administrator roles through the database-backed authorization store.</p></section></section></main> }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
page Contacts {
|
||||
apis { listContacts GET /api/contacts { response { return data.contacts } } }
|
||||
load server contacts { return await api.listContacts() }
|
||||
seo { title = "Contacts" }
|
||||
view { <main class="crm-shell"><aside class="crm-sidebar"><a class="crm-brand" href="/"><span class="crm-brand-mark">N</span>Northstar CRM</a><nav class="crm-menu"><a href="/dashboard">Overview</a><a href="/contacts" aria-current="page">Contacts</a><a href="/deals">Deals</a><a href="/admin">Administration</a></nav></aside><section class="crm-content"><header class="crm-topbar"><div><h1>Contacts</h1><p>Every relationship, scoped securely to its owner.</p></div><a class="crm-button" href="mailto:sales@example.test">New contact</a></header><section class="crm-panel"><span class="crm-eyebrow">Customer directory</span>{#if contacts.length}<ul class="crm-list">{#each contacts as contact}<li><strong>{contact.name}</strong><span>{contact.company || contact.email}</span><span class="crm-pill">{contact.status}</span></li>{/each}</ul>{/if}{#if !contacts.length}<div class="crm-empty"><h2>No contacts yet</h2><p>Your first customer relationship will appear here.</p></div>{/if}</section></section></main> }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
page Dashboard {
|
||||
state user = ctx.user
|
||||
state dashboardMetrics = ctx.metrics
|
||||
apis { dashboard GET /api/dashboard { response { return data.metrics } } }
|
||||
load server metrics { return await api.dashboard() }
|
||||
seo { title = "Dashboard" }
|
||||
view { <main class="crm-shell"><aside class="crm-sidebar"><a class="crm-brand" href="/"><span class="crm-brand-mark">N</span>Northstar CRM</a><nav class="crm-menu"><a href="/dashboard" aria-current="page">Overview</a><a href="/contacts">Contacts</a><a href="/deals">Deals</a><a href="/admin">Administration</a></nav><form class="crm-sidebar-footer" method="post" action="/api/auth/logout" data-schema="auth-empty"><button class="crm-button crm-button--ghost" type="submit">Log out</button></form></aside><section class="crm-content"><header class="crm-topbar"><div><h1>Good to see you, {user.displayName || user.username}</h1><p>Live figures from your private SQLite workspace.</p></div><a class="crm-button" href="/contacts">View contacts</a></header><div class="crm-stat-grid"><article class="crm-stat"><span>Pipeline value</span><strong>{dashboardMetrics.pipelineValue}</strong></article><article class="crm-stat"><span>Open opportunities</span><strong>{dashboardMetrics.openOpportunities}</strong></article><article class="crm-stat"><span>Total contacts</span><strong>{dashboardMetrics.contacts}</strong></article></div><section class="crm-panel"><span class="crm-eyebrow">Server rendered</span><h2>One secure data path</h2><p class="crm-hero-copy">This page was rendered from authenticated API results on the server. No browser prefetch or client data request is needed to show these metrics.</p></section></section></main> }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
page Deals {
|
||||
apis { listDeals GET /api/deals { response { return data.deals } } }
|
||||
load server deals { return await api.listDeals() }
|
||||
seo { title = "Deals" }
|
||||
view { <main class="crm-shell"><aside class="crm-sidebar"><a class="crm-brand" href="/"><span class="crm-brand-mark">N</span>Northstar CRM</a><nav class="crm-menu"><a href="/dashboard">Overview</a><a href="/contacts">Contacts</a><a href="/deals" aria-current="page">Deals</a><a href="/admin">Administration</a></nav></aside><section class="crm-content"><header class="crm-topbar"><div><h1>Sales pipeline</h1><p>Focus on the opportunities most likely to move.</p></div><a class="crm-button" href="/contacts">New deal</a></header><section class="crm-panel"><span class="crm-eyebrow">Open opportunities</span>{#if deals.length}<ul class="crm-list">{#each deals as deal}<li><strong>{deal.title}</strong><span>{deal.contact_name || "Unassigned contact"}</span><span class="crm-pill">{deal.stage}</span></li>{/each}</ul>{/if}{#if !deals.length}<div class="crm-empty"><h2>No open deals</h2><p>Create a contact and turn the relationship into an opportunity.</p></div>{/if}</section></section></main> }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
page Forbidden {
|
||||
seo { title = "Access denied" }
|
||||
view {
|
||||
<main class="crm-status-page"><section class="crm-status-card"><span class="crm-status-icon" aria-hidden="true">!</span><span class="crm-eyebrow">Permission required</span><h1>You do not have access to administration.</h1><p>Your account is working correctly, but an administrator role is required for this page.</p><div class="crm-actions"><a class="crm-button" href="/dashboard">Back to dashboard</a><a class="crm-button crm-button--ghost" href="/contacts">View contacts</a></div></section></main>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
page Home {
|
||||
seo { title = "CRM that keeps sales moving" }
|
||||
view {
|
||||
<main
|
||||
class="crm-public"
|
||||
>
|
||||
<nav
|
||||
class="crm-nav"
|
||||
>
|
||||
<a
|
||||
class="crm-brand"
|
||||
href="/"
|
||||
>
|
||||
<span
|
||||
class="crm-brand-mark"
|
||||
>
|
||||
N</span>Northstar CRM</a><div class="crm-nav-links"><a href="/pricing">Pricing</a><a href="/login">Log in</a><a class="crm-button" href="/signup">Start free</a></div>
|
||||
</nav>
|
||||
<section
|
||||
class="crm-hero"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
class="crm-eyebrow"
|
||||
>
|
||||
Customer intelligence, simplified</span><h1>Know every customer. Close every opportunity.</h1><p class="crm-hero-copy">Bring contacts, conversations, and pipeline into one calm workspace built for teams that value clarity.</p><div class="crm-actions"><a class="crm-button" href="/signup">Create your workspace</a><a class="crm-button crm-button--ghost" href="/login">View the CRM</a></div></div><div class="crm-preview"><div class="crm-preview-bar"><span></span><span></span><span></span></div><div class="crm-preview-grid"><article class="crm-preview-card"><small>Pipeline value</small><strong>$125k</strong><small>+18% this month</small></article><article class="crm-preview-card"><small>Active contacts</small><strong>248</strong><small>32 need follow-up</small></article><article class="crm-preview-card"><small>Win rate</small><strong>42%</strong><small>Across qualified deals</small></article><article class="crm-preview-card"><small>Next action</small><strong>8 today</strong><small>Stay ahead of every promise</small></article></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import AuthSplitLayout from "@wrnexus/ui/components/AuthSplitLayout.wrn"
|
||||
|
||||
page Login {
|
||||
seo { title = "Log in" }
|
||||
view {
|
||||
<AuthSplitLayout brand="Northstar CRM" eyebrow="Customer intelligence" title="Turn every conversation into momentum." description="Sign in to a focused workspace for contacts, pipeline, and activity." features='[{"label":"One customer timeline","description":"Every relationship and opportunity in context."},{"label":"Secure by default","description":"SQL sessions and role-based authorization."},{"label":"Built for focus","description":"A calm workspace without sales-tool clutter."}]'>
|
||||
<div data-slot="form"><SignIn action="/api/auth/login" returnTo="/dashboard" signUpHref="/signup" forgotHref="/login" showPasskey="false" class="border-0 shadow-none" /></div>
|
||||
</AuthSplitLayout>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
page Pricing {
|
||||
seo { title = "Pricing" }
|
||||
view { <main class="crm-public"><nav class="crm-nav"><a class="crm-brand" href="/"><span class="crm-brand-mark">N</span>Northstar CRM</a><div class="crm-nav-links"><a href="/login">Log in</a><a class="crm-button" href="/signup">Start free</a></div></nav><section class="crm-hero"><div><span class="crm-eyebrow">Simple pricing</span><h1>Start free. Scale when your team does.</h1><p class="crm-hero-copy">Everything needed to evaluate a secure, full-stack CRM locally. Move to Team when you are ready to collaborate.</p></div><div class="crm-preview"><article class="crm-preview-card"><small>Starter</small><strong>$0</strong><p>SQLite workspace, CRM flows, authentication and permissions.</p><a class="crm-button" href="/signup">Start building</a></article><article class="crm-preview-card"><small>Team</small><strong>$29</strong><p>Per user/month with shared pipeline and administration.</p></article></div></section></main> }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import AuthSplitLayout from "@wrnexus/ui/components/AuthSplitLayout.wrn"
|
||||
|
||||
page Signup {
|
||||
seo { title = "Create account" }
|
||||
view {
|
||||
<AuthSplitLayout brand="Northstar CRM" eyebrow="Start in minutes" title="Build relationships, not spreadsheets." description="Create your secure sales workspace and begin with a complete customer view." features='[{"label":"Free local workspace","description":"Explore the complete CRM flow with SQLite."},{"label":"Flexible permissions","description":"Viewer, sales, manager, and admin roles."},{"label":"Your data stays yours","description":"Portable SQL data and explicit migrations."}]'>
|
||||
<div data-slot="form"><SignUp redirect="/dashboard" signInHref="/login" showPhone="false" showUsername="false" requireConsent="false" class="border-0 shadow-none" /></div>
|
||||
</AuthSplitLayout>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// AUTO-GENERATED by `wrnexus dev` - do not edit.
|
||||
// Typed routes support required, optional, and catch-all parameters.
|
||||
|
||||
export interface Routes {
|
||||
"/": Record<string, never>;
|
||||
"/admin": Record<string, never>;
|
||||
"/contacts": Record<string, never>;
|
||||
"/dashboard": Record<string, never>;
|
||||
"/deals": Record<string, never>;
|
||||
"/forbidden": Record<string, never>;
|
||||
"/login": Record<string, never>;
|
||||
"/pricing": Record<string, never>;
|
||||
"/signup": Record<string, never>;
|
||||
}
|
||||
|
||||
export interface RouteNames {
|
||||
"index": "/";
|
||||
"admin": "/admin";
|
||||
"contacts": "/contacts";
|
||||
"dashboard": "/dashboard";
|
||||
"deals": "/deals";
|
||||
"forbidden": "/forbidden";
|
||||
"login": "/login";
|
||||
"pricing": "/pricing";
|
||||
"signup": "/signup";
|
||||
}
|
||||
|
||||
export interface RouteQueries {
|
||||
[path: string]: Record<string, string | number | boolean | null | undefined>;
|
||||
}
|
||||
|
||||
export type RoutePath = keyof Routes;
|
||||
export type RouteName = keyof RouteNames;
|
||||
export type RouteQuery<P extends RoutePath> = P extends keyof RouteQueries
|
||||
? RouteQueries[P]
|
||||
: Record<string, string | number | boolean | null | undefined>;
|
||||
type RouteValue = string | readonly string[] | undefined;
|
||||
|
||||
function encodeRouteValue(value: RouteValue, catchAll: boolean): string {
|
||||
if (value === undefined) return "";
|
||||
const values = Array.isArray(value) ? value : catchAll ? String(value).split("/") : [String(value)];
|
||||
return values.map((part) => encodeURIComponent(part)).join("/");
|
||||
}
|
||||
|
||||
function buildHref(path: string, params: Record<string, RouteValue> = {}): string {
|
||||
const output: string[] = [];
|
||||
for (const segment of path.split("/").filter(Boolean)) {
|
||||
let name: string | undefined;
|
||||
let optional = false;
|
||||
let catchAll = false;
|
||||
if (segment.startsWith("[[") && segment.endsWith("]]")) {
|
||||
optional = true;
|
||||
name = segment.slice(2, -2);
|
||||
} else if (segment.startsWith("[") && segment.endsWith("]")) {
|
||||
name = segment.slice(1, -1);
|
||||
if (name.endsWith("?")) {
|
||||
optional = true;
|
||||
name = name.slice(0, -1);
|
||||
}
|
||||
}
|
||||
if (!name) {
|
||||
output.push(segment);
|
||||
continue;
|
||||
}
|
||||
if (name.startsWith("...")) {
|
||||
catchAll = true;
|
||||
name = name.slice(3);
|
||||
}
|
||||
const value = params[name];
|
||||
if (value === undefined && optional) continue;
|
||||
if (value === undefined) throw new Error(`WRN-ROUTE-MISSING-PARAM: Missing route parameter '${name}'.`);
|
||||
output.push(encodeRouteValue(value, catchAll));
|
||||
}
|
||||
return "/" + output.filter(Boolean).join("/");
|
||||
}
|
||||
|
||||
export function href<P extends RoutePath>(
|
||||
path: P,
|
||||
...args: keyof Routes[P] extends never
|
||||
? []
|
||||
: Record<string, never> extends Routes[P]
|
||||
? [params?: Routes[P]]
|
||||
: [params: Routes[P]]
|
||||
): string {
|
||||
const params = (args[0] ?? {}) as Record<string, RouteValue>;
|
||||
return buildHref(String(path), params);
|
||||
}
|
||||
|
||||
export function route<N extends RouteName>(
|
||||
name: N,
|
||||
...args: keyof Routes[RouteNames[N]] extends never
|
||||
? [params?: Routes[RouteNames[N]], query?: RouteQuery<RouteNames[N]>]
|
||||
: Record<string, never> extends Routes[RouteNames[N]]
|
||||
? [params?: Routes[RouteNames[N]], query?: RouteQuery<RouteNames[N]>]
|
||||
: [params: Routes[RouteNames[N]], query?: RouteQuery<RouteNames[N]>]
|
||||
): string {
|
||||
const paths: Record<RouteName, RoutePath> = {
|
||||
"index": "/",
|
||||
"admin": "/admin",
|
||||
"contacts": "/contacts",
|
||||
"dashboard": "/dashboard",
|
||||
"deals": "/deals",
|
||||
"forbidden": "/forbidden",
|
||||
"login": "/login",
|
||||
"pricing": "/pricing",
|
||||
"signup": "/signup"
|
||||
} as Record<RouteName, RoutePath>;
|
||||
const output = buildHref(paths[name], (args[0] ?? {}) as Record<string, RouteValue>);
|
||||
const query = args[1];
|
||||
if (!query) return output;
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query))
|
||||
if (value !== undefined && value !== null) search.set(key, String(value));
|
||||
const text = search.toString();
|
||||
return text ? `${output}?${text}` : output;
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@iconify/tailwind4";
|
||||
@source "../**/*.wrn";
|
||||
@source "../../../packages/auth/components/*.wrn";
|
||||
@source "../../../packages/ui/components/*.wrn";
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
min-width: 320px;
|
||||
background: var(--wrn-color-bg);
|
||||
}
|
||||
body {
|
||||
color: var(--wrn-color-text);
|
||||
background: var(--wrn-color-bg);
|
||||
font-family: Inter, "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.crm-public {
|
||||
min-height: 100dvh;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 80% 10%,
|
||||
color-mix(in srgb, var(--wrn-color-primary) 15%, transparent),
|
||||
transparent 28%
|
||||
),
|
||||
var(--wrn-color-bg);
|
||||
}
|
||||
.crm-nav {
|
||||
width: min(1180px, calc(100% - 2rem));
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 0;
|
||||
}
|
||||
.crm-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.crm-brand-mark {
|
||||
display: grid;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
place-items: center;
|
||||
border-radius: 0.75rem;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--wrn-color-primary), #7c3aed);
|
||||
box-shadow: 0 10px 24px color-mix(in srgb, var(--wrn-color-primary) 28%, transparent);
|
||||
}
|
||||
.crm-nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.crm-button {
|
||||
display: inline-flex;
|
||||
min-height: 2.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 1.1rem;
|
||||
border-radius: 0.75rem;
|
||||
color: white;
|
||||
background: var(--wrn-color-primary);
|
||||
font-weight: 700;
|
||||
box-shadow: 0 10px 24px color-mix(in srgb, var(--wrn-color-primary) 22%, transparent);
|
||||
}
|
||||
.crm-button--ghost {
|
||||
color: var(--wrn-color-text);
|
||||
background: var(--wrn-color-surface);
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
.crm-hero {
|
||||
width: min(1180px, calc(100% - 2rem));
|
||||
margin: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1.05fr 0.95fr;
|
||||
align-items: center;
|
||||
gap: 4rem;
|
||||
padding: clamp(4rem, 9vw, 8rem) 0;
|
||||
}
|
||||
.crm-eyebrow {
|
||||
color: var(--wrn-color-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.13em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.crm-hero h1 {
|
||||
max-width: 12ch;
|
||||
margin: 1rem 0;
|
||||
font-size: clamp(3rem, 7vw, 5.5rem);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.065em;
|
||||
}
|
||||
.crm-hero-copy {
|
||||
max-width: 38rem;
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.crm-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.crm-preview {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: 1.5rem;
|
||||
background: color-mix(in srgb, var(--wrn-color-surface) 88%, transparent);
|
||||
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.15);
|
||||
transform: rotate(1.5deg);
|
||||
}
|
||||
.crm-preview-bar {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.25rem 1rem;
|
||||
}
|
||||
.crm-preview-bar span {
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 50%;
|
||||
background: var(--wrn-color-border);
|
||||
}
|
||||
.crm-preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.crm-preview-card {
|
||||
min-height: 8rem;
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--wrn-color-surface-2);
|
||||
}
|
||||
.crm-preview-card strong {
|
||||
display: block;
|
||||
margin-top: 0.7rem;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
.crm-preview-card small {
|
||||
color: var(--wrn-color-muted);
|
||||
}
|
||||
|
||||
.crm-shell {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
grid-template-columns: 16rem 1fr;
|
||||
background: var(--wrn-color-bg);
|
||||
}
|
||||
.crm-sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.35rem;
|
||||
border-right: 1px solid var(--wrn-color-border);
|
||||
background: var(--wrn-color-surface);
|
||||
}
|
||||
.crm-menu {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.crm-menu a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-radius: 0.75rem;
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
.crm-menu a:hover,
|
||||
.crm-menu a[aria-current="page"] {
|
||||
color: var(--wrn-color-primary);
|
||||
background: color-mix(in srgb, var(--wrn-color-primary) 10%, transparent);
|
||||
}
|
||||
.crm-sidebar-footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
.crm-content {
|
||||
min-width: 0;
|
||||
padding: clamp(1.25rem, 4vw, 3rem);
|
||||
}
|
||||
.crm-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.crm-topbar h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.75rem, 4vw, 2.4rem);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.crm-topbar p {
|
||||
margin: 0.4rem 0 0;
|
||||
color: var(--wrn-color-muted);
|
||||
}
|
||||
.crm-panel {
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: 1rem;
|
||||
background: var(--wrn-color-surface);
|
||||
box-shadow: var(--wrn-shadow-1);
|
||||
}
|
||||
.crm-stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.crm-stat {
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: 1rem;
|
||||
background: var(--wrn-color-surface);
|
||||
}
|
||||
.crm-stat span {
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
.crm-stat strong {
|
||||
display: block;
|
||||
margin-top: 0.6rem;
|
||||
font-size: 2rem;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.crm-list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
padding: 0;
|
||||
margin: 1rem 0 0;
|
||||
list-style: none;
|
||||
}
|
||||
.crm-list li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(10rem, 1fr) minmax(8rem, 0.7fr) auto;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: 0.8rem;
|
||||
background: var(--wrn-color-surface-2);
|
||||
}
|
||||
.crm-pill {
|
||||
justify-self: end;
|
||||
padding: 0.3rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
color: var(--wrn-color-primary);
|
||||
background: color-mix(in srgb, var(--wrn-color-primary) 10%, transparent);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 750;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.crm-empty {
|
||||
padding: 3rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--wrn-color-muted);
|
||||
}
|
||||
.crm-empty h2 {
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--wrn-color-text);
|
||||
}
|
||||
.crm-status-page {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
background: radial-gradient(
|
||||
circle at 50% 0%,
|
||||
color-mix(in srgb, var(--wrn-color-primary) 12%, transparent),
|
||||
transparent 45%
|
||||
);
|
||||
}
|
||||
.crm-status-card {
|
||||
width: min(34rem, 100%);
|
||||
padding: clamp(1.5rem, 5vw, 3rem);
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: 1.25rem;
|
||||
background: var(--wrn-color-surface);
|
||||
box-shadow: var(--wrn-shadow-2);
|
||||
}
|
||||
.crm-status-card h1 {
|
||||
margin: 0.9rem 0 0.7rem;
|
||||
font-size: clamp(1.8rem, 5vw, 2.5rem);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
.crm-status-card > p {
|
||||
color: var(--wrn-color-muted);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.crm-status-icon {
|
||||
display: grid;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin-bottom: 1.25rem;
|
||||
place-items: center;
|
||||
border-radius: 1rem;
|
||||
color: #b45309;
|
||||
background: #fef3c7;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.crm-nav-links > a:not(.crm-button) {
|
||||
display: none;
|
||||
}
|
||||
.crm-hero {
|
||||
grid-template-columns: 1fr;
|
||||
padding-top: 3rem;
|
||||
}
|
||||
.crm-preview {
|
||||
transform: none;
|
||||
}
|
||||
.crm-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.crm-sidebar {
|
||||
position: static;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.crm-menu {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
overflow: auto;
|
||||
}
|
||||
.crm-sidebar-footer {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.crm-stat-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.crm-hero h1 {
|
||||
font-size: 3rem;
|
||||
}
|
||||
.crm-menu {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.crm-list li {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.crm-pill {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// AUTO-GENERATED by `wrnexus generate types` - do not edit.
|
||||
//
|
||||
// Type-only assertions for sectioned `api` blocks. Kept as a real .ts file (not
|
||||
// wrnexus.generated.d.ts) because `skipLibCheck` exempts .d.ts contents from being
|
||||
// checked; this file is compiled and checked normally by the project's own tsc.
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,47 @@
|
||||
// AUTO-GENERATED by `wrnexus generate types` - do not edit.
|
||||
declare namespace WRNexusGenerated {
|
||||
type ApiContract<T> = T extends import("@wrnexus/core").DefinedEndpoint<infer I, infer O>
|
||||
? { input: I; output: O }
|
||||
: T extends (...args: infer A) => infer R
|
||||
? { input: A extends [any, infer I, ...any[]] ? I : unknown; output: Awaited<R> }
|
||||
: { input: unknown; output: unknown };
|
||||
type MiddlewareContext<T> = T extends (ctx: infer C, ...args: any[]) => any ? C : never;
|
||||
type QueryContract<T> = T extends (db: any, args: infer A, ...rest: any[]) => infer R
|
||||
? { args: A; result: Awaited<R> }
|
||||
: T extends (db: any, ...rest: any[]) => infer R
|
||||
? { args: Record<string, never>; result: Awaited<R> }
|
||||
: 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 = "admin" | "contacts" | "dashboard" | "deals" | "forbidden" | "index" | "login" | "pricing" | "signup";
|
||||
type ApiRoute = "/api/contacts" | "/api/dashboard" | "/api/deals";
|
||||
type RealtimeRoute = never;
|
||||
type EnvironmentKey = never;
|
||||
type TranslationKey = never;
|
||||
type QueueName = never;
|
||||
type CacheKey = never;
|
||||
type Components = Record<string, never>;
|
||||
interface ApiContracts {
|
||||
"/api/dashboard": { GET: ApiContract<typeof import("../api/dashboard.ts")["GET"]> };
|
||||
"/api/contacts": { GET: ApiContract<typeof import("../api/contacts.ts")["GET"]>; POST: ApiContract<typeof import("../api/contacts.ts")["POST"]> };
|
||||
"/api/deals": { GET: ApiContract<typeof import("../api/deals.ts")["GET"]> };
|
||||
}
|
||||
interface MiddlewareContexts {
|
||||
"authz": MiddlewareContext<(typeof import("../middleware/authz.ts"))["default"]>;
|
||||
"protected": MiddlewareContext<(typeof import("../middleware/protected.ts"))["default"]>;
|
||||
}
|
||||
type DatabaseQueries = Record<string, never>;
|
||||
type RealtimeMessages = Record<string, never>;
|
||||
type QueuePayloads = Record<string, never>;
|
||||
type ApplicationConfig = (typeof import("../../wrnexus.config.ts"))["default"];
|
||||
type AssertAssignable<Actual, Expected> = unknown extends Expected
|
||||
? true
|
||||
: [Actual] extends [Expected]
|
||||
? [Exclude<keyof Actual, keyof Expected>] extends [never]
|
||||
? true
|
||||
: false
|
||||
: false;
|
||||
type __wrn_expect_true<T extends true> = T;
|
||||
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
|
||||
type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// AUTO-GENERATED plugin type aggregation - do not edit.
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "wrnexus-crm-example",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run ../../packages/cli/src/index.ts dev .",
|
||||
"build": "bun run ../../packages/cli/src/index.ts build .",
|
||||
"db:migrate": "bun run ../../packages/cli/src/index.ts db migrate .",
|
||||
"db:seed": "bun run ../../packages/cli/src/index.ts db seed .",
|
||||
"test": "bun test test",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"check": "bun run typecheck && bun run test && bun run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/auth": "workspace:*",
|
||||
"@wrnexus/authz": "workspace:*",
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/db": "workspace:*",
|
||||
"@wrnexus/styles": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/lucide": "^1.2.123",
|
||||
"@iconify/tailwind4": "^1.2.3",
|
||||
"@tailwindcss/cli": "^4.3.3",
|
||||
"@types/bun": "^1.3.14",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createDb, loadMigrations, migrate } from "@wrnexus/db";
|
||||
import { sqlite } from "@wrnexus/db/sqlite";
|
||||
import seed from "../app/db/seed.ts";
|
||||
|
||||
const root = join(import.meta.dir, "..");
|
||||
const source = (path: string) => readFileSync(join(root, path), "utf8");
|
||||
|
||||
test("the CRM migration applies to SQLite, is idempotent, and the seed is repeatable", async () => {
|
||||
const db = createDb(sqlite());
|
||||
const migrations = join(root, "app", "db", "migrations");
|
||||
|
||||
expect(loadMigrations(migrations).map(({ name }) => name)).toEqual(["001_crm"]);
|
||||
expect(await migrate(db, migrations)).toEqual(["001_crm"]);
|
||||
expect(await migrate(db, migrations)).toEqual([]);
|
||||
await seed(db);
|
||||
await seed(db);
|
||||
|
||||
expect(await db.one<{ count: number }>("SELECT COUNT(*) count FROM crm_contacts")).toEqual({
|
||||
count: 2,
|
||||
});
|
||||
expect(await db.one<{ count: number }>("SELECT COUNT(*) count FROM crm_deals")).toEqual({
|
||||
count: 1,
|
||||
});
|
||||
expect(
|
||||
await db.all(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('_wrn_authz_assignment','_wrn_authz_grant') ORDER BY name",
|
||||
),
|
||||
).toHaveLength(2);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test("authentication uses SQL, plugin migrations, signup/login components, and protected routes", () => {
|
||||
expect(source("app/lib/auth.ts")).toContain("SqlAuthStore");
|
||||
expect(source("wrnexus.config.ts")).toContain("migrations: true");
|
||||
expect(source("app/pages/login.wrn")).toContain("<SignIn");
|
||||
expect(source("app/pages/signup.wrn")).toContain("<SignUp");
|
||||
expect(source("app/middleware/protected.ts")).toContain('requireAuth({ loginPath: "/login" })');
|
||||
expect(source("app/pages/dashboard.wrn")).toContain("/api/auth/logout");
|
||||
expect(source("app/pages/login.wrn")).toContain("AuthSplitLayout");
|
||||
expect(source("app/pages/login.wrn")).toContain('data-slot="form"');
|
||||
expect(source("app/pages/signup.wrn")).not.toContain("Already registered?");
|
||||
expect(source("wrnexus.config.ts")).toContain('entry: "app/styles/global.css"');
|
||||
});
|
||||
|
||||
test("authorization is database-backed and guards both APIs and administration", () => {
|
||||
expect(source("app/middleware/authz.ts")).toContain("dbPermissionStore(getDb())");
|
||||
expect(source("app/lib/auth.ts")).toContain('assignRole(user.id, "sales-rep")');
|
||||
expect(source("app/api/contacts.ts")).toContain('can(ctx, "contact:write")');
|
||||
expect(source("app/api/deals.ts")).toContain('can(ctx, "deal:read")');
|
||||
expect(source("app/middleware/protected.ts")).toContain('can(ctx, "admin:access")');
|
||||
});
|
||||
|
||||
test("the app includes public, authentication, and protected CRM pages", () => {
|
||||
for (const page of [
|
||||
"index",
|
||||
"pricing",
|
||||
"login",
|
||||
"signup",
|
||||
"dashboard",
|
||||
"contacts",
|
||||
"deals",
|
||||
"admin",
|
||||
"forbidden",
|
||||
]) {
|
||||
expect(source(`app/pages/${page}.wrn`)).toContain("page ");
|
||||
}
|
||||
});
|
||||
|
||||
test("protected CRM data is loaded on the server without browser API bindings", () => {
|
||||
for (const page of ["contacts", "deals", "dashboard"]) {
|
||||
const contents = source(`app/pages/${page}.wrn`);
|
||||
expect(contents).toContain("load server");
|
||||
expect(contents).not.toContain(' api="');
|
||||
}
|
||||
expect(source("app/api/dashboard.ts")).toContain("pipeline_value_cents");
|
||||
expect(source("app/lib/workspace.ts")).toContain("INSERT OR IGNORE INTO crm_contacts");
|
||||
expect(source("app/lib/auth.ts")).toContain("ensureCrmWorkspace(user.id)");
|
||||
expect(source("app/middleware/protected.ts")).toContain('new URL("/forbidden", ctx.url)');
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": { "noEmit": true },
|
||||
"include": ["app/**/*.ts", "test/**/*.ts", "wrnexus.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { join } from "node:path";
|
||||
import type { AuthConfig } from "@wrnexus/auth";
|
||||
import type { AppConfig } from "@wrnexus/styles";
|
||||
import { auth } from "./app/lib/auth.ts";
|
||||
|
||||
const config = {
|
||||
seo: {
|
||||
title: "Northstar CRM",
|
||||
titleTemplate: "%s | Northstar CRM",
|
||||
description: "A complete WrNexus SQLite CRM example.",
|
||||
canonicalBase: "http://localhost:3000",
|
||||
},
|
||||
theme: { palette: "blue", default: "light" },
|
||||
db: { driver: "sqlite", url: "file:./crm.sqlite" },
|
||||
styles: {
|
||||
entry: "app/styles/global.css",
|
||||
async process({ entryPath, appRoot, mode }) {
|
||||
const args = ["@tailwindcss/cli", "-i", entryPath!];
|
||||
if (mode === "production") args.push("--minify");
|
||||
return await Bun.$.cwd(appRoot)`bunx ${args}`.text();
|
||||
},
|
||||
failureMode: "throw",
|
||||
},
|
||||
auth: {
|
||||
engine: auth,
|
||||
routes: true,
|
||||
middleware: true,
|
||||
components: true,
|
||||
migrations: true,
|
||||
baseUrl: "http://localhost:3000",
|
||||
},
|
||||
profiles: {
|
||||
test: { db: { driver: "sqlite", url: `file:${join(import.meta.dir, ".tmp-test.sqlite")}` } },
|
||||
},
|
||||
security: { cors: { enabled: false } },
|
||||
} satisfies AppConfig & { auth: AuthConfig };
|
||||
|
||||
export default config;
|
||||
@@ -4,8 +4,6 @@ import { join } from "node:path";
|
||||
import type { AppConfig } from "@wrnexus/styles";
|
||||
|
||||
const config: AppConfig = {
|
||||
compatibilityDate: "2026-08-02",
|
||||
frameworkBehaviour: 1,
|
||||
// v0.8 defaults: explicit imports, strict template types, safe stores, and
|
||||
// automatic progressive navigation. Package plugins are discovered from the
|
||||
// installed packages above; add custom plugins to this array when needed.
|
||||
@@ -19,14 +17,7 @@ const config: AppConfig = {
|
||||
checkComponentProps: true,
|
||||
generateDeclarations: true,
|
||||
},
|
||||
functions: { legacyDefaultRuntime: "current" },
|
||||
stores: { strictMutations: true, persistence: true },
|
||||
compatibility: {
|
||||
legacyEmit: false,
|
||||
legacyEventProps: false,
|
||||
legacyComponentDiscovery: false,
|
||||
stringLayouts: false,
|
||||
},
|
||||
experimental: {},
|
||||
|
||||
performance: {
|
||||
|
||||
@@ -4,8 +4,6 @@ import { join } from "node:path";
|
||||
import type { AppConfig } from "@wrnexus/styles";
|
||||
|
||||
const config: AppConfig = {
|
||||
compatibilityDate: "2026-08-02",
|
||||
frameworkBehaviour: 1,
|
||||
// v0.8 defaults: explicit imports, strict template types, safe stores, and
|
||||
// automatic progressive navigation. Package plugins are discovered from the
|
||||
// installed packages above; add custom plugins to this array when needed.
|
||||
@@ -19,14 +17,7 @@ const config: AppConfig = {
|
||||
checkComponentProps: true,
|
||||
generateDeclarations: true,
|
||||
},
|
||||
functions: { legacyDefaultRuntime: "current" },
|
||||
stores: { strictMutations: true, persistence: true },
|
||||
compatibility: {
|
||||
legacyEmit: false,
|
||||
legacyEventProps: false,
|
||||
legacyComponentDiscovery: false,
|
||||
stringLayouts: false,
|
||||
},
|
||||
experimental: {},
|
||||
|
||||
performance: {
|
||||
|
||||
@@ -25,17 +25,17 @@ component SignIn {
|
||||
class = ""
|
||||
}
|
||||
view {
|
||||
<section {...attrs} data-wrnexus-runtime="auth" data-auth-sign-in data-mfa-href='{mfaHref}' class='w-full max-w-md rounded-[var(--wrn-radius-lg)] border border-[var(--wrn-color-border)] bg-[var(--wrn-color-surface)] p-6 text-[var(--wrn-color-text)] shadow-[var(--wrn-shadow-2)] {class}'>
|
||||
<header class="mb-6 space-y-1.5">
|
||||
<section {...attrs} data-wrnexus-runtime="auth" data-auth-sign-in data-mfa-href='{mfaHref}' class='wrn-auth-sign-in w-full max-w-md rounded-[var(--wrn-radius-lg)] border border-[var(--wrn-color-border)] bg-[var(--wrn-color-surface)] p-6 text-[var(--wrn-color-text)] shadow-[var(--wrn-shadow-2)] {class}'>
|
||||
<header class="wrn-auth-sign-in__header mb-6 space-y-1.5">
|
||||
<h1 class="m-0 text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
<p class="m-0 text-sm text-[var(--wrn-color-muted)]">{description}</p>
|
||||
</header>
|
||||
<form method="post" action='{action}' data-schema='{schema}' data-redirect='{redirect}' novalidate class="space-y-4">
|
||||
<form method="post" action='{action}' data-schema='{schema}' data-redirect='{redirect}' novalidate class="wrn-auth-sign-in__form">
|
||||
<input type="hidden" name="returnTo" value='{returnTo}' />
|
||||
<input type="hidden" name="deviceFingerprint" value="" />
|
||||
<input type="hidden" name="deviceName" value="" />
|
||||
<Input id="auth-sign-in-identifier" name="identifier" type="text" label="{identifierLabel}" autocomplete="username webauthn" placeholder="{identifierPlaceholder}" icon="icon-[lucide--at-sign]" color="{color}" size="{size}" />
|
||||
<div class="space-y-1.5">
|
||||
<div>
|
||||
<TogglePassword
|
||||
label="{passwordLabel}"
|
||||
cornerHint="Forgot password?"
|
||||
@@ -48,9 +48,7 @@ component SignIn {
|
||||
size="{size}"
|
||||
/>
|
||||
</div>
|
||||
{#if showRemember}
|
||||
<Checkbox id="auth-sign-in-remember" name="rememberDevice" label="Trust this device" color="{color}" size="{size}" />
|
||||
{/if}
|
||||
{#if showRemember}<div class="wrn-auth-sign-in__remember"><Checkbox id="auth-sign-in-remember" name="rememberDevice" label="Trust this device" color="{color}" size="{size}" /></div>{/if}
|
||||
<slot></slot>
|
||||
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wrn-radius-sm)] bg-[color-mix(in_srgb,var(--wrn-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wrn-color-danger)]"></p>
|
||||
<Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" />
|
||||
@@ -64,4 +62,18 @@ component SignIn {
|
||||
{/if}
|
||||
</section>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-auth-sign-in__header { margin-bottom: 1.5rem; }
|
||||
.wrn-auth-sign-in__header h1 { font-size: 1.75rem; line-height: 1.15; }
|
||||
.wrn-auth-sign-in__header p { margin-top: 0.35rem; font-size: 0.9375rem; line-height: 1.5; }
|
||||
.wrn-auth-sign-in__form { display: grid; gap: 1.15rem; }
|
||||
.wrn-auth-sign-in__form > * { margin: 0 !important; }
|
||||
.wrn-auth-sign-in__remember { margin-top: -0.25rem !important; }
|
||||
.wrn-auth-sign-in__form .wrn-next__field-heading label,
|
||||
.wrn-auth-sign-in__form .wrn-next__password-heading { font-size: 0.875rem; font-weight: 650; }
|
||||
.wrn-auth-sign-in__form .wrn-next__field-control > input { min-height: 3rem; font-size: 0.875rem; }
|
||||
.wrn-auth-sign-in__form [data-error]:empty { display: none; }
|
||||
.wrn-auth-sign-in__form .wrn-action[data-full-width="true"] { margin-top: 0.15rem !important; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ component SignUp {
|
||||
<section
|
||||
{...attrs}
|
||||
data-wrnexus-runtime="auth"
|
||||
class='w-full max-w-lg rounded-[var(--wrn-radius-lg)] border border-[var(--wrn-color-border)] bg-[var(--wrn-color-surface)] p-6 text-[var(--wrn-color-text)] shadow-[var(--wrn-shadow-2)] {class}'
|
||||
class='wrn-auth-sign-up w-full max-w-lg rounded-[var(--wrn-radius-lg)] border border-[var(--wrn-color-border)] bg-[var(--wrn-color-surface)] p-6 text-[var(--wrn-color-text)] shadow-[var(--wrn-shadow-2)] {class}'
|
||||
>
|
||||
<header
|
||||
class="mb-6 space-y-1.5"
|
||||
@@ -50,7 +50,7 @@ component SignUp {
|
||||
data-schema='{schema}'
|
||||
data-redirect='{redirect}'
|
||||
novalidate
|
||||
class="grid gap-4 sm:grid-cols-2"
|
||||
class="wrn-auth-sign-up__form"
|
||||
>
|
||||
<Input
|
||||
id="auth-sign-up-name"
|
||||
@@ -61,7 +61,6 @@ component SignUp {
|
||||
icon="icon-[lucide--user]"
|
||||
color="{color}"
|
||||
size="{size}"
|
||||
class="sm:col-span-2"
|
||||
/>
|
||||
|
||||
<Input
|
||||
@@ -100,7 +99,6 @@ component SignUp {
|
||||
icon="icon-[lucide--circle-user-round]"
|
||||
color="{color}"
|
||||
size="{size}"
|
||||
class="sm:col-span-2"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -176,15 +174,7 @@ component SignUp {
|
||||
>
|
||||
</p>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
label="{submitLabel}"
|
||||
variant="solid"
|
||||
color="{color}"
|
||||
size="{size}"
|
||||
fullWidth="true"
|
||||
class="wrn-auth-sign-up__wide sm:col-span-2"
|
||||
/>
|
||||
<div class="wrn-auth-sign-up__submit"><Button type="submit" label="{submitLabel}" variant="solid" color="{color}" size="{size}" fullWidth="true" /></div>
|
||||
</form>
|
||||
|
||||
<p
|
||||
@@ -196,9 +186,19 @@ component SignUp {
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-auth-sign-up > header { margin-bottom: 1.5rem; }
|
||||
.wrn-auth-sign-up > header h1 { font-size: 1.75rem; line-height: 1.15; letter-spacing: -0.025em; }
|
||||
.wrn-auth-sign-up > header p { margin-top: 0.35rem; font-size: 0.9375rem; line-height: 1.5; }
|
||||
.wrn-auth-sign-up__form { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1.15rem; }
|
||||
.wrn-auth-sign-up__form .wrn-next__field-heading label,
|
||||
.wrn-auth-sign-up__form .wrn-next--strong-password > label { font-size: 0.875rem; font-weight: 650; }
|
||||
.wrn-auth-sign-up__form .wrn-next__field-control > input { min-height: 3rem; font-size: 0.875rem; }
|
||||
.wrn-auth-sign-up__form [data-error]:empty { display: none; }
|
||||
.wrn-auth-sign-up__wide {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
}
|
||||
.wrn-auth-sign-up__submit { width: 100%; margin-top: 0.25rem; }
|
||||
.wrn-auth-sign-up__submit .wrn-btn { min-height: 3rem; font-size: 0.9375rem; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/auth",
|
||||
"version": "0.8.12",
|
||||
"version": "0.8.13",
|
||||
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@@ -160,10 +160,6 @@ export interface AuthEngine {
|
||||
key: string;
|
||||
response: unknown;
|
||||
name?: string;
|
||||
/** @deprecated Verification uses the RP ID bound to the issued challenge. */
|
||||
rpId?: string;
|
||||
/** @deprecated Verification uses the origin bound to the issued challenge. */
|
||||
origin?: string;
|
||||
},
|
||||
): Promise<boolean>;
|
||||
beginPasskeyAuthentication(input: {
|
||||
@@ -176,10 +172,6 @@ export interface AuthEngine {
|
||||
response: unknown;
|
||||
/** Request metadata used only for the resulting session. */
|
||||
session?: Partial<AuthSession>;
|
||||
/** @deprecated Verification uses the RP ID bound to the issued challenge. */
|
||||
rpId?: string;
|
||||
/** @deprecated Verification uses the origin bound to the issued challenge. */
|
||||
origin?: string;
|
||||
}): Promise<AuthResult>;
|
||||
changePassword(
|
||||
userId: string,
|
||||
@@ -832,7 +824,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
store,
|
||||
onSignedIn: options.onSignedIn,
|
||||
onSignedOut: options.onSignedOut,
|
||||
onSuccessfulSignUp: options.onSuccessfulSignUp ?? options.onSuccessfullSignUp,
|
||||
onSuccessfulSignUp: options.onSuccessfulSignUp,
|
||||
|
||||
async register(input) {
|
||||
try {
|
||||
|
||||
@@ -9,11 +9,7 @@ import {
|
||||
getAuthUser,
|
||||
} from "../middleware.ts";
|
||||
import { resolveAuthSchemas, type AuthSchemaOverrides, type AuthSchemaSet } from "../validation.ts";
|
||||
import type {
|
||||
AuthSessionVerificationHandler,
|
||||
AuthSignedInHandler,
|
||||
AuthSignedOutHandler,
|
||||
} from "../types.ts";
|
||||
import type { AuthSessionVerificationHandler } from "../types.ts";
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === "string" ? value : value == null ? "" : String(value);
|
||||
@@ -53,18 +49,14 @@ export interface AuthHttpOptions {
|
||||
baseUrl?: string;
|
||||
schemas?: AuthSchemaOverrides | AuthSchemaSet;
|
||||
passkey?: AuthPasskeyHttpOptions;
|
||||
/** @deprecated Prefer createAuthEngine({ onSignedIn }). */
|
||||
onSignedIn?: AuthSignedInHandler;
|
||||
/** @deprecated Prefer createAuthEngine({ onSignedOut }). */
|
||||
onSignedOut?: AuthSignedOutHandler;
|
||||
onSessionVerification?: AuthSessionVerificationHandler;
|
||||
}
|
||||
|
||||
export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
const engine = options.engine;
|
||||
const schemas = resolveAuthSchemas(options.schemas);
|
||||
const onSignedIn = options.onSignedIn ?? engine.onSignedIn;
|
||||
const onSignedOut = options.onSignedOut ?? engine.onSignedOut;
|
||||
const onSignedIn = engine.onSignedIn;
|
||||
const onSignedOut = engine.onSignedOut;
|
||||
const onSuccessfulSignUp = engine.onSuccessfulSignUp;
|
||||
|
||||
function signupRedirect(ctx: Context, value: string | undefined, fallback: string): Response {
|
||||
|
||||
@@ -4,11 +4,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { definePlugin, type PluginContext } from "@wrnexus/plugin";
|
||||
import type { AuthEngine } from "./engine.ts";
|
||||
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
|
||||
import type {
|
||||
AuthSessionVerificationHandler,
|
||||
AuthSignedInHandler,
|
||||
AuthSignedOutHandler,
|
||||
} from "./types.ts";
|
||||
import type { AuthSessionVerificationHandler } from "./types.ts";
|
||||
import { AUTH_ROUTE_DEFINITIONS, type AuthRouteGroup } from "./routes/definitions.ts";
|
||||
import {
|
||||
clearDefaultAuthEngine,
|
||||
@@ -52,10 +48,6 @@ export interface AuthConfig {
|
||||
baseUrl?: string;
|
||||
csrf?: boolean;
|
||||
passkey?: AuthPasskeyHttpOptions;
|
||||
/** @deprecated Prefer createAuthEngine({ onSignedIn }). */
|
||||
onSignedIn?: AuthSignedInHandler;
|
||||
/** @deprecated Prefer createAuthEngine({ onSignedOut }). */
|
||||
onSignedOut?: AuthSignedOutHandler;
|
||||
/** Shared SSO cookie whose value is an AuthEngine session id. */
|
||||
sessionCookieName?: string;
|
||||
/** Customize the package forward-auth verification response. */
|
||||
@@ -94,8 +86,6 @@ interface ResolvedAuthConfig {
|
||||
baseUrl?: string;
|
||||
csrf: boolean;
|
||||
passkey?: AuthPasskeyHttpOptions;
|
||||
onSignedIn?: AuthSignedInHandler;
|
||||
onSignedOut?: AuthSignedOutHandler;
|
||||
sessionCookieName?: string;
|
||||
onSessionVerification?: AuthSessionVerificationHandler;
|
||||
}
|
||||
@@ -156,8 +146,6 @@ function resolveConfig(
|
||||
baseUrl: raw.baseUrl,
|
||||
csrf: raw.csrf ?? true,
|
||||
passkey: raw.passkey,
|
||||
onSignedIn: raw.onSignedIn ?? raw.engine?.onSignedIn,
|
||||
onSignedOut: raw.onSignedOut ?? raw.engine?.onSignedOut,
|
||||
sessionCookieName: raw.sessionCookieName,
|
||||
onSessionVerification: raw.onSessionVerification,
|
||||
};
|
||||
@@ -392,10 +380,6 @@ export function authPlugin(options: AuthPluginOptions = {}) {
|
||||
|
||||
passkey: value.passkey,
|
||||
|
||||
onSignedIn: value.onSignedIn,
|
||||
|
||||
onSignedOut: value.onSignedOut,
|
||||
|
||||
sessionCookieName: value.sessionCookieName,
|
||||
|
||||
onSessionVerification: value.onSessionVerification,
|
||||
|
||||
@@ -60,8 +60,6 @@ function handlersFor(ctx: Context): AuthHttpHandlers | undefined {
|
||||
schemas: getDefaultAuthSchemas(),
|
||||
baseUrl: routeOptions.baseUrl ?? ctx.url.origin,
|
||||
passkey: routeOptions.passkey,
|
||||
onSignedIn: routeOptions.onSignedIn,
|
||||
onSignedOut: routeOptions.onSignedOut,
|
||||
onSessionVerification: routeOptions.onSessionVerification,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import type { AuthEngine } from "./engine.ts";
|
||||
import type { AuthPasskeyHttpOptions } from "./http/index.ts";
|
||||
import type { AuthSessionVerificationHandler } from "./types.ts";
|
||||
@@ -8,10 +7,6 @@ export interface DefaultAuthRouteOptions {
|
||||
baseUrl?: string;
|
||||
csrf?: boolean;
|
||||
passkey?: AuthPasskeyHttpOptions;
|
||||
|
||||
onSignedIn?: (ctx: Context, returnTo?: string) => Response | Promise<Response>;
|
||||
|
||||
onSignedOut?: (ctx: Context) => Response | Promise<Response>;
|
||||
sessionCookieName?: string;
|
||||
onSessionVerification?: AuthSessionVerificationHandler;
|
||||
}
|
||||
|
||||
@@ -420,8 +420,6 @@ export interface AuthEngineOptions {
|
||||
* redirects to /sign-in.
|
||||
*/
|
||||
onSuccessfulSignUp?: AuthSuccessfulSignUpHandler;
|
||||
/** @deprecated Misspelled alias; use onSuccessfulSignUp. */
|
||||
onSuccessfullSignUp?: AuthSuccessfulSignUpHandler;
|
||||
breachProvider?: PasswordBreachProvider;
|
||||
passkeys?: PasskeyProvider;
|
||||
passkeyChallengeStore?: import("./passkeys/index.ts").PasskeyChallengeStore;
|
||||
|
||||
@@ -72,6 +72,17 @@ test("account forms use the dedicated password components", () => {
|
||||
expect(signUp).not.toContain('id="auth-sign-up-password"');
|
||||
});
|
||||
|
||||
test("account forms own consistent spacing and full-width submit layout", () => {
|
||||
const signIn = readFileSync(join(directory, "SignIn.wrn"), "utf8");
|
||||
const signUp = readFileSync(join(directory, "SignUp.wrn"), "utf8");
|
||||
|
||||
expect(signIn).toContain("wrn-auth-sign-in__form");
|
||||
expect(signIn).toContain("wrn-auth-sign-in__remember");
|
||||
expect(signUp).toContain("wrn-auth-sign-up__form");
|
||||
expect(signUp).toContain("wrn-auth-sign-up__submit");
|
||||
expect(signUp).toContain("grid-template-columns: minmax(0, 1fr)");
|
||||
});
|
||||
|
||||
test("authentication journeys use themed UI form controls", () => {
|
||||
const expected = {
|
||||
"SignIn.wrn": ["<Input", "<TogglePassword", "<Checkbox", "<Button"],
|
||||
|
||||
@@ -579,8 +579,6 @@ describe("authentication engine", () => {
|
||||
await engine.finishPasskeyRegistration(registered.user!.id, {
|
||||
key: start.key,
|
||||
response: {},
|
||||
rpId: "example.test",
|
||||
origin: "https://example.test",
|
||||
}),
|
||||
).toBe(true);
|
||||
const auth = await engine.beginPasskeyAuthentication({
|
||||
@@ -595,8 +593,6 @@ describe("authentication engine", () => {
|
||||
await engine.finishPasskeyAuthentication({
|
||||
key: auth.key,
|
||||
response: { id: "cred-1" },
|
||||
rpId: "example.test",
|
||||
origin: "https://example.test",
|
||||
})
|
||||
).ok,
|
||||
).toBe(true);
|
||||
|
||||
@@ -1,9 +1,45 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { createPluginRunner } from "@wrnexus/plugin";
|
||||
import { authPlugin } from "../src/plugin.ts";
|
||||
import { AUTH_ROUTE_DEFINITIONS } from "../src/routes/definitions.ts";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { getDefaultAuthRouteOptions } from "../src/runtime.ts";
|
||||
import { createAuthEngine } from "../src/engine.ts";
|
||||
import { MemoryAuthStore } from "../src/stores/memory.ts";
|
||||
import { invokeAuthHandler } from "../src/routes/api.ts";
|
||||
|
||||
function routeContext(request: Request): Context {
|
||||
const values = new Map<string, unknown>();
|
||||
return {
|
||||
req: request,
|
||||
url: new URL(request.url),
|
||||
params: {},
|
||||
locals: {},
|
||||
lang: "en",
|
||||
t: (key: string) => key,
|
||||
ip: "127.0.0.1",
|
||||
user: null,
|
||||
cookies: {
|
||||
get: (name: string) => (name === "wrn-csrf" ? "plugin-test-csrf-token" : undefined),
|
||||
} as Context["cookies"],
|
||||
localStorage: {} as Context["localStorage"],
|
||||
session: {
|
||||
id: () => "plugin-test-session",
|
||||
get: <T>(key: string) => values.get(key) as T | undefined,
|
||||
getAll: () => Object.fromEntries(values),
|
||||
set: (key: string, value: unknown) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
delete: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
regenerate: () => {},
|
||||
clear: () => {
|
||||
values.clear();
|
||||
},
|
||||
},
|
||||
} as Context;
|
||||
}
|
||||
|
||||
test("plugin contributes components, runtime, styles, migration, and toolbar", async () => {
|
||||
const metadata = new Map<string, unknown>();
|
||||
@@ -70,9 +106,25 @@ test("config.auth controls route groups and migrations without explicit plugin o
|
||||
expect(contributions.routes.some((route) => route.path === "/api/auth/login")).toBe(true);
|
||||
});
|
||||
|
||||
test("config.auth resolves navigation hooks from the configured engine", async () => {
|
||||
const onSignedIn = () => new Response(null, { status: 204 });
|
||||
const onSignedOut = () => new Response(null, { status: 204 });
|
||||
test("a hook set on config.auth.engine fires when a request goes through the route layer", async () => {
|
||||
const calls: string[] = [];
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "plugin-navigation-hooks-secret-longer-than-thirty-two-characters",
|
||||
onSignedIn(ctx, returnTo) {
|
||||
calls.push(`in:${returnTo}`);
|
||||
return Response.redirect(new URL(returnTo ?? "/account", ctx.url), 303);
|
||||
},
|
||||
onSignedOut(ctx) {
|
||||
calls.push("out");
|
||||
return Response.redirect(new URL("/sign-in", ctx.url), 303);
|
||||
},
|
||||
});
|
||||
await engine.register({
|
||||
email: "plugin-navigation@example.com",
|
||||
password: "StrongPassword123",
|
||||
});
|
||||
|
||||
const runner = createPluginRunner(authPlugin(), {
|
||||
root: process.cwd(),
|
||||
mode: "development",
|
||||
@@ -82,14 +134,41 @@ test("config.auth resolves navigation hooks from the configured engine", async (
|
||||
});
|
||||
|
||||
await runner.configure({
|
||||
auth: {
|
||||
engine: { onSignedIn, onSignedOut } as never,
|
||||
routes: true,
|
||||
},
|
||||
auth: { engine, routes: true },
|
||||
});
|
||||
|
||||
expect(getDefaultAuthRouteOptions().onSignedIn).toBe(onSignedIn);
|
||||
expect(getDefaultAuthRouteOptions().onSignedOut).toBe(onSignedOut);
|
||||
const loginResponse = await invokeAuthHandler(
|
||||
"login",
|
||||
routeContext(
|
||||
new Request("https://example.test/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-csrf-token": "plugin-test-csrf-token",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
identifier: "plugin-navigation@example.com",
|
||||
password: "StrongPassword123",
|
||||
returnTo: "/dashboard",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(loginResponse.status).toBe(303);
|
||||
expect(loginResponse.headers.get("location")).toBe("https://example.test/dashboard");
|
||||
|
||||
const logoutResponse = await invokeAuthHandler(
|
||||
"logout",
|
||||
routeContext(
|
||||
new Request("https://example.test/api/auth/logout", {
|
||||
method: "POST",
|
||||
headers: { "x-csrf-token": "plugin-test-csrf-token" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(logoutResponse.status).toBe(303);
|
||||
expect(logoutResponse.headers.get("location")).toBe("https://example.test/sign-in");
|
||||
expect(calls).toEqual(["in:/dashboard", "out"]);
|
||||
});
|
||||
|
||||
test("auth runtime contains built-in browser schemas", async () => {
|
||||
|
||||
@@ -83,17 +83,10 @@ Every command accepts an optional `[app-dir]` (defaults to `.`). Commands that r
|
||||
| `wrnexus db <cmd>` | Database migrations and tooling (see [db](#wrnexus-db)). |
|
||||
| `wrnexus test [app-dir] [--watch]` | Run the app's tests via `bun test` (defaults to the `test` profile). |
|
||||
| `wrnexus profiles [app-dir]` | List config profiles and their `.env` files, marking the active one. |
|
||||
| `wrnexus compatibility check` | Check whether behavior defaults are explicitly pinned and current. |
|
||||
| `wrnexus compatibility explain` | Explain configured, effective, and current compatibility behavior. |
|
||||
| `wrnexus compatibility upgrade` | Back up config and explicitly opt into reviewed current behavior. |
|
||||
| `wrnexus help` | Print usage. |
|
||||
|
||||
`wrnexus g` is an alias for `wrnexus generate`.
|
||||
|
||||
Compatibility upgrades never happen implicitly. New applications pin
|
||||
`compatibilityDate` and `frameworkBehaviour`; existing applications use
|
||||
`wrnexus compatibility explain` before the backed-up, idempotent upgrade command.
|
||||
|
||||
### `wrnexus dev`
|
||||
|
||||
Supervises a child dev-server process (from `@wrnexus/dev-server`). The child owns file watching and HMR: CSS and client-island edits update the live page over a WebSocket with no restart; when a server module changes, the child exits with a restart code and the supervisor respawns it (the browser reconnects and morphs in the new HTML). On startup it regenerates typed DB queries and typed routes (best effort). Use `--port=` to change the port (default `3000`).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.8.46",
|
||||
"version": "0.8.47",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import {
|
||||
CURRENT_COMPATIBILITY_DATE,
|
||||
CURRENT_FRAMEWORK_BEHAVIOUR,
|
||||
loadRawConfig,
|
||||
resolveCompatibility,
|
||||
} from "@wrnexus/styles";
|
||||
|
||||
const CONFIG_NAMES = ["wrnexus.config.ts", "wrnexus.config.js", "wrnexus.config.mjs"];
|
||||
|
||||
function configPath(root: string): string | undefined {
|
||||
return CONFIG_NAMES.map((name) => join(root, name)).find(existsSync);
|
||||
}
|
||||
|
||||
export async function compatibilityReport(appRoot: string) {
|
||||
return resolveCompatibility(await loadRawConfig(resolve(appRoot)));
|
||||
}
|
||||
|
||||
export function upgradeCompatibility(appRoot: string): {
|
||||
file: string;
|
||||
backup: string;
|
||||
changed: boolean;
|
||||
} {
|
||||
const root = resolve(appRoot);
|
||||
const file = configPath(root);
|
||||
if (!file) throw new Error("WRN-COMPATIBILITY-NO-CONFIG: wrnexus.config.ts was not found.");
|
||||
const source = readFileSync(file, "utf8");
|
||||
let updated = source;
|
||||
const replace = (name: string, value: string) => {
|
||||
const pattern = new RegExp(`(^\\s*${name}\\s*:\\s*)(?:["'][^"']*["']|\\d+)(\\s*,?)`, "m");
|
||||
if (pattern.test(updated)) updated = updated.replace(pattern, `$1${value}$2`);
|
||||
else {
|
||||
const object = /(?:const\s+config[^=]*=|defineConfig\s*\(|export\s+default)\s*\{/m;
|
||||
if (!object.test(updated))
|
||||
throw new Error("WRN-COMPATIBILITY-CONFIG-SHAPE: unable to locate the root config object.");
|
||||
updated = updated.replace(object, (match) => `${match}\n ${name}: ${value},`);
|
||||
}
|
||||
};
|
||||
replace("compatibilityDate", JSON.stringify(CURRENT_COMPATIBILITY_DATE));
|
||||
replace("frameworkBehaviour", String(CURRENT_FRAMEWORK_BEHAVIOUR));
|
||||
if (updated === source) return { file, backup: "", changed: false };
|
||||
const directory = join(root, ".wrnexus", "compatibility-backups");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const backup = join(directory, `${Date.now()}-${basename(file)}`);
|
||||
copyFileSync(file, backup);
|
||||
writeFileSync(file, updated, "utf8");
|
||||
return { file, backup, changed: true };
|
||||
}
|
||||
|
||||
export async function runCompatibilityCommand(
|
||||
appRoot: string,
|
||||
command = "check",
|
||||
args: string[] = [],
|
||||
): Promise<boolean> {
|
||||
if (command === "upgrade") {
|
||||
const result = upgradeCompatibility(appRoot);
|
||||
if (args.includes("--json")) console.log(JSON.stringify(result, null, 2));
|
||||
else
|
||||
console.log(
|
||||
result.changed
|
||||
? `✓ Compatibility policy upgraded\n backup: ${result.backup}`
|
||||
: "✓ Compatibility policy already current",
|
||||
);
|
||||
}
|
||||
const report = await compatibilityReport(appRoot);
|
||||
if (args.includes("--json")) console.log(JSON.stringify(report, null, 2));
|
||||
else {
|
||||
console.log(`Compatibility date: ${report.effectiveDate} (current ${report.currentDate})`);
|
||||
console.log(
|
||||
`Framework behaviour: ${report.effectiveBehaviour} (current ${report.currentBehaviour})`,
|
||||
);
|
||||
for (const message of report.messages) console.log(`- ${message}`);
|
||||
}
|
||||
return !report.needsUpgrade && !report.future;
|
||||
}
|
||||
@@ -260,8 +260,6 @@ trim_trailing_whitespace = true
|
||||
"wrnexus.config.ts": `import type { AppConfig } from "@wrnexus/styles";
|
||||
|
||||
const config: AppConfig = {
|
||||
compatibilityDate: "2026-08-02",
|
||||
frameworkBehaviour: 1,
|
||||
// v0.8 defaults: explicit imports, strict template types, safe stores, and
|
||||
// automatic progressive navigation. Package plugins are discovered from the
|
||||
// installed packages above; add custom plugins to this array when needed.
|
||||
@@ -275,14 +273,7 @@ const config: AppConfig = {
|
||||
checkComponentProps: true,
|
||||
generateDeclarations: true,
|
||||
},
|
||||
functions: { legacyDefaultRuntime: "current" },
|
||||
stores: { strictMutations: true, persistence: true },
|
||||
compatibility: {
|
||||
legacyEmit: false,
|
||||
legacyEventProps: false,
|
||||
legacyComponentDiscovery: false,
|
||||
stringLayouts: false,
|
||||
},
|
||||
experimental: {},
|
||||
|
||||
performance: {
|
||||
|
||||
+72
-5
@@ -14,15 +14,23 @@
|
||||
* (`databases.<name>`), with files under `app/db/<name>/`.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
createPluginRunner,
|
||||
discoverPlugins,
|
||||
type PackageMigrationDefinition,
|
||||
} from "@wrnexus/plugin";
|
||||
import { loadAppConfig, type AppConfig } from "@wrnexus/styles";
|
||||
import {
|
||||
generateQueriesFile,
|
||||
analyzeMigrations,
|
||||
appliedMigrations,
|
||||
applyMigrations,
|
||||
loadMigrations,
|
||||
migrate,
|
||||
parseMigration,
|
||||
parseQueries,
|
||||
rollback,
|
||||
scaffoldMigration,
|
||||
@@ -42,6 +50,55 @@ function dbBaseOf(appDir: string, dbName: string | null): string {
|
||||
return dbName ? join(appDir, "db", dbName) : join(appDir, "db");
|
||||
}
|
||||
|
||||
function packageMigrationName(definition: PackageMigrationDefinition, name?: string): string {
|
||||
const clean = (value: string) => value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
||||
return name ? `${clean(definition.id)}__${clean(name)}` : clean(definition.id);
|
||||
}
|
||||
|
||||
function resolvePackageMigrations(
|
||||
definitions: readonly PackageMigrationDefinition[],
|
||||
database?: string,
|
||||
) {
|
||||
return definitions.flatMap((definition) => {
|
||||
if ((definition.database?.trim() || "default") !== (database?.trim() || "default")) return [];
|
||||
if (definition.source !== undefined) {
|
||||
return [parseMigration(packageMigrationName(definition), definition.source)];
|
||||
}
|
||||
if (!definition.entry || !existsSync(definition.entry)) {
|
||||
throw new Error(`WRN-PLUGIN-MIGRATION-MISSING: ${definition.id} has no readable entry.`);
|
||||
}
|
||||
if (statSync(definition.entry).isDirectory()) {
|
||||
return loadMigrations(definition.entry).map((migration) => ({
|
||||
...migration,
|
||||
name: packageMigrationName(definition, migration.name),
|
||||
}));
|
||||
}
|
||||
return [
|
||||
parseMigration(
|
||||
packageMigrationName(definition, basename(definition.entry, ".sql")),
|
||||
readFileSync(definition.entry, "utf8"),
|
||||
),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
async function packageMigrations(root: string, config: AppConfig, database?: string) {
|
||||
const discovered = await discoverPlugins(root, config.plugins, {
|
||||
includeDevDependencies: true,
|
||||
strict: true,
|
||||
});
|
||||
const runner = createPluginRunner(discovered, {
|
||||
root,
|
||||
mode: "development",
|
||||
command: "dev",
|
||||
metadata: new Map(),
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
});
|
||||
await runner.configure(config as Record<string, unknown>);
|
||||
await runner.configResolved(config as Readonly<Record<string, unknown>>);
|
||||
return resolvePackageMigrations((await runner.contributions()).migrations, database);
|
||||
}
|
||||
|
||||
/** List user tables for the connected database (dialect-aware introspection). */
|
||||
async function listTables(db: import("@wrnexus/db").Db): Promise<string[]> {
|
||||
const dialect = db.driver.dialect;
|
||||
@@ -126,6 +183,7 @@ export async function runDbCommand(
|
||||
const dbConfig = dbName ? config.databases?.[dbName] : config.db;
|
||||
const dbBase = dbBaseOf(appDir, dbName);
|
||||
const migrationsDir = join(dbBase, "migrations");
|
||||
const contributedMigrations = await packageMigrations(root, config, dbName ?? undefined);
|
||||
const label = dbName ? ` (db: ${dbName})` : "";
|
||||
const safetyIssues = analyzeMigrations(loadMigrations(migrationsDir));
|
||||
|
||||
@@ -242,7 +300,10 @@ export async function runDbCommand(
|
||||
`WRN-DB-UNSAFE-MIGRATION: ${blockers.length} breaking rollout operation(s) found. Run 'wrnexus db check' and use an expand/contract migration; --allow-breaking explicitly overrides this gate.`,
|
||||
);
|
||||
}
|
||||
const applied = await migrate(db, migrationsDir);
|
||||
const applied = [
|
||||
...(await migrate(db, migrationsDir)),
|
||||
...(await applyMigrations(db, contributedMigrations)),
|
||||
];
|
||||
console.log(
|
||||
applied.length
|
||||
? `✓ Applied ${applied.length}${label}:\n ${applied.join("\n ")}`
|
||||
@@ -257,8 +318,14 @@ export async function runDbCommand(
|
||||
}
|
||||
case "status": {
|
||||
const rows = await status(db, migrationsDir);
|
||||
if (rows.length === 0) console.log(`No migrations found in ${migrationsDir}.`);
|
||||
else for (const r of rows) console.log(` [${r.applied ? "x" : " "}] ${r.name}`);
|
||||
const applied = new Set(await appliedMigrations(db));
|
||||
const packageRows = contributedMigrations.map(({ name }) => ({
|
||||
name,
|
||||
applied: applied.has(name),
|
||||
}));
|
||||
const allRows = [...rows, ...packageRows];
|
||||
if (allRows.length === 0) console.log(`No migrations found in ${migrationsDir}.`);
|
||||
else for (const r of allRows) console.log(` [${r.applied ? "x" : " "}] ${r.name}`);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -72,8 +72,6 @@ Usage:
|
||||
Run unit | component | api | browser | visual | accessibility | performance
|
||||
wrnexus profiles [app-dir] List config profiles (dev/prod/uat/…) and their env files
|
||||
wrnexus doctor [app-dir] [--fix] Check project health; optionally apply safe repairs
|
||||
wrnexus compatibility <check|explain|upgrade> [app-dir]
|
||||
Inspect or explicitly upgrade behavior defaults
|
||||
wrnexus contracts <check|snapshot> [app-dir]
|
||||
Detect breaking boundary contract changes
|
||||
wrnexus security <audit|headers|test> [app-dir]
|
||||
@@ -292,16 +290,6 @@ async function main(): Promise<void> {
|
||||
if (!healthy) process.exitCode = 1;
|
||||
break;
|
||||
}
|
||||
case "compatibility": {
|
||||
const { runCompatibilityCommand } = await import("./compatibility-command.ts");
|
||||
const subcommand = rest.find((value) => !value.startsWith("--")) ?? "check";
|
||||
const appRoot = rest.filter((value) => !value.startsWith("--"))[1] ?? ".";
|
||||
if (!["check", "explain", "upgrade"].includes(subcommand))
|
||||
throw new Error(`WRN-COMPATIBILITY-COMMAND: unknown command '${subcommand}'.`);
|
||||
const current = await runCompatibilityCommand(appRoot, subcommand, rest);
|
||||
if (!current && subcommand !== "explain") process.exitCode = 1;
|
||||
break;
|
||||
}
|
||||
case "contracts": {
|
||||
const { runContractsCommand } = await import("./contracts-command.ts");
|
||||
const subcommand = rest.find((value) => !value.startsWith("--")) ?? "check";
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Migration: move `api` entries out of legacy `ssr { … }` / `client { … }`
|
||||
* data blocks into a single page-level `apis { }` block.
|
||||
*
|
||||
* IMPORTANT: this is a text-level transform, not a parse-and-rewrite. The
|
||||
* `@wrnexus/syntax` parser deliberately THROWS a `ParseError` on `ssr { … }`
|
||||
* / `client { … }` data blocks (see `packages/syntax/src/parser.ts`, the
|
||||
* `case "ssr": case "client": case "server":` handling) — that legacy syntax
|
||||
* is exactly what this migration exists to read, so it cannot be reached by
|
||||
* parsing first. Instead this scans the source with the same balanced-brace
|
||||
* technique used throughout `update.ts` (`findMatching`), and only parses
|
||||
* the RESULT, as a validity check.
|
||||
*/
|
||||
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
|
||||
/** Blank out string/template literal contents and comments, preserving length. */
|
||||
function maskLiteralsAndComments(source: string): string {
|
||||
let out = "";
|
||||
let index = 0;
|
||||
while (index < source.length) {
|
||||
const char = source[index]!;
|
||||
if (char === '"' || char === "'" || char === "`") {
|
||||
const quote = char;
|
||||
let end = index + 1;
|
||||
while (end < source.length) {
|
||||
if (source[end] === "\\") {
|
||||
end += 2;
|
||||
continue;
|
||||
}
|
||||
if (source[end] === quote) {
|
||||
end++;
|
||||
break;
|
||||
}
|
||||
end++;
|
||||
}
|
||||
out += " ".repeat(end - index);
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && source[index + 1] === "/") {
|
||||
let end = index;
|
||||
while (end < source.length && source[end] !== "\n") end++;
|
||||
out += " ".repeat(end - index);
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && source[index + 1] === "*") {
|
||||
let end = source.indexOf("*/", index + 2);
|
||||
end = end < 0 ? source.length : end + 2;
|
||||
out += source.slice(index, end).replace(/[^\n]/g, " ");
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
out += char;
|
||||
index++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Balanced-brace scan: returns the index of the brace matching `open`, or -1. */
|
||||
function findMatching(source: string, open: number, openChar = "{", closeChar = "}"): number {
|
||||
let depth = 0;
|
||||
let quote = "";
|
||||
for (let index = open; index < source.length; index++) {
|
||||
const char = source[index]!;
|
||||
if (quote) {
|
||||
if (char === "\\") index++;
|
||||
else if (char === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'" || char === "`") quote = char;
|
||||
else if (char === openChar) depth++;
|
||||
else if (char === closeChar && --depth === 0) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
interface ModeBlock {
|
||||
mode: "ssr" | "client";
|
||||
/** Start of the `ssr`/`client` keyword. */
|
||||
start: number;
|
||||
/** One past the block's closing `}`. */
|
||||
end: number;
|
||||
bodyStart: number;
|
||||
bodyEnd: number;
|
||||
}
|
||||
|
||||
/** Find page-level `ssr { … }` / `client { … }` data blocks (not `state`/hydrate forms). */
|
||||
function findModeBlocks(source: string): ModeBlock[] {
|
||||
const blocks: ModeBlock[] = [];
|
||||
const header = /\b(ssr|client)\s*\{/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = header.exec(source))) {
|
||||
const open = match.index + match[0].length - 1;
|
||||
const close = findMatching(source, open);
|
||||
if (close < 0) {
|
||||
throw new Error(`unterminated "${match[1]} {" block at offset ${match.index}`);
|
||||
}
|
||||
blocks.push({
|
||||
mode: match[1] as "ssr" | "client",
|
||||
start: match.index,
|
||||
end: close + 1,
|
||||
bodyStart: open + 1,
|
||||
bodyEnd: close,
|
||||
});
|
||||
header.lastIndex = close + 1;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
interface ApiEntry {
|
||||
name: string;
|
||||
method: string;
|
||||
path: string;
|
||||
/** `name METHOD path { … }` text, without the leading `api` keyword. */
|
||||
text: string;
|
||||
/** Offsets relative to the block body the entry was found in. */
|
||||
localStart: number;
|
||||
localEnd: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find `api <name> <METHOD> <path> { … }` entries inside a mode-block body.
|
||||
*
|
||||
* A legacy BARE-BODY entry (no `request`/`response`/`error` sections — its
|
||||
* body is JS evaluated inside `with ($data ?? {})`) is deliberately excluded
|
||||
* here. The `apis { }` grammar requires sections, so folding a bare body into
|
||||
* it would produce source that fails to parse — a spurious "failed to parse"
|
||||
* report for a file that is actually fine. `report-legacy-api-bodies`
|
||||
* (`legacy-api-body.ts`) is the migration that surfaces these for manual
|
||||
* review; leaving them out of this scan lets that block's leftover content
|
||||
* fall through to the "mixes content" skip below when needed, or leaves the
|
||||
* block entirely untouched when it holds only bare-body entries.
|
||||
*/
|
||||
function findApiEntries(body: string): ApiEntry[] {
|
||||
const entries: ApiEntry[] = [];
|
||||
const header = /\bapi\s+([A-Za-z_$][\w$]*)\s+([A-Za-z]+)\s+(\S+?)\s*\{/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = header.exec(body))) {
|
||||
const open = match.index + match[0].length - 1;
|
||||
const close = findMatching(body, open);
|
||||
if (close < 0) {
|
||||
throw new Error(`unterminated api entry '${match[1]}' at offset ${match.index}`);
|
||||
}
|
||||
const localStart = match.index;
|
||||
const localEnd = close + 1;
|
||||
const bodyText = body.slice(open + 1, close);
|
||||
const hasSections = /\b(request|response|error)\s*\{/.test(maskLiteralsAndComments(bodyText));
|
||||
if (!hasSections) {
|
||||
header.lastIndex = localEnd;
|
||||
continue;
|
||||
}
|
||||
const text = body.slice(localStart, localEnd).replace(/^api\s+/, "");
|
||||
entries.push({
|
||||
name: match[1]!,
|
||||
method: match[2]!.toUpperCase(),
|
||||
path: match[3]!,
|
||||
text,
|
||||
localStart,
|
||||
localEnd,
|
||||
});
|
||||
header.lastIndex = localEnd;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
interface Edit {
|
||||
start: number;
|
||||
end: number;
|
||||
replacement: string;
|
||||
}
|
||||
|
||||
function applyEdits(source: string, edits: Edit[]): string {
|
||||
const sorted = [...edits].sort((a, b) => a.start - b.start);
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
for (const edit of sorted) {
|
||||
output += source.slice(cursor, edit.start) + edit.replacement;
|
||||
cursor = edit.end;
|
||||
}
|
||||
output += source.slice(cursor);
|
||||
return output;
|
||||
}
|
||||
|
||||
export function migrateApisBlock(
|
||||
source: string,
|
||||
): { source: string; changed: boolean } | { skip: string } {
|
||||
const blocks = findModeBlocks(source);
|
||||
if (blocks.length === 0) return { source, changed: false };
|
||||
|
||||
const allEntries: ApiEntry[] = [];
|
||||
const nameMode = new Map<string, "ssr" | "client">();
|
||||
const perBlockEntries = new Map<ModeBlock, ApiEntry[]>();
|
||||
|
||||
for (const block of blocks) {
|
||||
const body = source.slice(block.bodyStart, block.bodyEnd);
|
||||
const entries = findApiEntries(body);
|
||||
if (entries.length === 0) continue;
|
||||
perBlockEntries.set(block, entries);
|
||||
for (const entry of entries) {
|
||||
const existingMode = nameMode.get(entry.name);
|
||||
if (existingMode && existingMode !== block.mode) {
|
||||
return {
|
||||
skip: `api '${entry.name}' is declared in both ssr and client — merge them by hand before this migration can run`,
|
||||
};
|
||||
}
|
||||
nameMode.set(entry.name, block.mode);
|
||||
allEntries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (allEntries.length === 0) return { source, changed: false };
|
||||
|
||||
// Locate an existing page-level `apis { }` block, if any.
|
||||
const apisMatch = /\bapis\s*\{/.exec(source);
|
||||
let existingApis: { bodyStart: number; bodyEnd: number } | null = null;
|
||||
if (apisMatch) {
|
||||
const open = apisMatch.index + apisMatch[0].length - 1;
|
||||
const close = findMatching(source, open);
|
||||
if (close < 0) throw new Error(`unterminated "apis {" block at offset ${apisMatch.index}`);
|
||||
existingApis = { bodyStart: open + 1, bodyEnd: close };
|
||||
}
|
||||
|
||||
const designatedBlock = blocks.find((block) => perBlockEntries.has(block));
|
||||
|
||||
const edits: Edit[] = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
const entries = perBlockEntries.get(block);
|
||||
if (!entries) continue; // block had no api entries; leave it untouched entirely
|
||||
|
||||
const body = source.slice(block.bodyStart, block.bodyEnd);
|
||||
let remainder = body;
|
||||
for (const entry of [...entries].reverse()) {
|
||||
remainder = remainder.slice(0, entry.localStart) + remainder.slice(entry.localEnd);
|
||||
}
|
||||
if (remainder.trim() !== "") {
|
||||
// A bare `ssr { … }` / `client { … }` block is no longer valid syntax at
|
||||
// all once its api entries are gone -- only "state" and hydrate forms
|
||||
// survive. Leftover content (e.g. `functions { }`) belongs to the
|
||||
// move-mode-functions migration; migrating api entries alone here would
|
||||
// strand it inside a block shape the parser rejects. Skip rather than
|
||||
// partially rewrite.
|
||||
return {
|
||||
skip: `${block.mode} { … } mixes api entries with other content (e.g. functions) that must be migrated first`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!existingApis && block === designatedBlock) {
|
||||
const apisBody = allEntries.map((entry) => ` ${entry.text}`).join("\n\n");
|
||||
const apisBlockText = `apis {\n${apisBody}\n }`;
|
||||
edits.push({ start: block.start, end: block.end, replacement: apisBlockText });
|
||||
} else {
|
||||
edits.push({ start: block.start, end: block.end, replacement: "" });
|
||||
}
|
||||
}
|
||||
|
||||
if (existingApis) {
|
||||
const existingBody = source.slice(existingApis.bodyStart, existingApis.bodyEnd);
|
||||
const additions = allEntries.map((entry) => ` ${entry.text}`).join("\n\n");
|
||||
const mergedBody = `${existingBody.replace(/\s*$/, "")}\n\n${additions}\n `;
|
||||
edits.push({
|
||||
start: existingApis.bodyStart,
|
||||
end: existingApis.bodyEnd,
|
||||
replacement: mergedBody,
|
||||
});
|
||||
}
|
||||
|
||||
let after = applyEdits(source, edits);
|
||||
after = after.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n");
|
||||
|
||||
if (after === source) return { source, changed: false };
|
||||
|
||||
try {
|
||||
parse(after);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`apis-block migration produced source that failed to parse: ${message}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
return { source: after, changed: true };
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Detection (not migration) for legacy bare-body `api` entries.
|
||||
*
|
||||
* A legacy bare `api` body — one with no `request { }` / `response { }` /
|
||||
* `error { }` sections — is evaluated inside `with ($data ?? {})`, so it
|
||||
* references payload fields as bare identifiers. Converting
|
||||
* `return userNames(users)` correctly needs `data.users`, but nothing in the
|
||||
* source distinguishes `users` (a payload field) from `userNames` (a page
|
||||
* helper) — the response shape belongs to the route, which may not be typed.
|
||||
* A migration that guessed would emit code that compiles and is silently
|
||||
* wrong, so this module deliberately reports free identifiers rather than
|
||||
* rewriting anything.
|
||||
*
|
||||
* Like `apis-block.ts` / `mode-functions.ts`, this is a text-level scan, not
|
||||
* a parse-and-rewrite: the `@wrnexus/syntax` parser THROWS on `ssr { … }` /
|
||||
* `client { … }` data blocks, which is exactly the legacy syntax this module
|
||||
* reads. It uses the same balanced-brace technique (`findMatching`) used
|
||||
* throughout `update.ts`.
|
||||
*/
|
||||
|
||||
/** Balanced-brace scan: returns the index of the brace matching `open`, or -1. */
|
||||
function findMatching(source: string, open: number, openChar = "{", closeChar = "}"): number {
|
||||
let depth = 0;
|
||||
let quote = "";
|
||||
for (let index = open; index < source.length; index++) {
|
||||
const char = source[index]!;
|
||||
if (quote) {
|
||||
if (char === "\\") index++;
|
||||
else if (char === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'" || char === "`") quote = char;
|
||||
else if (char === openChar) depth++;
|
||||
else if (char === closeChar && --depth === 0) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
interface ModeBlock {
|
||||
mode: "ssr" | "client";
|
||||
start: number;
|
||||
end: number;
|
||||
bodyStart: number;
|
||||
bodyEnd: number;
|
||||
}
|
||||
|
||||
/** Find page-level `ssr { … }` / `client { … }` data blocks (not `state`/hydrate forms). */
|
||||
function findModeBlocks(source: string): ModeBlock[] {
|
||||
const blocks: ModeBlock[] = [];
|
||||
const header = /\b(ssr|client)\s*\{/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = header.exec(source))) {
|
||||
const open = match.index + match[0].length - 1;
|
||||
const close = findMatching(source, open);
|
||||
if (close < 0) {
|
||||
throw new Error(`unterminated "${match[1]} {" block at offset ${match.index}`);
|
||||
}
|
||||
blocks.push({
|
||||
mode: match[1] as "ssr" | "client",
|
||||
start: match.index,
|
||||
end: close + 1,
|
||||
bodyStart: open + 1,
|
||||
bodyEnd: close,
|
||||
});
|
||||
header.lastIndex = close + 1;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
interface LegacyApiEntry {
|
||||
name: string;
|
||||
/** True when the entry declares request/response/error sections. */
|
||||
hasSections: boolean;
|
||||
/** The entry's body text (between its outer braces). */
|
||||
bodyText: string;
|
||||
}
|
||||
|
||||
/** Find `api <name> <METHOD> <path> { … }` entries inside a mode-block body. */
|
||||
function findApiEntries(body: string): LegacyApiEntry[] {
|
||||
const entries: LegacyApiEntry[] = [];
|
||||
const header = /\bapi\s+([A-Za-z_$][\w$]*)\s+([A-Za-z]+)\s+(\S+?)\s*\{/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = header.exec(body))) {
|
||||
const open = match.index + match[0].length - 1;
|
||||
const close = findMatching(body, open);
|
||||
if (close < 0) {
|
||||
throw new Error(`unterminated api entry '${match[1]}' at offset ${match.index}`);
|
||||
}
|
||||
const bodyText = body.slice(open + 1, close);
|
||||
const maskedBodyText = maskLiteralsAndComments(bodyText);
|
||||
const hasSections = /\b(request|response|error)\s*\{/.test(maskedBodyText);
|
||||
entries.push({ name: match[1]!, hasSections, bodyText });
|
||||
header.lastIndex = close + 1;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Blank out string/template literal contents and comments, preserving length. */
|
||||
function maskLiteralsAndComments(source: string): string {
|
||||
let out = "";
|
||||
let index = 0;
|
||||
while (index < source.length) {
|
||||
const char = source[index]!;
|
||||
if (char === '"' || char === "'" || char === "`") {
|
||||
const quote = char;
|
||||
let end = index + 1;
|
||||
while (end < source.length) {
|
||||
if (source[end] === "\\") {
|
||||
end += 2;
|
||||
continue;
|
||||
}
|
||||
if (source[end] === quote) {
|
||||
end++;
|
||||
break;
|
||||
}
|
||||
end++;
|
||||
}
|
||||
out += " ".repeat(end - index);
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && source[index + 1] === "/") {
|
||||
let end = index;
|
||||
while (end < source.length && source[end] !== "\n") end++;
|
||||
out += " ".repeat(end - index);
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && source[index + 1] === "*") {
|
||||
let end = source.indexOf("*/", index + 2);
|
||||
end = end < 0 ? source.length : end + 2;
|
||||
out += source.slice(index, end).replace(/[^\n]/g, " ");
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
out += char;
|
||||
index++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const JS_KEYWORDS = new Set([
|
||||
"break",
|
||||
"case",
|
||||
"catch",
|
||||
"class",
|
||||
"const",
|
||||
"continue",
|
||||
"debugger",
|
||||
"default",
|
||||
"delete",
|
||||
"do",
|
||||
"else",
|
||||
"enum",
|
||||
"export",
|
||||
"extends",
|
||||
"false",
|
||||
"finally",
|
||||
"for",
|
||||
"function",
|
||||
"if",
|
||||
"implements",
|
||||
"import",
|
||||
"in",
|
||||
"instanceof",
|
||||
"interface",
|
||||
"let",
|
||||
"new",
|
||||
"null",
|
||||
"of",
|
||||
"package",
|
||||
"private",
|
||||
"protected",
|
||||
"public",
|
||||
"return",
|
||||
"static",
|
||||
"super",
|
||||
"switch",
|
||||
"this",
|
||||
"throw",
|
||||
"true",
|
||||
"try",
|
||||
"typeof",
|
||||
"undefined",
|
||||
"var",
|
||||
"void",
|
||||
"while",
|
||||
"with",
|
||||
"yield",
|
||||
"async",
|
||||
"await",
|
||||
"get",
|
||||
"set",
|
||||
]);
|
||||
|
||||
const JS_GLOBALS = new Set([
|
||||
"Math",
|
||||
"JSON",
|
||||
"Object",
|
||||
"Array",
|
||||
"String",
|
||||
"Number",
|
||||
"Boolean",
|
||||
"Date",
|
||||
"RegExp",
|
||||
"Map",
|
||||
"Set",
|
||||
"Promise",
|
||||
"Error",
|
||||
"TypeError",
|
||||
"RangeError",
|
||||
"SyntaxError",
|
||||
"EvalError",
|
||||
"ReferenceError",
|
||||
"URIError",
|
||||
"console",
|
||||
"NaN",
|
||||
"Infinity",
|
||||
"globalThis",
|
||||
"Symbol",
|
||||
"WeakMap",
|
||||
"WeakSet",
|
||||
"Proxy",
|
||||
"Reflect",
|
||||
"parseInt",
|
||||
"parseFloat",
|
||||
"isNaN",
|
||||
"isFinite",
|
||||
"encodeURIComponent",
|
||||
"decodeURIComponent",
|
||||
"encodeURI",
|
||||
"decodeURI",
|
||||
"structuredClone",
|
||||
"BigInt",
|
||||
"ArrayBuffer",
|
||||
"Int8Array",
|
||||
"Uint8Array",
|
||||
"Int16Array",
|
||||
"Uint16Array",
|
||||
"Int32Array",
|
||||
"Uint32Array",
|
||||
"Float32Array",
|
||||
"Float64Array",
|
||||
"DataView",
|
||||
]);
|
||||
|
||||
/** Identifiers that name a param, are function names, or are declared via const/let/var/catch. */
|
||||
function collectDeclaredNames(masked: string): Set<string> {
|
||||
const names = new Set<string>();
|
||||
|
||||
for (const m of masked.matchAll(/\bfunction\s*(?:\*\s*)?([A-Za-z_$][\w$]*)?\s*\(([^)]*)\)/g)) {
|
||||
if (m[1]) names.add(m[1]);
|
||||
for (const name of extractIdentifiers(m[2] ?? "")) names.add(name);
|
||||
}
|
||||
|
||||
for (const m of masked.matchAll(/\(([^)]*)\)\s*=>/g)) {
|
||||
for (const name of extractIdentifiers(m[1] ?? "")) names.add(name);
|
||||
}
|
||||
|
||||
for (const m of masked.matchAll(/(?:^|[^\w$.])([A-Za-z_$][\w$]*)\s*=>/g)) {
|
||||
names.add(m[1]!);
|
||||
}
|
||||
|
||||
for (const m of masked.matchAll(/\b(?:const|let|var)\s+([^;\n]+)/g)) {
|
||||
for (const part of m[1]!.split(",")) {
|
||||
const declarator = part.split("=")[0]!;
|
||||
for (const name of extractIdentifiers(declarator)) names.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const m of masked.matchAll(/\bcatch\s*\(\s*([A-Za-z_$][\w$]*)\s*\)/g)) {
|
||||
names.add(m[1]!);
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
function extractIdentifiers(text: string): string[] {
|
||||
return Array.from(text.matchAll(/[A-Za-z_$][\w$]*/g), (m) => m[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifiers a bare `api` body references that are neither declared locally
|
||||
* (params, `const`/`let`/`var`, function names, catch bindings) nor
|
||||
* JavaScript globals. Property-access keys (`u.name` → `u`, not `name`) and
|
||||
* object-literal keys are excluded; string/template contents never reach the
|
||||
* scan at all.
|
||||
*/
|
||||
function freeIdentifiersOf(body: string): string[] {
|
||||
const masked = maskLiteralsAndComments(body);
|
||||
const declared = collectDeclaredNames(masked);
|
||||
const free = new Set<string>();
|
||||
const identifier = /[A-Za-z_$][\w$]*/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = identifier.exec(masked))) {
|
||||
const name = match[0];
|
||||
if (JS_KEYWORDS.has(name)) continue;
|
||||
|
||||
let before = match.index - 1;
|
||||
while (before >= 0 && /\s/.test(masked[before]!)) before--;
|
||||
if (before >= 0 && masked[before] === ".") continue; // property access
|
||||
|
||||
let after = match.index + name.length;
|
||||
while (after < masked.length && /\s/.test(masked[after]!)) after++;
|
||||
if (masked[after] === ":" && masked[after + 1] !== ":") {
|
||||
if (before >= 0 && (masked[before] === "{" || masked[before] === ",")) continue; // object key
|
||||
}
|
||||
|
||||
if (declared.has(name)) continue;
|
||||
if (JS_GLOBALS.has(name)) continue;
|
||||
free.add(name);
|
||||
}
|
||||
return [...free];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find legacy bare-body `api` entries — those with no `request`/`response`/
|
||||
* `error` sections — and report each one's name and free identifiers.
|
||||
* Writes nothing; the caller decides what to do with the report.
|
||||
*/
|
||||
export function detectLegacyApiBodies(
|
||||
source: string,
|
||||
): { name: string; freeIdentifiers: string[] }[] {
|
||||
const results: { name: string; freeIdentifiers: string[] }[] = [];
|
||||
for (const block of findModeBlocks(source)) {
|
||||
const body = source.slice(block.bodyStart, block.bodyEnd);
|
||||
for (const entry of findApiEntries(body)) {
|
||||
if (entry.hasSections) continue;
|
||||
results.push({ name: entry.name, freeIdentifiers: freeIdentifiersOf(entry.bodyText) });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Migration: move mode-scoped helpers out of legacy `ssr { functions { … } }`
|
||||
* / `client { functions { … } }` blocks into the page-level `functions { }`
|
||||
* block, tagged `shared`.
|
||||
*
|
||||
* IMPORTANT: this is a text-level transform, not a parse-and-rewrite, for the
|
||||
* same reason as `apis-block.ts`: the `@wrnexus/syntax` parser deliberately
|
||||
* THROWS a `ParseError` on `ssr { … }` / `client { … }` data blocks — that
|
||||
* legacy syntax is exactly what this migration exists to read, so it cannot
|
||||
* be reached by parsing first. Instead this scans the source with the same
|
||||
* balanced-brace technique used throughout `update.ts` (`findMatching`), and
|
||||
* only parses the RESULT, as a validity check.
|
||||
*/
|
||||
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
|
||||
/** Balanced-brace scan: returns the index of the brace matching `open`, or -1. */
|
||||
function findMatching(source: string, open: number, openChar = "{", closeChar = "}"): number {
|
||||
let depth = 0;
|
||||
let quote = "";
|
||||
for (let index = open; index < source.length; index++) {
|
||||
const char = source[index]!;
|
||||
if (quote) {
|
||||
if (char === "\\") index++;
|
||||
else if (char === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'" || char === "`") quote = char;
|
||||
else if (char === openChar) depth++;
|
||||
else if (char === closeChar && --depth === 0) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Net `{` minus `}` outside string literals -- a splice that loses a brace changes it. */
|
||||
function braceBalance(source: string): number {
|
||||
let balance = 0;
|
||||
let quote = "";
|
||||
for (let index = 0; index < source.length; index++) {
|
||||
const char = source[index]!;
|
||||
if (quote) {
|
||||
if (char === "\\") index++;
|
||||
else if (char === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'" || char === "`") quote = char;
|
||||
else if (char === "{") balance++;
|
||||
else if (char === "}") balance--;
|
||||
}
|
||||
return balance;
|
||||
}
|
||||
|
||||
interface ModeBlock {
|
||||
mode: "ssr" | "client";
|
||||
/** Start of the `ssr`/`client` keyword. */
|
||||
start: number;
|
||||
/** One past the block's closing `}`. */
|
||||
end: number;
|
||||
bodyStart: number;
|
||||
bodyEnd: number;
|
||||
}
|
||||
|
||||
/** Find page-level `ssr { … }` / `client { … }` data blocks (not `state`/hydrate forms). */
|
||||
function findModeBlocks(source: string): ModeBlock[] {
|
||||
const blocks: ModeBlock[] = [];
|
||||
const header = /\b(ssr|client)\s*\{/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = header.exec(source))) {
|
||||
const open = match.index + match[0].length - 1;
|
||||
const close = findMatching(source, open);
|
||||
if (close < 0) {
|
||||
throw new Error(`unterminated "${match[1]} {" block at offset ${match.index}`);
|
||||
}
|
||||
blocks.push({
|
||||
mode: match[1] as "ssr" | "client",
|
||||
start: match.index,
|
||||
end: close + 1,
|
||||
bodyStart: open + 1,
|
||||
bodyEnd: close,
|
||||
});
|
||||
header.lastIndex = close + 1;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
interface FunctionsSubBlock {
|
||||
/** Offsets relative to the mode block's body. */
|
||||
localStart: number;
|
||||
localEnd: number;
|
||||
bodyStart: number;
|
||||
bodyEnd: number;
|
||||
}
|
||||
|
||||
/** Find `functions { … }` sub-blocks inside a mode-block body. */
|
||||
function findFunctionsSubBlocks(body: string): FunctionsSubBlock[] {
|
||||
const blocks: FunctionsSubBlock[] = [];
|
||||
const header = /\bfunctions\s*\{/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = header.exec(body))) {
|
||||
const open = match.index + match[0].length - 1;
|
||||
const close = findMatching(body, open);
|
||||
if (close < 0) {
|
||||
throw new Error(`unterminated "functions {" block at offset ${match.index}`);
|
||||
}
|
||||
blocks.push({
|
||||
localStart: match.index,
|
||||
localEnd: close + 1,
|
||||
bodyStart: open + 1,
|
||||
bodyEnd: close,
|
||||
});
|
||||
header.lastIndex = close + 1;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
interface FunctionEntry {
|
||||
name: string;
|
||||
/** `function name(...) { ... }` text, with any leading runtime keyword stripped. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Find `[client|server|shared] function <name>(...) { ... }` entries inside a functions-block body. */
|
||||
function findFunctionEntries(body: string): FunctionEntry[] {
|
||||
const entries: FunctionEntry[] = [];
|
||||
const header = /\b(?:(?:client|server|shared)\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = header.exec(body))) {
|
||||
const parenOpen = match.index + match[0].length - 1;
|
||||
const parenClose = findMatching(body, parenOpen, "(", ")");
|
||||
if (parenClose < 0) {
|
||||
throw new Error(`unterminated function parameter list at offset ${match.index}`);
|
||||
}
|
||||
const braceOpen = body.indexOf("{", parenClose + 1);
|
||||
if (braceOpen < 0) {
|
||||
throw new Error(`unterminated function body at offset ${match.index}`);
|
||||
}
|
||||
const braceClose = findMatching(body, braceOpen);
|
||||
if (braceClose < 0) {
|
||||
throw new Error(`unterminated function body at offset ${match.index}`);
|
||||
}
|
||||
const localStart = match.index;
|
||||
const localEnd = braceClose + 1;
|
||||
const text = body.slice(localStart, localEnd).replace(/^(client|server|shared)\s+/, "");
|
||||
entries.push({ name: match[1]!, text });
|
||||
header.lastIndex = localEnd;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
interface Edit {
|
||||
start: number;
|
||||
end: number;
|
||||
replacement: string;
|
||||
}
|
||||
|
||||
function applyEdits(source: string, edits: Edit[]): string {
|
||||
const sorted = [...edits].sort((a, b) => a.start - b.start);
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
for (const edit of sorted) {
|
||||
output += source.slice(cursor, edit.start) + edit.replacement;
|
||||
cursor = edit.end;
|
||||
}
|
||||
output += source.slice(cursor);
|
||||
return output;
|
||||
}
|
||||
|
||||
export function migrateModeFunctions(
|
||||
source: string,
|
||||
): { source: string; changed: boolean } | { skip: string } {
|
||||
const blocks = findModeBlocks(source);
|
||||
if (blocks.length === 0) return { source, changed: false };
|
||||
|
||||
const allEntries: FunctionEntry[] = [];
|
||||
const nameMode = new Map<string, "ssr" | "client">();
|
||||
const perBlockFuncBlocks = new Map<ModeBlock, FunctionsSubBlock[]>();
|
||||
|
||||
for (const block of blocks) {
|
||||
const body = source.slice(block.bodyStart, block.bodyEnd);
|
||||
const funcBlocks = findFunctionsSubBlocks(body);
|
||||
if (funcBlocks.length === 0) continue;
|
||||
|
||||
for (const funcBlock of funcBlocks) {
|
||||
const funcBody = body.slice(funcBlock.bodyStart, funcBlock.bodyEnd);
|
||||
for (const entry of findFunctionEntries(funcBody)) {
|
||||
const existingMode = nameMode.get(entry.name);
|
||||
if (existingMode && existingMode !== block.mode) {
|
||||
return {
|
||||
skip: `function '${entry.name}' is declared in both ssr and client — merge them by hand before this migration can run`,
|
||||
};
|
||||
}
|
||||
nameMode.set(entry.name, block.mode);
|
||||
allEntries.push(entry);
|
||||
}
|
||||
}
|
||||
perBlockFuncBlocks.set(block, funcBlocks);
|
||||
}
|
||||
|
||||
if (allEntries.length === 0) return { source, changed: false };
|
||||
|
||||
// Locate an existing page-level `functions { }` block, i.e. one that is not
|
||||
// inside any ssr/client mode block.
|
||||
const insideAMode = (index: number) =>
|
||||
blocks.some((block) => index >= block.start && index < block.end);
|
||||
|
||||
let existingFunctions: { bodyStart: number; bodyEnd: number } | null = null;
|
||||
{
|
||||
const header = /\bfunctions\s*\{/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = header.exec(source))) {
|
||||
if (insideAMode(match.index)) continue;
|
||||
const open = match.index + match[0].length - 1;
|
||||
const close = findMatching(source, open);
|
||||
if (close < 0) throw new Error(`unterminated "functions {" block at offset ${match.index}`);
|
||||
existingFunctions = { bodyStart: open + 1, bodyEnd: close };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (existingFunctions) {
|
||||
const existingBody = source.slice(existingFunctions.bodyStart, existingFunctions.bodyEnd);
|
||||
for (const entry of allEntries) {
|
||||
if (new RegExp(`\\bfunction\\s+${entry.name}\\b`).test(existingBody)) {
|
||||
return {
|
||||
skip: `'${entry.name}' already exists in the page-level functions block`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const designatedBlock = blocks.find((block) => perBlockFuncBlocks.has(block));
|
||||
const edits: Edit[] = [];
|
||||
// True when a mode block keeps non-function content (e.g. `api` entries)
|
||||
// after its functions are extracted. The grammar rejects any `ssr {}` /
|
||||
// `client {}` wrapper outright, regardless of what's inside, so such a
|
||||
// leftover wrapper cannot parse until move-api-blocks removes it in the
|
||||
// same `wrnexus update` run. Validating with `parse` here would therefore
|
||||
// always fail on a case this migration is specifically meant to unblock.
|
||||
let leavesModeWrapper = false;
|
||||
|
||||
for (const block of blocks) {
|
||||
const funcBlocks = perBlockFuncBlocks.get(block);
|
||||
if (!funcBlocks) continue; // block had no functions sub-block; leave it untouched entirely
|
||||
|
||||
const body = source.slice(block.bodyStart, block.bodyEnd);
|
||||
let remainder = body;
|
||||
for (const funcBlock of [...funcBlocks].reverse()) {
|
||||
remainder = remainder.slice(0, funcBlock.localStart) + remainder.slice(funcBlock.localEnd);
|
||||
}
|
||||
const isEmpty = remainder.trim() === "";
|
||||
if (!isEmpty) leavesModeWrapper = true;
|
||||
|
||||
if (block === designatedBlock && !existingFunctions) {
|
||||
const funcsBody = allEntries.map((entry) => ` shared ${entry.text}`).join("\n\n");
|
||||
const funcsBlockText = `functions {\n${funcsBody}\n }`;
|
||||
const replacement = isEmpty
|
||||
? funcsBlockText
|
||||
: `${funcsBlockText}\n\n ${block.mode} {${remainder}}`;
|
||||
edits.push({ start: block.start, end: block.end, replacement });
|
||||
} else {
|
||||
const replacement = isEmpty ? "" : `${block.mode} {${remainder}}`;
|
||||
edits.push({ start: block.start, end: block.end, replacement });
|
||||
}
|
||||
}
|
||||
|
||||
if (existingFunctions) {
|
||||
const existingBody = source.slice(existingFunctions.bodyStart, existingFunctions.bodyEnd);
|
||||
const additions = allEntries.map((entry) => ` shared ${entry.text}`).join("\n\n");
|
||||
const mergedBody = `${existingBody.replace(/\s*$/, "")}\n\n${additions}\n `;
|
||||
edits.push({
|
||||
start: existingFunctions.bodyStart,
|
||||
end: existingFunctions.bodyEnd,
|
||||
replacement: mergedBody,
|
||||
});
|
||||
}
|
||||
|
||||
let after = applyEdits(source, edits);
|
||||
after = after.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n");
|
||||
|
||||
if (after === source) return { source, changed: false };
|
||||
|
||||
if (leavesModeWrapper) {
|
||||
// The only path that writes without a parse check. A surviving `ssr {`/`client {`
|
||||
// wrapper still holding `api` entries is unparseable by design -- `move-api-blocks`
|
||||
// finishes the job later in the same run -- so `parse` cannot validate it here.
|
||||
// Brace balance is the one invariant still checkable, and it is what a bad splice
|
||||
// offset would break. Downstream nothing else would catch it: the parser rejects a
|
||||
// corrupted wrapper and an untouched one identically.
|
||||
if (braceBalance(after) !== braceBalance(source)) {
|
||||
throw new Error(
|
||||
"mode-functions migration unbalanced the source braces; refusing to write the file",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
parse(after);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`mode-functions migration produced source that failed to parse: ${message}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { source: after, changed: true };
|
||||
}
|
||||
+13
-12
@@ -138,19 +138,20 @@ function apiBlockAssertions(
|
||||
for (const block of page.ast.dataApis) {
|
||||
if (!block.sections) continue;
|
||||
|
||||
// ssr-mode sectioned blocks can never declare a `request` (they are
|
||||
// render-time only), so they always fall back to the empty-shape
|
||||
// `Record<string, never>` below. `keyof Record<string, never>` is
|
||||
// `string`, which makes the key-exactness arm of AssertAssignable
|
||||
// evaluate to `false` unconditionally and raises TS2344 on every such
|
||||
// block regardless of whether the block author did anything wrong.
|
||||
// We choose to skip emission for both (a) any non-client-mode block,
|
||||
// since it structurally can never have a request to check, and (b) any
|
||||
// block -- client included -- that has zero declared request fields,
|
||||
// since there is nothing to assert type-safety about. This is more
|
||||
// honest about intent than emitting a vacuous/always-failing check.
|
||||
// A block with zero declared request fields would fall back to the
|
||||
// empty-shape `Record<string, never>` below. `keyof Record<string,
|
||||
// never>` is `string`, which makes the key-exactness arm of
|
||||
// AssertAssignable evaluate to `false` unconditionally and raises
|
||||
// TS2344 on every such block regardless of whether the block author
|
||||
// did anything wrong. We skip emission for any block with zero
|
||||
// declared request fields, since there is nothing to assert
|
||||
// type-safety about. This applies regardless of mode: `ssr` blocks can
|
||||
// never declare a `request` and so are always caught by this same
|
||||
// check; mode-less (`any`) and `client` blocks can declare fields and
|
||||
// get an assertion whenever they do. This is more honest about intent
|
||||
// than emitting a vacuous/always-failing check.
|
||||
const fields = [...block.sections.parameters, ...block.sections.body];
|
||||
if (block.mode !== "client" || fields.length === 0) continue;
|
||||
if (fields.length === 0) continue;
|
||||
|
||||
if (!apiContracts.includes(JSON.stringify(block.path))) {
|
||||
console.warn(
|
||||
|
||||
+199
-1346
File diff suppressed because it is too large
Load Diff
+52
-73
@@ -9,9 +9,8 @@ afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Minimal app with one typed endpoint and one page that calls it. */
|
||||
function fixture(block: string): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-"));
|
||||
function fixture(apisBlock: string): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-apis-types-"));
|
||||
roots.push(root);
|
||||
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||
mkdirSync(join(root, "app/api"), { recursive: true });
|
||||
@@ -21,26 +20,38 @@ function fixture(block: string): string {
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app/pages/search.wrn"),
|
||||
`page Search {\n client {\n${block}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||
`page Search {\n apis {\n${apisBlock}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
const BLOCK = ` api searchUsers POST /api/users {
|
||||
request {
|
||||
body {
|
||||
name?: string
|
||||
age?: number
|
||||
}
|
||||
}
|
||||
test("a mode-less block with declared fields gets an assertion", () => {
|
||||
const root = fixture(` searchUsers POST /api/users {
|
||||
request { body { name?: string } }
|
||||
response { return data.users }
|
||||
}`);
|
||||
generateApplicationTypes(root);
|
||||
const generated = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
|
||||
response {
|
||||
return data.users
|
||||
}
|
||||
}`;
|
||||
expect(generated).toContain("searchUsers");
|
||||
expect(generated).toContain('ApiInput<"/api/users", "POST">');
|
||||
});
|
||||
|
||||
test("a block with no declared fields gets no assertion", () => {
|
||||
const root = fixture(` listAll GET /api/users {
|
||||
response { return data.users }
|
||||
}`);
|
||||
generateApplicationTypes(root);
|
||||
const generated = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
|
||||
expect(generated).not.toContain("listAll");
|
||||
});
|
||||
|
||||
test("emits the ApiInput, ApiOutput and AssertAssignable helpers", () => {
|
||||
const root = fixture(BLOCK);
|
||||
const root = fixture(` searchUsers POST /api/users {
|
||||
request { body { name?: string; age?: number } }
|
||||
response { return data.users }
|
||||
}`);
|
||||
generateApplicationTypes(root);
|
||||
const generated = readFileSync(join(root, "app/types/wrnexus.generated.d.ts"), "utf8");
|
||||
|
||||
@@ -56,7 +67,10 @@ test("emits the ApiInput, ApiOutput and AssertAssignable helpers", () => {
|
||||
// never actually enforce anything here. A real .ts file under app/ is compiled and
|
||||
// checked normally.
|
||||
test("emits one assertion per sectioned block, naming its route and method", () => {
|
||||
const root = fixture(BLOCK);
|
||||
const root = fixture(` searchUsers POST /api/users {
|
||||
request { body { name?: string; age?: number } }
|
||||
response { return data.users }
|
||||
}`);
|
||||
generateApplicationTypes(root);
|
||||
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
|
||||
@@ -66,18 +80,11 @@ test("emits one assertion per sectioned block, naming its route and method", ()
|
||||
expect(checks).toContain("age?: number");
|
||||
});
|
||||
|
||||
test("a legacy bare-body block produces no assertion", () => {
|
||||
const root = fixture(` api legacyUsers GET /api/users {
|
||||
return users.length
|
||||
}`);
|
||||
generateApplicationTypes(root);
|
||||
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
|
||||
expect(checks).not.toContain("__wrn_api_check_legacyUsers");
|
||||
});
|
||||
|
||||
test("the api-checks file has no runtime code and is a module", () => {
|
||||
const root = fixture(BLOCK);
|
||||
const root = fixture(` searchUsers POST /api/users {
|
||||
request { body { name?: string } }
|
||||
response { return data.users }
|
||||
}`);
|
||||
generateApplicationTypes(root);
|
||||
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
|
||||
@@ -86,7 +93,7 @@ test("the api-checks file has no runtime code and is a module", () => {
|
||||
});
|
||||
|
||||
test("B1: two pages each declaring a block with the same name do not collide", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-collide-"));
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-apis-types-collide-"));
|
||||
roots.push(root);
|
||||
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||
mkdirSync(join(root, "app/api"), { recursive: true });
|
||||
@@ -94,13 +101,17 @@ test("B1: two pages each declaring a block with the same name do not collide", (
|
||||
join(root, "app/api/users.ts"),
|
||||
`export const POST = async () => Response.json({ users: [] });\n`,
|
||||
);
|
||||
const block = ` searchUsers POST /api/users {
|
||||
request { body { name?: string } }
|
||||
response { return data.users }
|
||||
}`;
|
||||
writeFileSync(
|
||||
join(root, "app/pages/one.wrn"),
|
||||
`page One {\n client {\n${BLOCK}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||
`page One {\n apis {\n${block}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app/pages/two.wrn"),
|
||||
`page Two {\n client {\n${BLOCK}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||
`page Two {\n apis {\n${block}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||
);
|
||||
|
||||
generateApplicationTypes(root);
|
||||
@@ -111,43 +122,11 @@ test("B1: two pages each declaring a block with the same name do not collide", (
|
||||
expect(new Set(names).size).toBe(2);
|
||||
});
|
||||
|
||||
test("B2: an ssr sectioned block emits no assertion (it can never declare a request)", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-ssr-"));
|
||||
roots.push(root);
|
||||
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||
mkdirSync(join(root, "app/api"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "app/api/users.ts"),
|
||||
`export const GET = async () => Response.json({ users: [] });\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app/pages/ssr.wrn"),
|
||||
`page Ssr {\n ssr {\n api loadUsers GET /api/users {\n response {\n return data.users\n }\n }\n }\n\n view { <main>x</main> }\n}\n`,
|
||||
);
|
||||
|
||||
generateApplicationTypes(root);
|
||||
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
|
||||
expect(checks).not.toContain("__wrn_api_check");
|
||||
expect(checks).not.toContain("loadUsers");
|
||||
});
|
||||
|
||||
test("B2: a client block with an empty request emits no assertion", () => {
|
||||
const root = fixture(` api pingServer GET /api/users {
|
||||
response {
|
||||
return data.users
|
||||
}
|
||||
}`);
|
||||
|
||||
generateApplicationTypes(root);
|
||||
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
|
||||
expect(checks).not.toContain("__wrn_api_check");
|
||||
expect(checks).not.toContain("pingServer");
|
||||
});
|
||||
|
||||
test("B6: each emitted assertion is exported, so noUnusedLocals cannot flag it", () => {
|
||||
const root = fixture(BLOCK);
|
||||
const root = fixture(` searchUsers POST /api/users {
|
||||
request { body { name?: string } }
|
||||
response { return data.users }
|
||||
}`);
|
||||
generateApplicationTypes(root);
|
||||
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
|
||||
@@ -172,7 +151,7 @@ test("B6: each emitted assertion is exported, so noUnusedLocals cannot flag it",
|
||||
// `unknown` — with `unknown`, `AssertAssignable`'s untyped-route bypass means nothing
|
||||
// could ever fail, which would make these tests meaningless.
|
||||
function typedFixture(block: string): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-tsc-"));
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-apis-types-tsc-"));
|
||||
roots.push(root);
|
||||
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||
mkdirSync(join(root, "app/api"), { recursive: true });
|
||||
@@ -182,7 +161,7 @@ function typedFixture(block: string): string {
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app/pages/search.wrn"),
|
||||
`page Search {\n client {\n${block}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||
`page Search {\n apis {\n${block}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||
);
|
||||
return root;
|
||||
}
|
||||
@@ -217,7 +196,7 @@ function typecheckGenerated(root: string): { ok: boolean; output: string } {
|
||||
return { ok: result.exitCode === 0, output };
|
||||
}
|
||||
|
||||
const MATCHING_BLOCK = ` api searchUsers POST /api/users {
|
||||
const MATCHING_BLOCK = ` searchUsers POST /api/users {
|
||||
request {
|
||||
body {
|
||||
name: string
|
||||
@@ -240,7 +219,7 @@ test("tsc: a block whose fields match the contract has no diagnostics", () => {
|
||||
});
|
||||
|
||||
test("tsc: a field with the wrong type fails, naming the block's assertion", () => {
|
||||
const root = typedFixture(` api searchUsers POST /api/users {
|
||||
const root = typedFixture(` searchUsers POST /api/users {
|
||||
request {
|
||||
body {
|
||||
name: number
|
||||
@@ -263,7 +242,7 @@ test("tsc: a field with the wrong type fails, naming the block's assertion", ()
|
||||
});
|
||||
|
||||
test("tsc: an extra field the contract does not accept fails (Finding A regression guard)", () => {
|
||||
const root = typedFixture(` api searchUsers POST /api/users {
|
||||
const root = typedFixture(` searchUsers POST /api/users {
|
||||
request {
|
||||
body {
|
||||
name: string
|
||||
@@ -287,7 +266,7 @@ test("tsc: an extra field the contract does not accept fails (Finding A regressi
|
||||
});
|
||||
|
||||
test("tsc: a missing required field fails", () => {
|
||||
const root = typedFixture(` api searchUsers POST /api/users {
|
||||
const root = typedFixture(` searchUsers POST /api/users {
|
||||
request {
|
||||
body {
|
||||
name: string
|
||||
@@ -1,17 +0,0 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { compatibilityReport, upgradeCompatibility } from "../src/compatibility-command.ts";
|
||||
|
||||
test("compatibility upgrade is backed up, current, and idempotent", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-compatibility-"));
|
||||
const file = join(root, "wrnexus.config.ts");
|
||||
writeFileSync(file, `export default { port: 3000 };\n`);
|
||||
const first = upgradeCompatibility(root);
|
||||
expect(first.changed).toBe(true);
|
||||
expect(readFileSync(first.backup, "utf8")).toContain("port: 3000");
|
||||
expect(readFileSync(file, "utf8")).toContain('compatibilityDate: "2026-08-02"');
|
||||
expect(upgradeCompatibility(root).changed).toBe(false);
|
||||
expect((await compatibilityReport(root)).needsUpgrade).toBe(false);
|
||||
});
|
||||
@@ -96,7 +96,6 @@ test("scaffoldApp includes the complete v0.8 configuration and starter structure
|
||||
"imports:",
|
||||
"types:",
|
||||
"stores:",
|
||||
"compatibility:",
|
||||
"performance:",
|
||||
"observability:",
|
||||
"tenancy:",
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { migrateApisBlock } from "../src/migrations/apis-block.ts";
|
||||
|
||||
const SOURCE = `page Search {
|
||||
client {
|
||||
api searchUsers POST /api/users {
|
||||
request { body { name?: string } }
|
||||
response { return data.users }
|
||||
error { return [] }
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
test("a client api entry moves into an apis block", () => {
|
||||
const result = migrateApisBlock(SOURCE) as { source: string; changed: boolean };
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.source).toContain("apis {");
|
||||
expect(result.source).toContain("searchUsers POST /api/users");
|
||||
expect(result.source).not.toContain("client {\n api");
|
||||
});
|
||||
|
||||
test("the sections survive unchanged", () => {
|
||||
const result = migrateApisBlock(SOURCE) as { source: string };
|
||||
|
||||
expect(result.source).toContain("return data.users");
|
||||
expect(result.source).toContain("return []");
|
||||
});
|
||||
|
||||
test("running it on migrated source changes nothing", () => {
|
||||
const once = (migrateApisBlock(SOURCE) as { source: string }).source;
|
||||
const twice = migrateApisBlock(once) as { source: string; changed: boolean };
|
||||
|
||||
expect(twice.changed).toBe(false);
|
||||
expect(twice.source).toBe(once);
|
||||
});
|
||||
|
||||
test("a name declared in both modes is skipped with a reason", () => {
|
||||
const clash = `page P {
|
||||
ssr { api dup GET /api/a { response { return data } } }
|
||||
client { api dup GET /api/a { response { return data } } }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
const result = migrateApisBlock(clash) as { skip: string };
|
||||
|
||||
expect(result.skip).toContain("dup");
|
||||
});
|
||||
|
||||
test("a page with an existing apis block merges legacy entries instead of producing two apis blocks", () => {
|
||||
const source = `page P {
|
||||
apis {
|
||||
existing GET /api/existing {
|
||||
response { return data }
|
||||
}
|
||||
}
|
||||
|
||||
client {
|
||||
api added POST /api/added {
|
||||
response { return data.value }
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
const result = migrateApisBlock(source) as { source: string; changed: boolean };
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
const apisCount = (result.source.match(/\bapis\s*\{/g) ?? []).length;
|
||||
expect(apisCount).toBe(1);
|
||||
expect(result.source).toContain("existing GET /api/existing");
|
||||
expect(result.source).toContain("added POST /api/added");
|
||||
expect(result.source).not.toContain("client {");
|
||||
});
|
||||
|
||||
test("a mode block mixing api entries with other content is skipped, not partially rewritten", () => {
|
||||
// A bare `ssr { … }` block is invalid syntax once api entries are removed
|
||||
// (only `state` and hydrate forms survive) -- leftover content such as
|
||||
// `functions { }` belongs to the move-mode-functions migration, so this
|
||||
// file cannot be completed by this migration alone.
|
||||
const source = `page P {
|
||||
ssr {
|
||||
api foo GET /api/foo {
|
||||
response { return data }
|
||||
}
|
||||
|
||||
functions {
|
||||
function helper() { return 1 }
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
const result = migrateApisBlock(source) as { skip: string };
|
||||
|
||||
expect(result.skip).toContain("ssr");
|
||||
expect(result.skip).toContain("functions");
|
||||
});
|
||||
|
||||
test("a legacy bare-body api entry is left alone instead of producing a bogus parse failure", () => {
|
||||
// No request/response/error sections -- this is the legacy bare-body form
|
||||
// that `report-legacy-api-bodies` handles for manual review. Folding it
|
||||
// into `apis { }` as-is would produce source the grammar rejects, so this
|
||||
// migration must leave it untouched rather than attempt the rewrite.
|
||||
const source = `page Hello {
|
||||
ssr {
|
||||
api ssrUsers GET /api/users/ssr {
|
||||
return userNames(users)
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
const result = migrateApisBlock(source) as { source: string; changed: boolean };
|
||||
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.source).toBe(source);
|
||||
});
|
||||
|
||||
test("a brace inside a string literal in a section body does not truncate the entry", () => {
|
||||
const source = `page P {
|
||||
client {
|
||||
api foo GET /api/foo {
|
||||
response { return { text: "a } b" } }
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
const result = migrateApisBlock(source) as { source: string; changed: boolean };
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.source).toContain('text: "a } b"');
|
||||
expect(result.source).toContain("apis {");
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { updateApp } from "../src/update.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const CONFIG = `export default {
|
||||
compatibilityDate: "2026-08-02",
|
||||
frameworkBehaviour: 1,
|
||||
functions: { legacyDefaultRuntime: "current" },
|
||||
compatibility: { legacyEmit: false, stringLayouts: false },
|
||||
observability: { sampleRate: 1 },
|
||||
};
|
||||
`;
|
||||
|
||||
const NESTED_CONFIG = `export default {
|
||||
compatibilityDate: "2026-08-02",
|
||||
frameworkBehaviour: 1,
|
||||
functions: { legacyDefaultRuntime: "current", overrides: { a: { b: 1 } } },
|
||||
compatibility: { legacyEmit: false, nested: { deeper: { value: true } } },
|
||||
observability: { sampleRate: 1 },
|
||||
};
|
||||
`;
|
||||
|
||||
function project(config = CONFIG): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-migrate-"));
|
||||
roots.push(root);
|
||||
mkdirSync(join(root, "app"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "config-migrate-app",
|
||||
dependencies: { "@wrnexus/core": "^0.8.0" },
|
||||
wrnexus: { version: "0.8.0" },
|
||||
}),
|
||||
);
|
||||
writeFileSync(join(root, "wrnexus.config.ts"), config);
|
||||
return root;
|
||||
}
|
||||
|
||||
test("the removed keys are deleted and the rest is kept", () => {
|
||||
const root = project();
|
||||
updateApp(root, "0.9.0", false);
|
||||
const config = readFileSync(join(root, "wrnexus.config.ts"), "utf8");
|
||||
|
||||
expect(config).not.toContain("compatibilityDate");
|
||||
expect(config).not.toContain("frameworkBehaviour");
|
||||
expect(config).not.toContain("legacyDefaultRuntime");
|
||||
expect(config).not.toContain("legacyEmit");
|
||||
expect(config).toContain("observability");
|
||||
});
|
||||
|
||||
test("running it twice changes nothing the second time", () => {
|
||||
const root = project();
|
||||
updateApp(root, "0.9.0", false);
|
||||
const once = readFileSync(join(root, "wrnexus.config.ts"), "utf8");
|
||||
updateApp(root, "0.9.0", false);
|
||||
|
||||
expect(readFileSync(join(root, "wrnexus.config.ts"), "utf8")).toBe(once);
|
||||
});
|
||||
|
||||
test("a dry run writes nothing", () => {
|
||||
const root = project();
|
||||
updateApp(root, "0.9.0", true);
|
||||
|
||||
expect(readFileSync(join(root, "wrnexus.config.ts"), "utf8")).toBe(CONFIG);
|
||||
});
|
||||
|
||||
test("a nested object value under a removed key does not corrupt the file", () => {
|
||||
const root = project(NESTED_CONFIG);
|
||||
updateApp(root, "0.9.0", false);
|
||||
const config = readFileSync(join(root, "wrnexus.config.ts"), "utf8");
|
||||
|
||||
expect(config).not.toContain("compatibilityDate");
|
||||
expect(config).not.toContain("frameworkBehaviour");
|
||||
expect(config).not.toContain("legacyDefaultRuntime");
|
||||
expect(config).not.toContain("legacyEmit");
|
||||
expect(config).not.toContain("overrides");
|
||||
expect(config).not.toContain("nested");
|
||||
expect(config).toContain("observability");
|
||||
|
||||
// The file must remain valid, balanced TypeScript: an equal number of
|
||||
// opening and closing braces, and it must still be parseable as a module.
|
||||
const opens = (config.match(/\{/g) ?? []).length;
|
||||
const closes = (config.match(/\}/g) ?? []).length;
|
||||
expect(opens).toBe(closes);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { detectLegacyApiBodies } from "../src/migrations/legacy-api-body.ts";
|
||||
import { updateApp } from "../src/update.ts";
|
||||
|
||||
const SOURCE = `page Hello {
|
||||
ssr {
|
||||
api ssrUsers GET /api/users/ssr {
|
||||
return userNames(users)
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
test("a legacy bare body is detected with its free identifiers", () => {
|
||||
const found = detectLegacyApiBodies(SOURCE);
|
||||
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]!.name).toBe("ssrUsers");
|
||||
expect(found[0]!.freeIdentifiers).toContain("users");
|
||||
expect(found[0]!.freeIdentifiers).toContain("userNames");
|
||||
});
|
||||
|
||||
test("a sectioned block is not reported", () => {
|
||||
const sectioned = `page P {
|
||||
apis { x GET /api/x { response { return data.users } } }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(detectLegacyApiBodies(sectioned)).toEqual([]);
|
||||
});
|
||||
|
||||
test("an identifier that only appears inside a string literal is not collected", () => {
|
||||
const source = `page Hello {
|
||||
ssr {
|
||||
api ssrUsers GET /api/users/ssr {
|
||||
return "users are great, ask userNames"
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
const found = detectLegacyApiBodies(source);
|
||||
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]!.freeIdentifiers).not.toContain("users");
|
||||
expect(found[0]!.freeIdentifiers).not.toContain("userNames");
|
||||
});
|
||||
|
||||
test("a property-access key is not collected, only its object", () => {
|
||||
const source = `page Hello {
|
||||
ssr {
|
||||
api ssrUsers GET /api/users/ssr {
|
||||
return u.name
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
const found = detectLegacyApiBodies(source);
|
||||
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]!.freeIdentifiers).toContain("u");
|
||||
expect(found[0]!.freeIdentifiers).not.toContain("name");
|
||||
});
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function project(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-migrate-legacy-api-body-"));
|
||||
roots.push(root);
|
||||
mkdirSync(join(root, "app"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "legacy-api-body-migrate-app",
|
||||
dependencies: { "@wrnexus/core": "^0.8.0" },
|
||||
wrnexus: { version: "0.8.0" },
|
||||
}),
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
test("a full update run leaves a legacy bare body file byte-identical and reports it", () => {
|
||||
const root = project();
|
||||
writeFileSync(join(root, "app", "Hello.wrn"), SOURCE);
|
||||
|
||||
const report = {
|
||||
changedAutomatically: [] as string[],
|
||||
needsReview: [] as string[],
|
||||
unresolvedImports: [] as string[],
|
||||
ambiguousFunctions: [] as string[],
|
||||
legacyOutputPayloads: [] as string[],
|
||||
parseFailures: [] as string[],
|
||||
};
|
||||
|
||||
updateApp(root, "0.9.0", false, { report });
|
||||
|
||||
const after = readFileSync(join(root, "app", "Hello.wrn"), "utf8");
|
||||
|
||||
expect(after).toBe(SOURCE);
|
||||
expect(report.needsReview.some((entry) => entry.includes("ssrUsers"))).toBe(true);
|
||||
expect(report.parseFailures).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { migrateModeFunctions } from "../src/migrations/mode-functions.ts";
|
||||
import { updateApp } from "../src/update.ts";
|
||||
|
||||
const SOURCE = `page Hello {
|
||||
ssr {
|
||||
functions {
|
||||
function userNames(users) {
|
||||
return users.map((user) => user.name).join(", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
test("a mode helper becomes a shared function", () => {
|
||||
const result = migrateModeFunctions(SOURCE) as { source: string; changed: boolean };
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.source).toContain("shared function userNames");
|
||||
expect(result.source).not.toContain("ssr {");
|
||||
});
|
||||
|
||||
test("running it again changes nothing", () => {
|
||||
const once = (migrateModeFunctions(SOURCE) as { source: string }).source;
|
||||
const twice = migrateModeFunctions(once) as { changed: boolean; source: string };
|
||||
|
||||
expect(twice.changed).toBe(false);
|
||||
expect(twice.source).toBe(once);
|
||||
});
|
||||
|
||||
test("a name that already exists at page level is skipped with a reason", () => {
|
||||
const clash = `page P {
|
||||
functions { shared function userNames() { return "" } }
|
||||
ssr { functions { function userNames(users) { return "" } } }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
const result = migrateModeFunctions(clash) as { skip: string };
|
||||
|
||||
expect(result.skip).toContain("userNames");
|
||||
});
|
||||
|
||||
test("a helper body containing a brace inside a string literal survives", () => {
|
||||
const source = `page Hello {
|
||||
ssr {
|
||||
functions {
|
||||
function label(user) {
|
||||
return user.name + " {tag}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
const result = migrateModeFunctions(source) as { source: string; changed: boolean };
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.source).toContain("shared function label");
|
||||
expect(result.source).toContain('" {tag}"');
|
||||
});
|
||||
|
||||
test("both ssr and client declaring the same helper name is skipped with a reason", () => {
|
||||
const clash = `page P {
|
||||
ssr { functions { function helper() { return 1 } } }
|
||||
client { functions { function helper() { return 2 } } }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
const result = migrateModeFunctions(clash) as { skip: string };
|
||||
|
||||
expect(result.skip).toContain("helper");
|
||||
});
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function project(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-migrate-mode-functions-"));
|
||||
roots.push(root);
|
||||
mkdirSync(join(root, "app"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "mode-functions-migrate-app",
|
||||
dependencies: { "@wrnexus/core": "^0.8.0" },
|
||||
wrnexus: { version: "0.8.0" },
|
||||
}),
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
test("one update run fully migrates a page mixing api entries and mode functions", () => {
|
||||
const root = project();
|
||||
writeFileSync(
|
||||
join(root, "app", "P.wrn"),
|
||||
`page P {
|
||||
ssr {
|
||||
api getUsers GET /api/users { response { return data } }
|
||||
functions { function label(u) { return u.name } }
|
||||
}
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const report = {
|
||||
changedAutomatically: [] as string[],
|
||||
needsReview: [] as string[],
|
||||
unresolvedImports: [] as string[],
|
||||
ambiguousFunctions: [] as string[],
|
||||
legacyOutputPayloads: [] as string[],
|
||||
parseFailures: [] as string[],
|
||||
};
|
||||
|
||||
updateApp(root, "0.9.0", false, { report });
|
||||
|
||||
const migrated = readFileSync(join(root, "app", "P.wrn"), "utf8");
|
||||
|
||||
expect(migrated).toContain("apis {");
|
||||
expect(migrated).toContain("shared function label");
|
||||
expect(migrated).not.toContain("ssr {");
|
||||
expect(report.needsReview).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { runUpdate } from "../src/update.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function project(name: string): string {
|
||||
const root = mkdtempSync(join(tmpdir(), `wrnexus-update-exit-${name}-`));
|
||||
roots.push(root);
|
||||
mkdirSync(join(root, "app"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({
|
||||
name: `update-exit-${name}`,
|
||||
dependencies: { "@wrnexus/core": "^0.8.0" },
|
||||
wrnexus: { version: "0.8.0" },
|
||||
}),
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* `runUpdate` reports failure via `process.exitCode` (never a real
|
||||
* `process.exit()` call) on this path -- see the existing `--delegated` and
|
||||
* verification-failure branches in `src/update.ts`. `--delegated` skips the
|
||||
* "fetch a newer published CLI" handoff (there is no published 0.9.0 yet),
|
||||
* matching how a real newer CLI re-invokes itself. `--dry-run` keeps the test
|
||||
* offline too: the dry-run path returns before `bun install`/verification
|
||||
* ever run, so no network access is needed to observe the exit code this
|
||||
* task adds.
|
||||
*/
|
||||
test("a project with a legacy bare body exits non-zero", async () => {
|
||||
const root = project("needs-review");
|
||||
writeFileSync(
|
||||
join(root, "app", "Hello.wrn"),
|
||||
`page Hello {
|
||||
ssr {
|
||||
api ssrUsers GET /api/users/ssr {
|
||||
return userNames(users)
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const before = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
try {
|
||||
await runUpdate(root, ["--dry-run", "--version=0.9.0", "--delegated"]);
|
||||
expect(process.exitCode).toBeTruthy();
|
||||
} finally {
|
||||
process.exitCode = before ?? 0;
|
||||
}
|
||||
});
|
||||
|
||||
test("a fully-migratable project exits zero", async () => {
|
||||
const root = project("clean");
|
||||
writeFileSync(
|
||||
join(root, "app", "Hello.wrn"),
|
||||
`page Hello {
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const before = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
try {
|
||||
await runUpdate(root, ["--dry-run", "--version=0.9.0", "--delegated"]);
|
||||
expect(process.exitCode ?? 0).toBe(0);
|
||||
} finally {
|
||||
process.exitCode = before ?? 0;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
test("no migration targets a version below 0.8.0", () => {
|
||||
const source = readFileSync(join(import.meta.dir, "../src/update.ts"), "utf8");
|
||||
const versions = [...source.matchAll(/version:\s*"([0-9.]+)"/g)].map((match) => match[1]!);
|
||||
|
||||
expect(versions.length).toBeGreaterThan(0);
|
||||
|
||||
const belowFloor = versions.filter((version) => {
|
||||
const [major, minor] = version.split(".").map(Number);
|
||||
return major! === 0 && minor! < 8;
|
||||
});
|
||||
|
||||
expect(belowFloor).toEqual([]);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
@@ -35,153 +35,6 @@ test("dependency updates use each package's independently published version", ()
|
||||
expect(changes).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("updateApp migrates project files without marking an unverified update complete", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-update-"));
|
||||
mkdirSync(join(root, "app", "pages"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "legacy-app",
|
||||
scripts: { dev: "wrnexus dev ." },
|
||||
dependencies: { "@wrnexus/core": "0.2.13" },
|
||||
wrnexus: { version: "0.2.13" },
|
||||
}),
|
||||
);
|
||||
writeFileSync(join(root, ".gitignore"), "node_modules/\n");
|
||||
|
||||
try {
|
||||
const result = updateApp(root, "0.2.14", false);
|
||||
expect(result).not.toBeNull();
|
||||
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
||||
expect(pkg.dependencies["@wrnexus/core"]).toBe("^0.2.14");
|
||||
expect(pkg.scripts.build).toBe("wrnexus build .");
|
||||
expect(pkg.scripts.start).toBe("bun dist/server.js");
|
||||
expect(pkg.scripts.production).toBe("bun run build && bun run start");
|
||||
expect(pkg.wrnexus.version).toBe("0.2.13");
|
||||
|
||||
const gitignore = readFileSync(join(root, ".gitignore"), "utf8");
|
||||
expect(gitignore).toContain("!.env.example");
|
||||
expect(gitignore).toContain("mobile/android/");
|
||||
expect(readFileSync(join(root, "public", "llms.txt"), "utf8")).toContain("# WrNexus");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("0.2.18 migration adds helpers to existing apps and is idempotent", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-update-"));
|
||||
mkdirSync(join(root, "app", "pages"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "existing-app",
|
||||
dependencies: { "@wrnexus/core": "^0.2.17" },
|
||||
wrnexus: { version: "0.2.17" },
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
updateApp(root, "0.2.18", false);
|
||||
updateApp(root, "0.2.18", false);
|
||||
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
||||
expect(pkg.dependencies["@wrnexus/helpers"]).toBe("^0.2.18");
|
||||
expect(
|
||||
Object.keys(pkg.dependencies).filter((name) => name === "@wrnexus/helpers"),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("0.2.70 migration pins the production runtime directly in runnable apps", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-update-"));
|
||||
mkdirSync(join(root, "app", "pages"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "existing-app",
|
||||
dependencies: { "@wrnexus/core": "^0.2.69" },
|
||||
wrnexus: { version: "0.2.69" },
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
updateApp(root, "0.2.70", false);
|
||||
updateApp(root, "0.2.70", false);
|
||||
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
||||
expect(pkg.dependencies["@wrnexus/dev-server"]).toBe("^0.2.70");
|
||||
expect(
|
||||
Object.keys(pkg.dependencies).filter((name) => name === "@wrnexus/dev-server"),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("0.2.19 migration adds VS Code formatting defaults without overwriting settings", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-update-"));
|
||||
mkdirSync(join(root, "app", "pages"), { recursive: true });
|
||||
mkdirSync(join(root, ".vscode"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({ name: "editor-app", wrnexus: { version: "0.2.18" } }),
|
||||
);
|
||||
writeFileSync(join(root, ".gitignore"), ".vscode/\n");
|
||||
writeFileSync(join(root, ".prettierignore"), "node_modules/\n");
|
||||
writeFileSync(join(root, ".vscode", "settings.json"), '{"editor.tabSize":4}\n');
|
||||
|
||||
try {
|
||||
updateApp(root, "0.2.19", false);
|
||||
updateApp(root, "0.2.19", false);
|
||||
|
||||
expect(readFileSync(join(root, ".vscode", "settings.json"), "utf8")).toBe(
|
||||
'{"editor.tabSize":4}\n',
|
||||
);
|
||||
expect(readFileSync(join(root, ".vscode", "extensions.json"), "utf8")).toContain(
|
||||
"wrnexus.wrnexus",
|
||||
);
|
||||
expect(readFileSync(join(root, ".vscode", "extensions.json"), "utf8")).toBe(
|
||||
'{\n "recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]\n}\n',
|
||||
);
|
||||
expect(readFileSync(join(root, ".prettierignore"), "utf8")).toBe("node_modules/\nCLAUDE.md\n");
|
||||
const gitignore = readFileSync(join(root, ".gitignore"), "utf8");
|
||||
expect(gitignore).not.toContain(".vscode/\n");
|
||||
expect(gitignore).toContain("!.vscode/settings.json");
|
||||
expect(gitignore).toContain("!.vscode/extensions.json");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("0.2.20 migration repairs only the legacy generated recommendations layout", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-update-"));
|
||||
mkdirSync(join(root, "app", "pages"), { recursive: true });
|
||||
mkdirSync(join(root, ".vscode"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({ name: "editor-app", wrnexus: { version: "0.2.19" } }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, ".vscode", "extensions.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
recommendations: ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
|
||||
try {
|
||||
updateApp(root, "0.2.20", false);
|
||||
expect(readFileSync(join(root, ".vscode", "extensions.json"), "utf8")).toBe(
|
||||
'{\n "recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]\n}\n',
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("update verification formats before checking and building", () => {
|
||||
expect(
|
||||
verificationCommands({
|
||||
@@ -200,30 +53,6 @@ test("update verification skips unavailable scripts", () => {
|
||||
expect(verificationCommands({ check: "bun run lint" })).toEqual([["run", "check"]]);
|
||||
});
|
||||
|
||||
test("0.6 type declarations are created only inside runnable apps", () => {
|
||||
const workspaceRoot = mkdtempSync(join(tmpdir(), "wrnexus-update-workspace-"));
|
||||
const appRoot = join(workspaceRoot, "apps", "web");
|
||||
mkdirSync(join(appRoot, "app", "pages"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(workspaceRoot, "package.json"),
|
||||
JSON.stringify({ name: "workspace", wrnexus: { version: "0.5.9" } }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(appRoot, "package.json"),
|
||||
JSON.stringify({ name: "web", wrnexus: { version: "0.5.9" } }),
|
||||
);
|
||||
|
||||
try {
|
||||
updateApp(workspaceRoot, "0.6.0", false);
|
||||
updateApp(appRoot, "0.6.0", false);
|
||||
|
||||
expect(existsSync(join(workspaceRoot, "app", "types", "global.d.ts"))).toBe(false);
|
||||
expect(existsSync(join(appRoot, "app", "types", "global.d.ts"))).toBe(true);
|
||||
} finally {
|
||||
rmSync(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("0.3 WRN source normalization is conservative and idempotent", async () => {
|
||||
const { migrateWrnSource } = await import("../src/update.ts");
|
||||
const source = `component Card {
|
||||
@@ -282,61 +111,28 @@ test("update verification still blocks real doctor errors", () => {
|
||||
).toEqual([fatal]);
|
||||
});
|
||||
|
||||
test("0.4 migration removes manual CAPTCHA runtime wiring and archives copied assets", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-update-"));
|
||||
test("updateApp refreshes framework-owned files without marking an unverified update complete", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-update-unverified-"));
|
||||
mkdirSync(join(root, "app", "pages"), { recursive: true });
|
||||
mkdirSync(join(root, "public", "assets", "wrnexus"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "captcha-app",
|
||||
scripts: { dev: "wrnexus dev ." },
|
||||
dependencies: { "@wrnexus/captcha": "^0.3.6" },
|
||||
wrnexus: { version: "0.3.6" },
|
||||
name: "current-app",
|
||||
dependencies: { "@wrnexus/core": "0.8.5" },
|
||||
wrnexus: { version: "0.8.5" },
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app", "pages", "index.wrn"),
|
||||
`page Home {
|
||||
view {
|
||||
<Captcha type="number" action="login" />
|
||||
<script src="/assets/wrnexus/captcha.js" defer></script>
|
||||
}
|
||||
}`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "public", "assets", "wrnexus", "captcha.js"),
|
||||
"window.legacyCaptcha = true;\n",
|
||||
);
|
||||
|
||||
try {
|
||||
updateApp(root, "0.4.0", false);
|
||||
const page = readFileSync(join(root, "app", "pages", "index.wrn"), "utf8");
|
||||
const result = updateApp(root, "0.8.6", false);
|
||||
expect(result).not.toBeNull();
|
||||
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
||||
expect(page).toContain("<Captcha");
|
||||
expect(page).not.toContain("captcha.js");
|
||||
expect(pkg.dependencies["@wrnexus/captcha"]).toBe("^0.4.0");
|
||||
expect(pkg.scripts["inspect:runtimes"]).toBe("wrnexus inspect runtimes .");
|
||||
expect(
|
||||
readFileSync(
|
||||
join(
|
||||
root,
|
||||
".wrnexus",
|
||||
"legacy-assets",
|
||||
"0.4.0",
|
||||
"public",
|
||||
"assets",
|
||||
"wrnexus",
|
||||
"captcha.js",
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
).toContain("legacyCaptcha");
|
||||
expect(existsSync(join(root, "public", "assets", "wrnexus", "captcha.js"))).toBe(false);
|
||||
const report = JSON.parse(
|
||||
readFileSync(join(root, ".wrnexus", "migrations", "0.4.0.json"), "utf8"),
|
||||
);
|
||||
expect(report.manualCaptchaRuntimeRequired).toBe(false);
|
||||
expect(pkg.dependencies["@wrnexus/core"]).toBe("^0.8.6");
|
||||
// updateApp only bumps dependency versions; the `wrnexus.version` marker is
|
||||
// set later, once install/build/doctor verification actually succeeds.
|
||||
expect(pkg.wrnexus.version).toBe("0.8.5");
|
||||
expect(readFileSync(join(root, "public", "llms.txt"), "utf8")).toContain("# WrNexus");
|
||||
expect(readFileSync(join(root, "CLAUDE.md"), "utf8").length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -394,3 +190,53 @@ test("0.8 migration modernizes every WRN source with imports and a review report
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("updateApp warns when a marker-less project is below 0.8.0, and stays quiet at 0.8.x", () => {
|
||||
const belowRoot = mkdtempSync(join(tmpdir(), "wrnexus-update-below-"));
|
||||
const currentRoot = mkdtempSync(join(tmpdir(), "wrnexus-update-current-"));
|
||||
try {
|
||||
// No `wrnexus.version` marker and no installed @wrnexus/cli resolves to
|
||||
// "0.0.0" — the common, benign case (e.g. examples/basic-app).
|
||||
writeFileSync(
|
||||
join(belowRoot, "package.json"),
|
||||
JSON.stringify({ name: "marker-less-app", dependencies: {} }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(currentRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "current-app",
|
||||
dependencies: { "@wrnexus/core": "^0.8.5" },
|
||||
wrnexus: { version: "0.8.5" },
|
||||
}),
|
||||
);
|
||||
|
||||
const originalLog = console.log;
|
||||
const capture = (): string[] => {
|
||||
const logs: string[] = [];
|
||||
console.log = (...args: unknown[]) => {
|
||||
logs.push(args.join(" "));
|
||||
};
|
||||
return logs;
|
||||
};
|
||||
|
||||
let belowLogs: string[];
|
||||
let currentLogs: string[];
|
||||
try {
|
||||
belowLogs = capture();
|
||||
updateApp(belowRoot, "0.8.9", true);
|
||||
|
||||
currentLogs = capture();
|
||||
updateApp(currentRoot, "0.8.9", true);
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
}
|
||||
|
||||
expect(belowLogs.some((l) => l.includes("no longer supported") && l.includes("0.0.0"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(currentLogs.some((l) => l.includes("no longer supported"))).toBe(false);
|
||||
} finally {
|
||||
rmSync(belowRoot, { recursive: true, force: true });
|
||||
rmSync(currentRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
eraseFunctionTypes,
|
||||
skipLiteralOrComment,
|
||||
type PageAst,
|
||||
type RuntimeFunctionDecl,
|
||||
type StructuredImportDecl,
|
||||
@@ -169,9 +170,7 @@ function selectedBrowserImports(
|
||||
}
|
||||
|
||||
export function browserModuleRequired(ast: PageAst): boolean {
|
||||
const functions = ast.runtimeFunctions.filter((fn) =>
|
||||
["legacy", "client", "shared"].includes(fn.runtime),
|
||||
);
|
||||
const functions = ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime));
|
||||
return functions.length > 0 || selectedBrowserImports(ast, functions).length > 0;
|
||||
}
|
||||
|
||||
@@ -310,15 +309,120 @@ function _functionEntry(
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-mode api blocks become members of an `api` object in client scope.
|
||||
* Blank out string/template literals and comments in a raw JS body, preserving
|
||||
* length and newlines, so a scanner walking the result never mistakes text
|
||||
* inside a string or comment for real code. Reuses the tokenizer's
|
||||
* comment/string-skipping rules (`skipLiteralOrComment`) instead of
|
||||
* reimplementing them — a second hand-rolled scanner is how apostrophes in
|
||||
* prose used to swallow braces elsewhere in this codebase.
|
||||
*/
|
||||
function maskStringsAndComments(src: string): string {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
let atLineStart = true;
|
||||
while (i < src.length) {
|
||||
const c = src[i]!;
|
||||
if (c === "\n") {
|
||||
out += c;
|
||||
atLineStart = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const skipped = skipLiteralOrComment(src, i, atLineStart);
|
||||
if (skipped !== null) {
|
||||
out += src.slice(i, skipped).replace(/[^\n]/g, " ");
|
||||
i = skipped;
|
||||
atLineStart = false;
|
||||
continue;
|
||||
}
|
||||
if (c !== " " && c !== "\t" && c !== "\r") atLineStart = false;
|
||||
out += c;
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block names the page's client functions actually call.
|
||||
*
|
||||
* A block's response and error bodies are page code. Emitting one the browser
|
||||
* never calls would ship a server-only transform to every visitor and grow the
|
||||
* bundle for nothing.
|
||||
*/
|
||||
function clientCalledApiNames(ast: PageAst): Set<string> {
|
||||
const called = new Set<string>();
|
||||
|
||||
for (const fn of ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime))) {
|
||||
const masked = maskStringsAndComments(fn.body);
|
||||
for (const match of masked.matchAll(/\bapi\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)/g)) {
|
||||
called.add(match[1]!);
|
||||
}
|
||||
}
|
||||
|
||||
return called;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to compile a client/shared function whose body references `api` in
|
||||
* any form other than `api.<identifier>` — e.g. `api["searchUsers"]()`, or
|
||||
* passing `api` to a helper. Usage-driven emission (see `clientCalledApiNames`
|
||||
* above) can only see `api.<identifier>` calls; a dynamic or indirect
|
||||
* reference is invisible to it, so the referenced block would be silently
|
||||
* dropped from the browser bundle and the call would fail at runtime with
|
||||
* "api.<name> is not a function". That failure direction is worse than a
|
||||
* loud compile error, so it is caught here instead.
|
||||
*
|
||||
* Strings and comments are masked out first so `api` appearing in prose or in
|
||||
* a quoted value never trips this check, and every `api.<identifier>` access
|
||||
* is stripped before the standalone-word scan so a real, well-formed call
|
||||
* never does either.
|
||||
*/
|
||||
function assertNoDynamicApiAccess(ast: PageAst): void {
|
||||
// Only pages with a sectioned api block have anything at stake here: those
|
||||
// blocks are emitted solely because usage detection saw `api.<name>`, so a
|
||||
// dynamic reference this scan can't see is the one that silently drops a
|
||||
// block from the bundle. A page with no api blocks at all may still declare
|
||||
// an ordinary `state api` (see the B5 regression test) where a bare "api"
|
||||
// identifier is just that state, not a missed block reference.
|
||||
if (!ast.dataApis.some((block) => block.sections)) return;
|
||||
|
||||
for (const fn of ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime))) {
|
||||
const masked = maskStringsAndComments(fn.body);
|
||||
const withoutCalls = masked.replace(/\bapi\s*\.\s*[A-Za-z_$][A-Za-z0-9_$]*/g, (match) =>
|
||||
match.replace(/[^\n]/g, " "),
|
||||
);
|
||||
if (/\bapi\b/.test(withoutCalls)) {
|
||||
throw new Error(
|
||||
`.wrn ${fn.runtime} function "${fn.name}" in page "${ast.name}" references "api" in a form other than "api.<name>(...)". ` +
|
||||
`API blocks must be called as api.name(...) so the compiler can tell which ones the browser needs to receive; ` +
|
||||
`dynamic or indirect access (e.g. api["name"](), or passing api to a helper) cannot be detected and would silently drop the block from the browser bundle.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A block is emitted into the browser module when it declares typed sections
|
||||
* and a client function actually calls it. `hasClientApi` below must use this
|
||||
* exact predicate so the `api` reserved-binding exclusion and the emitted
|
||||
* object can never disagree.
|
||||
*/
|
||||
function isClientEmittedApiBlock(block: PageAst["dataApis"][number], called: Set<string>): boolean {
|
||||
return Boolean(block.sections) && called.has(block.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-mode and client-called any-mode api blocks become members of an
|
||||
* `api` object in client scope.
|
||||
*
|
||||
* Only the response and error bodies are emitted; the declared field types are
|
||||
* type-only and are consumed by the types generator instead. Anything
|
||||
* TypeScript reaching this module would be a syntax error in the .mjs artifact.
|
||||
*/
|
||||
function apiBindings(ast: PageAst): string {
|
||||
const called = clientCalledApiNames(ast);
|
||||
const members = ast.dataApis
|
||||
.filter((block) => block.mode === "client" && block.sections)
|
||||
.filter((block) => isClientEmittedApiBlock(block, called))
|
||||
.map((block) => {
|
||||
const sections = block.sections!;
|
||||
const response = eraseFunctionTypes(sections.response).trim() || "return data;";
|
||||
@@ -336,9 +440,8 @@ function apiBindings(ast: PageAst): string {
|
||||
}
|
||||
|
||||
export function generateBrowserModule(ast: PageAst): string {
|
||||
const functions = ast.runtimeFunctions.filter((fn) =>
|
||||
["legacy", "client", "shared"].includes(fn.runtime),
|
||||
);
|
||||
assertNoDynamicApiAccess(ast);
|
||||
const functions = ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime));
|
||||
const functionNames = functions.map((fn) => fn.name);
|
||||
const state = ast.states.filter((entry) => entry.runtime !== "server").map((entry) => entry.name);
|
||||
const selectedImports = selectedBrowserImports(ast, functions);
|
||||
@@ -349,7 +452,9 @@ export function generateBrowserModule(ast: PageAst): string {
|
||||
// `state api` without any client api blocks must keep reading/writing that
|
||||
// state as before, so only exclude the "api" name from destructuring when
|
||||
// there is a real `api` binding to shadow it.
|
||||
const hasClientApi = ast.dataApis.some((block) => block.mode === "client" && block.sections);
|
||||
const hasClientApi = ast.dataApis.some((block) =>
|
||||
isClientEmittedApiBlock(block, clientCalledApiNames(ast)),
|
||||
);
|
||||
const localRuntimeBindings = hasClientApi
|
||||
? RUNTIME_BINDINGS
|
||||
: new Set([...RUNTIME_BINDINGS].filter((name) => name !== "api"));
|
||||
|
||||
+135
-102
@@ -17,9 +17,16 @@
|
||||
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { VOID_ELEMENTS, type Attr, type DataMode, type PageAst, type ViewNode } from "./parser.ts";
|
||||
import { eraseFunctionTypes, runtimeTypeOf } from "./types.ts";
|
||||
import { stripRuntimeFunctionModifiers } from "@wrnexus/syntax";
|
||||
import {
|
||||
VOID_ELEMENTS,
|
||||
type Attr,
|
||||
type DataMode,
|
||||
type PageAst,
|
||||
type ViewNode,
|
||||
eraseFunctionTypes,
|
||||
runtimeTypeOf,
|
||||
stripRuntimeFunctionModifiers,
|
||||
} from "@wrnexus/syntax";
|
||||
import { generateStoreModule } from "./store-codegen.ts";
|
||||
import { optimizeAst } from "./analysis.ts";
|
||||
import {
|
||||
@@ -560,29 +567,6 @@ function compileIfExpr(node: IfNode): string {
|
||||
return "${" + expr + "}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every server-control expression in a view (recursively): `{#each}` list
|
||||
* expressions and `{#if}` conditions. Used to wrn up raw SSR data consts.
|
||||
*/
|
||||
function collectControlExprs(nodes: ViewNode[], out: string[] = []): string[] {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "text") continue;
|
||||
if (node.type === "each") {
|
||||
out.push(node.list);
|
||||
collectControlExprs(node.body, out);
|
||||
collectControlExprs(node.empty, out);
|
||||
} else if (node.type === "if") {
|
||||
for (const b of node.branches) {
|
||||
if (b.cond) out.push(b.cond);
|
||||
collectControlExprs(b.body, out);
|
||||
}
|
||||
} else if (node.type === "element") {
|
||||
collectControlExprs(node.children, out);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderNode(
|
||||
node: ViewNode,
|
||||
ssrBindings: SsrBinding[],
|
||||
@@ -718,10 +702,14 @@ function renderNode(
|
||||
);
|
||||
}
|
||||
|
||||
const apiName = attrValue(node.attrs, "api");
|
||||
const apiBinding = apiName ? apiBindings.get(apiName) : undefined;
|
||||
if (apiName && !apiBinding) {
|
||||
throw new Error(`Unknown .wrn api binding "${apiName}"`);
|
||||
const apiAttr = attrValue(node.attrs, "api");
|
||||
const parsedApi = apiAttr ? parseApiBinding(apiAttr) : null;
|
||||
if (apiAttr && !parsedApi) {
|
||||
throw new Error(`Invalid .wrn api binding "${apiAttr}"`);
|
||||
}
|
||||
const apiBinding = parsedApi ? apiBindings.get(parsedApi.name) : undefined;
|
||||
if (parsedApi && !apiBinding) {
|
||||
throw new Error(`Unknown .wrn api binding "${parsedApi.name}"`);
|
||||
}
|
||||
|
||||
const ssrGet = attrValue(node.attrs, "ssrGet");
|
||||
@@ -730,37 +718,32 @@ function renderNode(
|
||||
const csrText = attrValue(node.attrs, "csrText");
|
||||
|
||||
const csrId =
|
||||
apiBinding?.mode === "client"
|
||||
? csrMarker(csrBindings, renderBinding(apiBinding))
|
||||
: csrGet && csrText
|
||||
? csrMarker(csrBindings, {
|
||||
method: "GET",
|
||||
path: apiRoutePath(csrGet),
|
||||
body: expressionBody(csrText),
|
||||
helpers: "",
|
||||
})
|
||||
: undefined;
|
||||
csrGet && csrText
|
||||
? csrMarker(csrBindings, {
|
||||
method: "GET",
|
||||
path: apiRoutePath(csrGet),
|
||||
body: expressionBody(csrText),
|
||||
helpers: "",
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// Void elements (<br>, <img>, …) have no closing tag and no children.
|
||||
if (VOID_ELEMENTS.has(node.tag.toLowerCase())) {
|
||||
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive, loops)}>`;
|
||||
}
|
||||
|
||||
const inner =
|
||||
apiBinding?.mode === "ssr"
|
||||
? ssrMarker(ssrBindings, renderBinding(apiBinding))
|
||||
: ssrGet && ssrText
|
||||
? ssrMarker(ssrBindings, {
|
||||
method: "GET",
|
||||
path: apiRoutePath(ssrGet),
|
||||
body: expressionBody(ssrText),
|
||||
helpers: "",
|
||||
})
|
||||
: node.children
|
||||
.map((child) =>
|
||||
renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive),
|
||||
)
|
||||
.join("");
|
||||
const inner = apiBinding
|
||||
? apiCallMarker(loops, parsedApi!.name, parsedApi!.args)
|
||||
: ssrGet && ssrText
|
||||
? ssrMarker(ssrBindings, {
|
||||
method: "GET",
|
||||
path: apiRoutePath(ssrGet),
|
||||
body: expressionBody(ssrText),
|
||||
helpers: "",
|
||||
})
|
||||
: node.children
|
||||
.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive))
|
||||
.join("");
|
||||
|
||||
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive, loops)}>${inner}</${node.tag}>`;
|
||||
}
|
||||
@@ -859,6 +842,34 @@ function renderNestedComponentInvocation(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the three `api="…"` render-binding forms: a bare name, an empty call,
|
||||
* or a call carrying an argument expression. Mirrors the shape of `@click="fn()"`,
|
||||
* so no new escaping or attribute-naming rules are introduced. The argument
|
||||
* capture is greedy up to the outer parens, so nested braces/parens/quotes in
|
||||
* the argument expression (object literals, arrays, strings) are carried
|
||||
* through untouched rather than truncated at the first `)`.
|
||||
*/
|
||||
function parseApiBinding(value: string): { name: string; args: string } | null {
|
||||
const match = /^\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:\(([\s\S]*)\))?\s*$/.exec(value);
|
||||
if (!match) return null;
|
||||
return { name: match[1]!, args: (match[2] ?? "").trim() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a render-time call into the server `api` object (Task 4's generated
|
||||
* transport) for an `apis {}` (mode "any") binding, and return the sentinel
|
||||
* that the loop/expression-splicing mechanism swaps for the real `${…}` code.
|
||||
* This calls the same server `api.<name>()` member a `load`/action block would
|
||||
* call -- it does not reimplement fetch/response handling -- so a block that is
|
||||
* both render-bound and called from code runs its own call each time (no
|
||||
* dedup is attempted; see apis-render-binding.test.ts).
|
||||
*/
|
||||
function apiCallMarker(loops: string[], name: string, args: string): string {
|
||||
loops.push(`\${__wrnexusEscapeHtml(await api.${name}(${args}))}`);
|
||||
return `\x00WRNEACH${loops.length - 1}\x00`;
|
||||
}
|
||||
|
||||
function ssrMarker(bindings: SsrBinding[], binding: RenderBinding): string {
|
||||
const marker = `<!--wrnexus-ssr:${bindings.length}-->`;
|
||||
bindings.push({ marker, ...binding });
|
||||
@@ -871,16 +882,6 @@ function csrMarker(bindings: CsrBinding[], binding: RenderBinding): string {
|
||||
return id;
|
||||
}
|
||||
|
||||
function renderBinding(binding: NamedDataBinding): RenderBinding {
|
||||
return {
|
||||
method: binding.method,
|
||||
path: binding.path,
|
||||
body: binding.body,
|
||||
helpers: binding.helpers,
|
||||
...(binding.errorBody ? { errorBody: binding.errorBody } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function hasClientBehavior(nodes: ViewNode[]): boolean {
|
||||
return nodes.some((node) => {
|
||||
// `{t:key}` is i18n sugar resolved server-side — not client reactivity.
|
||||
@@ -922,18 +923,6 @@ function dataBody(source: string): string {
|
||||
return /\breturn\b/.test(trimmed) ? trimmed : expressionBody(trimmed);
|
||||
}
|
||||
|
||||
function modeHelpers(ast: PageAst, mode: DataMode, sharedHelpers: string): string {
|
||||
return [
|
||||
sharedHelpers,
|
||||
...ast.modeFunctions
|
||||
.filter((block) => block.mode === mode)
|
||||
.map((block) => block.body.trim())
|
||||
.filter(Boolean),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDataBinding> {
|
||||
const bindings = new Map<string, NamedDataBinding>();
|
||||
|
||||
@@ -956,13 +945,47 @@ function apiBindingMap(ast: PageAst, sharedHelpers: string): Map<string, NamedDa
|
||||
// legacy blocks and sectioned blocks without `error` keep failures
|
||||
// propagating exactly as before.
|
||||
...(errorSection ? { errorBody: errorSection } : {}),
|
||||
helpers: modeHelpers(ast, block.mode, sharedHelpers),
|
||||
helpers: sharedHelpers,
|
||||
});
|
||||
}
|
||||
|
||||
return bindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side `api` object.
|
||||
*
|
||||
* The transport dispatches in-process, so a call from a load block or an action
|
||||
* costs a function call rather than a network round trip. The request context
|
||||
* comes from AsyncLocalStorage because `ctx` is not in scope everywhere server
|
||||
* code runs.
|
||||
*/
|
||||
function serverApiBindings(ast: PageAst): string {
|
||||
const members = ast.dataApis
|
||||
.filter((block) => block.mode === "any")
|
||||
.map((block) => {
|
||||
const sections = block.sections!;
|
||||
const response = sections.response.trim() || "return data;";
|
||||
const error = sections.error.trim();
|
||||
const failure = error
|
||||
? `const status = (err as { status?: unknown } | null | undefined)?.status; const message = err instanceof Error ? err.message : String(err); const data = (err as { data?: unknown } | null | undefined)?.data; ${error}`
|
||||
: `throw err;`;
|
||||
|
||||
return ` ${JSON.stringify(block.name)}: async (input?: unknown) => {
|
||||
const ctx = __wrnexusRequireRequestContext(${JSON.stringify(`api.${block.name}`)}) as __WrnexusContext;
|
||||
let data: any;
|
||||
try {
|
||||
data = await __wrnexusCallApi(${JSON.stringify(apiRoutePath(block.path))}, ${JSON.stringify(block.method)}, ctx, input);
|
||||
} catch (err) {
|
||||
${failure}
|
||||
}
|
||||
${response}
|
||||
}`;
|
||||
});
|
||||
|
||||
return members.length ? `const api = {\n${members.join(",\n")}\n};` : "";
|
||||
}
|
||||
|
||||
function ssrRuntimeSource(): string {
|
||||
return `const __wrnexusHtmlEscapes: Record<string, string> = { "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" };
|
||||
function __wrnexusEscapeHtml(value: unknown): string {
|
||||
@@ -970,7 +993,7 @@ function __wrnexusEscapeHtml(value: unknown): string {
|
||||
}
|
||||
|
||||
type __WrnexusContext = import("@wrnexus/core").Context & {
|
||||
__wrnexusCallApi?: (path: string, method: string) => Promise<unknown>;
|
||||
__wrnexusCallApi?: (path: string, method: string, input?: unknown) => Promise<unknown>;
|
||||
localStorage?: unknown;
|
||||
};
|
||||
|
||||
@@ -1017,13 +1040,27 @@ function __wrnexusPropAttr(
|
||||
);
|
||||
}
|
||||
|
||||
async function __wrnexusCallApi(path: string, method: string, ctx: __WrnexusContext): Promise<unknown> {
|
||||
async function __wrnexusCallApi(
|
||||
path: string,
|
||||
method: string,
|
||||
ctx: __WrnexusContext,
|
||||
input?: unknown,
|
||||
): Promise<unknown> {
|
||||
if (typeof ctx.__wrnexusCallApi === "function") {
|
||||
return await ctx.__wrnexusCallApi(path, method);
|
||||
return await ctx.__wrnexusCallApi(path, method, input);
|
||||
}
|
||||
|
||||
const url = new URL(path, ctx.req.url);
|
||||
const res = await fetch(new Request(url, { method, headers: ctx.req.headers }));
|
||||
const built = __wrnexusBuildApiRequest(path, method, input as Record<string, unknown> | undefined);
|
||||
const url = new URL(built.url, ctx.req.url);
|
||||
const headers = new Headers(ctx.req.headers);
|
||||
if (built.contentType) headers.set("content-type", built.contentType);
|
||||
const res = await fetch(
|
||||
new Request(url, {
|
||||
method,
|
||||
headers,
|
||||
...(built.body === undefined ? {} : { body: built.body }),
|
||||
}),
|
||||
);
|
||||
const type = res.headers.get("content-type") || "";
|
||||
if (!res.ok) {
|
||||
const data = type.includes("application/json")
|
||||
@@ -1310,9 +1347,7 @@ function hydrationAttribute(ast: PageAst): string {
|
||||
|
||||
function targetFunctions(ast: PageAst, target: "browser" | "server"): string {
|
||||
const runtimes =
|
||||
target === "browser"
|
||||
? (["legacy", "client", "shared"] as const)
|
||||
: (["legacy", "server", "shared"] as const);
|
||||
target === "browser" ? (["client", "shared"] as const) : (["server", "shared"] as const);
|
||||
return ast.functions
|
||||
.map((body) => stripRuntimeFunctionModifiers(body, [...runtimes]))
|
||||
.map((body) => body.trim())
|
||||
@@ -1426,6 +1461,12 @@ function generateInner(ast: PageAst): string {
|
||||
);
|
||||
}
|
||||
if (ast.imports.length > 0) out.push(generatedImports(ast).join("\n"));
|
||||
const hasServerApis = ast.dataApis.some((block) => block.mode === "any");
|
||||
if (hasServerApis) {
|
||||
out.push(
|
||||
`import { requireRequestContext as __wrnexusRequireRequestContext } from "@wrnexus/core";`,
|
||||
);
|
||||
}
|
||||
const ssrBindings: SsrBinding[] = [];
|
||||
const csrBindings: CsrBinding[] = [];
|
||||
const helpers = targetFunctions(ast, "server");
|
||||
@@ -1571,29 +1612,21 @@ function generateInner(ast: PageAst): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Server loops iterate raw SSR data. Declare a named const for every `ssr` data
|
||||
// binding a loop references, so `{#each <name> as …}` can iterate the real value.
|
||||
const loopConsts: string[] = [];
|
||||
if (loops.length > 0) {
|
||||
const lists = collectControlExprs(ast.view);
|
||||
for (const [name, binding] of apiBindings) {
|
||||
if (binding.mode !== "ssr") continue;
|
||||
if (!lists.some((expr) => new RegExp(`\\b${name}\\b`).test(expr))) continue;
|
||||
const errorBodyProp = binding.errorBody
|
||||
? `, errorBody: ${JSON.stringify(binding.errorBody)}`
|
||||
: "";
|
||||
loopConsts.push(
|
||||
` const ${name} = await __wrnexusResolveApiBinding({ path: ${JSON.stringify(binding.path)}, method: ${JSON.stringify(binding.method)}, body: ${JSON.stringify(binding.body)}, helpers: ${JSON.stringify(binding.helpers)}${errorBodyProp} }, ctx);`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0;
|
||||
if (needsSsrRuntime) {
|
||||
const needsRuntimeHelpers = needsSsrRuntime || hasServerApis;
|
||||
if (needsRuntimeHelpers) {
|
||||
out.push(`import { buildApiRequest as __wrnexusBuildApiRequest } from "@wrnexus/core";`);
|
||||
out.push(ssrRuntimeSource());
|
||||
out.push(
|
||||
`const __wrnexusSsrBindings: __WrnexusSsrBinding[] = ${JSON.stringify(ssrBindings, null, 2)};`,
|
||||
);
|
||||
}
|
||||
if (hasServerApis) {
|
||||
out.push(serverApiBindings(ast));
|
||||
}
|
||||
if (needsSsrRuntime) {
|
||||
const decls = loopConsts.length > 0 ? loopConsts.join("\n") + "\n" : "";
|
||||
out.push(
|
||||
`export default async function ${ast.name}(ctx: __WrnexusContext) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Attr, PageAst, ViewNode } from "./parser.ts";
|
||||
import type { Attr, PageAst, ViewNode } from "@wrnexus/syntax";
|
||||
|
||||
export class NativeCompileError extends Error {
|
||||
constructor(message: string) {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
/** @deprecated Import parser APIs from @wrnexus/syntax. */
|
||||
export * from "@wrnexus/syntax/parser";
|
||||
@@ -25,7 +25,7 @@ function stableId(value: string): string {
|
||||
*/
|
||||
export function remotelyReferencedServerFunctions(ast: PageAst): Set<string> {
|
||||
const browserSources = ast.runtimeFunctions
|
||||
.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime))
|
||||
.filter((fn) => ["client", "shared"].includes(fn.runtime))
|
||||
.map((fn) => fn.body);
|
||||
for (const [hook, body] of Object.entries(ast.storeLifecycle)) {
|
||||
if (hook !== "serverInit" && body) browserSources.push(body);
|
||||
@@ -57,11 +57,11 @@ export function rpcManifest(ast: PageAst): RpcManifestEntry[] {
|
||||
|
||||
export function generateServerFunctionsModule(ast: PageAst): string {
|
||||
const source = ast.functions
|
||||
.map((body) => stripRuntimeFunctionModifiers(body, ["legacy", "server", "shared"]))
|
||||
.map((body) => stripRuntimeFunctionModifiers(body, ["server", "shared"]))
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const names = ast.runtimeFunctions
|
||||
.filter((fn) => ["legacy", "server", "shared"].includes(fn.runtime))
|
||||
.filter((fn) => ["server", "shared"].includes(fn.runtime))
|
||||
.map((fn) => fn.name);
|
||||
const manifest = rpcManifest(ast);
|
||||
return `// generated WRNexusJS server module for ${ast.name}\n${source}\n\nexport const __wrnexusServerFunctions = { ${[...new Set(names)].join(", ")} };\nexport const __wrnexusRpcManifest = ${JSON.stringify(manifest, null, 2)};\n`;
|
||||
|
||||
@@ -175,7 +175,7 @@ export function generateStoreBrowserModule(ast: PageAst): string {
|
||||
.join(",\n");
|
||||
const groups = new Map<string, RuntimeFunctionDecl[]>();
|
||||
for (const fn of ast.runtimeFunctions.filter((entry) =>
|
||||
["client", "shared", "legacy"].includes(entry.runtime),
|
||||
["client", "shared"].includes(entry.runtime),
|
||||
)) {
|
||||
const group = groups.get(fn.name) ?? [];
|
||||
group.push(fn);
|
||||
@@ -296,7 +296,7 @@ function __create(definition) {
|
||||
Object.keys(actions).forEach(function (name) { delete actions[name]; });
|
||||
Object.entries(currentDefinition.actions || {}).forEach(function (pair) {
|
||||
const name = pair[0], candidates = pair[1];
|
||||
const selected = candidates.find(function (entry) { return entry.runtime === "client"; }) || candidates.find(function (entry) { return entry.runtime === "shared"; }) || candidates.find(function (entry) { return entry.runtime === "legacy"; });
|
||||
const selected = candidates.find(function (entry) { return entry.runtime === "client"; }) || candidates.find(function (entry) { return entry.runtime === "shared"; });
|
||||
if (!selected) return;
|
||||
actions[name] = async function () {
|
||||
const args = Array.prototype.slice.call(arguments);
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
/** @deprecated Import tokenizer APIs from @wrnexus/syntax. */
|
||||
export * from "@wrnexus/syntax/tokenizer";
|
||||
@@ -47,7 +47,7 @@ export function generateDeclarations(ast: PageAst): string {
|
||||
)
|
||||
.join("\n");
|
||||
const clientFunctions = ast.runtimeFunctions
|
||||
.filter((fn) => fn.runtime === "client" || fn.runtime === "shared" || fn.runtime === "legacy")
|
||||
.filter((fn) => fn.runtime === "client" || fn.runtime === "shared")
|
||||
.map(
|
||||
(fn) =>
|
||||
` ${member(fn.name)}(${params(fn.parameters)}): ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")};`,
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
/** @deprecated Import language type utilities from @wrnexus/syntax. */
|
||||
export * from "@wrnexus/syntax/types";
|
||||
@@ -1,285 +0,0 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
import { generateTargets } from "../src/targets.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function browserModule(inner: string): string {
|
||||
return generateTargets(
|
||||
parse(`page Repro {
|
||||
client {
|
||||
${inner}
|
||||
}
|
||||
|
||||
functions {
|
||||
client async function run(): Promise<void> {
|
||||
const users = await api.searchUsers({ name: "Ajay" })
|
||||
console.log(users)
|
||||
}
|
||||
}
|
||||
|
||||
view { <main><button @click="run()">go</button></main> }
|
||||
}
|
||||
`),
|
||||
).browser;
|
||||
}
|
||||
|
||||
const BLOCK = ` api searchUsers POST /api/users {
|
||||
request {
|
||||
body {
|
||||
name?: string
|
||||
age?: number
|
||||
}
|
||||
}
|
||||
|
||||
response {
|
||||
return data.users
|
||||
}
|
||||
|
||||
error {
|
||||
return []
|
||||
}
|
||||
}`;
|
||||
|
||||
test("emits an api member that calls the transport with the block's path and method", () => {
|
||||
const generated = browserModule(BLOCK);
|
||||
|
||||
expect(generated).toContain("const api =");
|
||||
expect(generated).toContain("searchUsers");
|
||||
expect(generated).toContain('"/api/users"');
|
||||
expect(generated).toContain('"POST"');
|
||||
});
|
||||
|
||||
test("declared field types never reach the browser module", () => {
|
||||
// The artifact is written as .mjs and parsed as JavaScript.
|
||||
const generated = browserModule(BLOCK);
|
||||
|
||||
expect(generated).not.toContain("name?: string");
|
||||
expect(generated).not.toContain("age?: number");
|
||||
});
|
||||
|
||||
test("the emitted module is valid JavaScript", () => {
|
||||
const generated = browserModule(BLOCK);
|
||||
|
||||
expect(() => {
|
||||
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test("a block without an error section still emits its response body", () => {
|
||||
const generated = browserModule(` api plainUsers GET /api/users {
|
||||
request {
|
||||
parameters {
|
||||
team: string
|
||||
}
|
||||
}
|
||||
|
||||
response {
|
||||
return data.users
|
||||
}
|
||||
}`);
|
||||
|
||||
expect(generated).toContain("plainUsers");
|
||||
expect(generated).toContain("data.users");
|
||||
});
|
||||
|
||||
test("type annotations in response/error bodies are erased before emission (B4)", () => {
|
||||
// Every other browser-bound body in the repo passes through eraseFunctionTypes
|
||||
// (see the fn.body call sites in client-codegen.ts ~line 288 and ~371, and
|
||||
// store-codegen.ts); response/error bodies must too, for the same reason:
|
||||
// eraseFunctionTypes strips function-signature annotations (params, return
|
||||
// type, typed catch clauses) so a locally-declared helper function inside a
|
||||
// response/error body no longer ships raw TypeScript into the .mjs artifact.
|
||||
const generated = browserModule(` api searchUsers POST /api/users {
|
||||
request {
|
||||
body {
|
||||
name?: string
|
||||
}
|
||||
}
|
||||
|
||||
response {
|
||||
function pick(list: string[]): string[] { return list }
|
||||
return pick(data.users)
|
||||
}
|
||||
|
||||
error {
|
||||
function describe(e: unknown): string { return String(e) }
|
||||
return describe(error)
|
||||
}
|
||||
}`);
|
||||
|
||||
expect(generated).not.toContain("list: string[]");
|
||||
expect(generated).not.toContain("): string[] {");
|
||||
expect(generated).not.toContain("e: unknown");
|
||||
expect(generated).not.toContain("): string {");
|
||||
expect(() => {
|
||||
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test("a page with state api and no client api blocks still reads that state (B5)", () => {
|
||||
// "api" is normally excluded from state/prop destructuring because the
|
||||
// emitted `const api = {...}` binding would shadow it -- but that binding
|
||||
// only exists when the page has client-mode api blocks. Without one, the
|
||||
// exclusion left `api` completely undeclared: a ReferenceError.
|
||||
const generated = generateTargets(
|
||||
parse(`page Repro {
|
||||
state {
|
||||
api = "hello"
|
||||
}
|
||||
|
||||
functions {
|
||||
client function run(): void {
|
||||
console.log(api)
|
||||
}
|
||||
}
|
||||
|
||||
view { <main><button @click="run()">go</button></main> }
|
||||
}
|
||||
`),
|
||||
).browser;
|
||||
|
||||
expect(generated).toContain("context.state");
|
||||
expect(() => {
|
||||
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds a browser module whose `run()` function calls api.searchUsers and
|
||||
* reports the outcome through `output.report(...)` so the test can observe
|
||||
* whether the call resolved or rejected without reaching into codegen
|
||||
* internals.
|
||||
*/
|
||||
function reportingBrowserModule(apiBlock: string): string {
|
||||
return generateTargets(
|
||||
parse(`page Repro {
|
||||
client {
|
||||
${apiBlock}
|
||||
}
|
||||
|
||||
outputs {
|
||||
report(payload: any)
|
||||
}
|
||||
|
||||
functions {
|
||||
client async function run(): Promise<void> {
|
||||
try {
|
||||
const users = await api.searchUsers({ name: "Ajay" })
|
||||
output.report({ ok: true, users })
|
||||
} catch (e) {
|
||||
output.report({ ok: false, message: String(e && e.message || e) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view { <main><button @click="run()">go</button></main> }
|
||||
}
|
||||
`),
|
||||
).browser;
|
||||
}
|
||||
|
||||
async function importBrowserModule(source: string): Promise<any> {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-client-exec-"));
|
||||
roots.push(root);
|
||||
mkdirSync(root, { recursive: true });
|
||||
const file = join(root, "page.mjs");
|
||||
writeFileSync(file, source);
|
||||
return import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
|
||||
}
|
||||
|
||||
test("a response body error is not swallowed by the error section (client)", async () => {
|
||||
const mod = await importBrowserModule(
|
||||
reportingBrowserModule(` api searchUsers GET /api/users {
|
||||
request { parameters { name: string } }
|
||||
response {
|
||||
return data.users.missing.length
|
||||
}
|
||||
error {
|
||||
return []
|
||||
}
|
||||
}`),
|
||||
);
|
||||
|
||||
const reports: unknown[] = [];
|
||||
const context = {
|
||||
state: {},
|
||||
props: {},
|
||||
output: { report: (value: unknown) => reports.push(value) },
|
||||
server: {},
|
||||
refs: {},
|
||||
callApi: async () => ({ users: [] }),
|
||||
};
|
||||
|
||||
await mod.__wrnexusClientFunctions.run(context);
|
||||
|
||||
expect(reports).toEqual([{ ok: false, message: expect.any(String) }]);
|
||||
// The error section's own fallback ("[]" / an empty array) must not have
|
||||
// been what the caller observed -- a bug in the response body is a
|
||||
// rejection, not a silently-returned fallback value.
|
||||
expect(reports[0]).not.toEqual({ ok: true, users: [] });
|
||||
});
|
||||
|
||||
test("a genuine transport failure still runs the error section's fallback (client)", async () => {
|
||||
const mod = await importBrowserModule(
|
||||
reportingBrowserModule(` api searchUsers GET /api/users {
|
||||
request { parameters { name: string } }
|
||||
response {
|
||||
return data.users
|
||||
}
|
||||
error {
|
||||
return ["fallback"]
|
||||
}
|
||||
}`),
|
||||
);
|
||||
|
||||
const reports: unknown[] = [];
|
||||
const context = {
|
||||
state: {},
|
||||
props: {},
|
||||
output: { report: (value: unknown) => reports.push(value) },
|
||||
server: {},
|
||||
refs: {},
|
||||
callApi: async () => {
|
||||
throw Object.assign(new Error("transport failed"), { status: 500 });
|
||||
},
|
||||
};
|
||||
|
||||
await mod.__wrnexusClientFunctions.run(context);
|
||||
|
||||
expect(reports).toEqual([{ ok: true, users: ["fallback"] }]);
|
||||
});
|
||||
|
||||
test("a state field named api does not collide with the emitted api object", () => {
|
||||
const generated = generateTargets(
|
||||
parse(`page Repro {
|
||||
state {
|
||||
api = ""
|
||||
}
|
||||
|
||||
client {
|
||||
${BLOCK}
|
||||
}
|
||||
|
||||
functions {
|
||||
client async function run(): Promise<void> {
|
||||
const users = await api.searchUsers({ name: "Ajay" })
|
||||
console.log(users)
|
||||
}
|
||||
}
|
||||
|
||||
view { <main><button @click="run()">go</button></main> }
|
||||
}
|
||||
`),
|
||||
).browser;
|
||||
|
||||
expect(() => {
|
||||
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
|
||||
}).not.toThrow();
|
||||
});
|
||||
@@ -1,282 +0,0 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
import { generate } from "../src/codegen.ts";
|
||||
|
||||
const ROOT_TSCONFIG = join(import.meta.dir, "../../../tsconfig.json").replace(/\\/g, "/");
|
||||
// The repo's own tsc, not a `bunx`-fetched one — `bunx tsc` can resolve an
|
||||
// unrelated TypeScript version that doesn't understand this repo's tsconfig
|
||||
// options (observed: it rejected `ignoreDeprecations: "6.0"` and couldn't
|
||||
// find the `bun` type-definition entry point), unlike `bun run typecheck`,
|
||||
// which uses this same local binary.
|
||||
const LOCAL_TSC = join(import.meta.dir, "../../../node_modules/.bin/tsc").replace(/\\/g, "/");
|
||||
// `types`/`typeRoots` in an extended tsconfig resolve relative to the config
|
||||
// file that's actually invoked (our temp one), not the base file — so the
|
||||
// ambient `bun` types need an explicit path back to the repo's node_modules.
|
||||
const TYPE_ROOTS = join(import.meta.dir, "../../../node_modules/@types").replace(/\\/g, "/");
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Runs the real TypeScript compiler over a generated server module. Proves
|
||||
* the emitted `__wrnexusSsrBindings` annotation (and everything else in the
|
||||
* module) actually type-checks — string-containment assertions alone can't
|
||||
* catch a declared type that omits a field every emitted object literal has.
|
||||
*/
|
||||
function typecheckGenerated(source: string): { ok: boolean; output: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-tsc-"));
|
||||
roots.push(root);
|
||||
const file = join(root, "page.ts");
|
||||
writeFileSync(file, source);
|
||||
// Reuse the repo's own tsconfig (paths, lib, types, jsx, ...) so this only
|
||||
// checks the one file we care about instead of hand-duplicating the whole
|
||||
// compiler configuration (and drifting from it over time).
|
||||
writeFileSync(
|
||||
join(root, "tsconfig.json"),
|
||||
JSON.stringify({
|
||||
extends: ROOT_TSCONFIG,
|
||||
compilerOptions: { noEmit: true, typeRoots: [TYPE_ROOTS] },
|
||||
include: ["page.ts"],
|
||||
}),
|
||||
);
|
||||
const result = Bun.spawnSync([LOCAL_TSC, "--project", join(root, "tsconfig.json")], {
|
||||
cwd: root,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const output = `${result.stdout?.toString() ?? ""}${result.stderr?.toString() ?? ""}`;
|
||||
return { ok: result.exitCode === 0, output };
|
||||
}
|
||||
|
||||
function serverModule(inner: string): string {
|
||||
return generate(
|
||||
parse(`page Repro {
|
||||
ssr {
|
||||
${inner}
|
||||
}
|
||||
|
||||
view { <main><p api="ssrUsers">loading</p></main> }
|
||||
}
|
||||
`),
|
||||
);
|
||||
}
|
||||
|
||||
test("a sectioned ssr block binds the payload to data", () => {
|
||||
const generated = serverModule(` api ssrUsers GET /api/users {
|
||||
response {
|
||||
return data.users.length
|
||||
}
|
||||
}`);
|
||||
|
||||
expect(generated).toContain("data.users.length");
|
||||
});
|
||||
|
||||
test("a legacy ssr block is unchanged", () => {
|
||||
const generated = serverModule(` api ssrUsers GET /api/users {
|
||||
return users.length
|
||||
}`);
|
||||
|
||||
expect(generated).toContain("users.length");
|
||||
});
|
||||
|
||||
test("an ssr block with an error section emits the error body and binds status/message/data", () => {
|
||||
const generated = serverModule(` api ssrUsers GET /api/users {
|
||||
response {
|
||||
return data.users.length
|
||||
}
|
||||
error {
|
||||
return message + status + data
|
||||
}
|
||||
}`);
|
||||
|
||||
expect(generated).toContain('"errorBody"');
|
||||
expect(generated).toContain("return message + status + data");
|
||||
expect(generated).toContain("const status = $status");
|
||||
expect(generated).toContain("const message = $message");
|
||||
expect(generated).toContain("const data = $data");
|
||||
expect(generated).toContain("__wrnexusEvalError");
|
||||
});
|
||||
|
||||
test("an ssr block without an error section emits no catch entry for that binding", () => {
|
||||
const generated = serverModule(` api ssrUsers GET /api/users {
|
||||
response {
|
||||
return data.users.length
|
||||
}
|
||||
}`);
|
||||
|
||||
expect(generated).not.toContain('"errorBody"');
|
||||
});
|
||||
|
||||
test("tsc: a sectioned ssr block's generated module has no diagnostics", () => {
|
||||
const generated = serverModule(` api ssrUsers GET /api/users {
|
||||
response {
|
||||
return data.users.length
|
||||
}
|
||||
error {
|
||||
return message + status + data
|
||||
}
|
||||
}`);
|
||||
|
||||
const { ok, output } = typecheckGenerated(generated);
|
||||
|
||||
expect(output.trim()).toBe("");
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
test("an ssr block used in {#each} with an error section runs the error body on failure", async () => {
|
||||
const generated = generate(
|
||||
parse(`page Repro {
|
||||
ssr {
|
||||
api ssrUsers GET /api/users {
|
||||
response {
|
||||
return data.users
|
||||
}
|
||||
error {
|
||||
return ["fallback"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
|
||||
}
|
||||
`),
|
||||
);
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-each-"));
|
||||
roots.push(root);
|
||||
mkdirSync(root, { recursive: true });
|
||||
const file = join(root, "page.ts");
|
||||
writeFileSync(file, generated);
|
||||
|
||||
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
|
||||
const html = await mod.default({
|
||||
req: { url: "http://localhost/", headers: new Headers() },
|
||||
cookies: {},
|
||||
session: {},
|
||||
localStorage: {},
|
||||
__wrnexusCallApi: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
|
||||
expect(html).toContain("fallback");
|
||||
});
|
||||
|
||||
test("an ssr block's response body error is not swallowed by the error section", async () => {
|
||||
const generated = generate(
|
||||
parse(`page Repro {
|
||||
ssr {
|
||||
api ssrUsers GET /api/users {
|
||||
response {
|
||||
return data.users.missing.length
|
||||
}
|
||||
error {
|
||||
return ["fallback"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
|
||||
}
|
||||
`),
|
||||
);
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-response-throws-"));
|
||||
roots.push(root);
|
||||
mkdirSync(root, { recursive: true });
|
||||
const file = join(root, "page.ts");
|
||||
writeFileSync(file, generated);
|
||||
|
||||
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
|
||||
|
||||
await expect(
|
||||
mod.default({
|
||||
req: { url: "http://localhost/", headers: new Headers() },
|
||||
cookies: {},
|
||||
session: {},
|
||||
localStorage: {},
|
||||
__wrnexusCallApi: async () => ({ users: [] }),
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("an ssr block still runs the error body on a genuine transport failure", async () => {
|
||||
const generated = generate(
|
||||
parse(`page Repro {
|
||||
ssr {
|
||||
api ssrUsers GET /api/users {
|
||||
response {
|
||||
return data.users.length
|
||||
}
|
||||
error {
|
||||
return ["fallback"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
|
||||
}
|
||||
`),
|
||||
);
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-transport-fails-"));
|
||||
roots.push(root);
|
||||
mkdirSync(root, { recursive: true });
|
||||
const file = join(root, "page.ts");
|
||||
writeFileSync(file, generated);
|
||||
|
||||
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
|
||||
const html = await mod.default({
|
||||
req: { url: "http://localhost/", headers: new Headers() },
|
||||
cookies: {},
|
||||
session: {},
|
||||
localStorage: {},
|
||||
__wrnexusCallApi: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
|
||||
expect(html).toContain("fallback");
|
||||
});
|
||||
|
||||
test("an ssr block used in {#each} without an error section still propagates a failure", async () => {
|
||||
const generated = generate(
|
||||
parse(`page Repro {
|
||||
ssr {
|
||||
api ssrUsers GET /api/users {
|
||||
response {
|
||||
return data.users
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
|
||||
}
|
||||
`),
|
||||
);
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-each-propagate-"));
|
||||
roots.push(root);
|
||||
mkdirSync(root, { recursive: true });
|
||||
const file = join(root, "page.ts");
|
||||
writeFileSync(file, generated);
|
||||
|
||||
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
|
||||
|
||||
await expect(
|
||||
mod.default({
|
||||
req: { url: "http://localhost/", headers: new Headers() },
|
||||
cookies: {},
|
||||
session: {},
|
||||
localStorage: {},
|
||||
__wrnexusCallApi: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("boom");
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user