From 8e4132a0da12765303bcf7ba92e6e251fff47683 Mon Sep 17 00:00:00 2001 From: DevBot Date: Mon, 7 Sep 2026 15:56:11 +0800 Subject: [PATCH 01/17] docs(roadmap): adopt Beta.2 product convergence and public Alpha (ADR-0152) --- README.md | 8 +- README.zh.md | 6 + deno.json | 3 +- .../ADR-0151-v044-release-train-retopology.md | 2 + ...52-product-router-and-alpha-convergence.md | 131 +++++ docs/architecture/product-model.md | 35 +- docs/current/DENO_DESKTOP_TARGET.md | 2 +- docs/current/VERSION_PLAN.md | 240 ++++----- docs/current/v0.44.0-AUTONOMOUS-GOAL.md | 2 + docs/current/v0.44.0-EXECUTION-PLAN.md | 89 +--- docs/governance/GOVERNANCE_CONSTITUTION.md | 16 +- docs/governance/PROJECT_WORKFLOW.md | 36 +- docs/governance/RELEASE_CONTRACT.md | 10 +- docs/governance/RELEASE_POLICY.md | 23 +- docs/governance/V044_AGENT_LOOP_SOP.md | 18 +- docs/release/release-state.json | 4 +- docs/roadmap/ROADMAP.md | 86 ++- docs/roadmap/v0.44.0-ISSUES.md | 78 +-- docs/status/STATUS.md | 67 +-- tools/check-v044-orchestration.test.ts | 235 -------- tools/check-v044-orchestration.ts | 504 ------------------ tools/project-constants.ts | 8 +- 22 files changed, 430 insertions(+), 1173 deletions(-) create mode 100644 docs/adr/ADR-0152-product-router-and-alpha-convergence.md delete mode 100644 tools/check-v044-orchestration.test.ts delete mode 100644 tools/check-v044-orchestration.ts diff --git a/README.md b/README.md index 945ac1db7..5c3943f11 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ and interactive regions activate selectively. The published stable line remains the 0.43 series on npm `latest`. `v0.44.0-beta.2` is the current public v0.44 prerelease — Beta.2, Productization + Governance Offload (ADR-0151) — published under dist-tag -`beta`. The next stage is Beta.3 (`v0.44.0-beta.3`). +`beta`. The accepted next work is Beta.2.1–Beta.2.3 convergence, followed by public +`v1.0.0-alpha.1` after admission ([roadmap](docs/roadmap/ROADMAP.md)). The `1.0.0` target remains unscheduled and requires separate evidence and approval. ```text @@ -21,6 +22,11 @@ current proven scope = static-first applications with fullstack output paths Source package line: `0.44.0-beta.2` (`v0.44.0-beta.2`). npm registry line: `v0.44.0-beta.2` (prerelease, dist-tag `beta`); npm `latest` remains the stable `0.43.3` line. +The accepted product direction is **Element / UI / Router**, with Route Mode +and Framework Mode sharing one core. [Product boundaries](docs/architecture/product-model.md) +and [the active plan](docs/current/VERSION_PLAN.md) distinguish planned convergence +from the currently shipped package surface. + ## Why OpenElement lets one Custom Element contract work in a standalone library and diff --git a/README.zh.md b/README.zh.md index e579db4dd..f778d125f 100644 --- a/README.zh.md +++ b/README.zh.md @@ -12,6 +12,12 @@ DOM 是默认服务端表示;交互区域按需升级。 npm registry 行为 `v0.44.0-beta.2`——预发布版本(dist-tag `beta`);npm `latest` 仍为 已发布的稳定 0.43 线。 +已确认的产品方向是 **Element / UI / Router**。Route Mode 接受显式路由记录, +Framework Mode 默认由文件路由生成记录,两者共用内核。自维护 URLPatternList +是核心技术资产;每次替换同步清理旧实现。下一步为 Beta.2.1–Beta.2.3,验收后 +进入公开 `1.0.0-alpha.1`。详见[产品边界](docs/architecture/product-model.md)和 +[路线图](docs/roadmap/ROADMAP.md);以下包图描述当前已发布能力。 + ## 当前产品 ```text diff --git a/deno.json b/deno.json index 2fb6431d2..80a150804 100644 --- a/deno.json +++ b/deno.json @@ -42,8 +42,7 @@ "www:apply-seo": "deno run --allow-read --allow-write tools/apply-www-seo.ts", "www:pagefind": "cd www && deno run --config ../deno.json --allow-read --allow-write --allow-run --allow-env --allow-net --allow-ffi --allow-sys build-pagefind.ts", "preview": "cd www && deno run --allow-read --allow-write --allow-net --allow-env --allow-ffi npm:vite preview --config vite.config.ts", - "workflow:check": "deno run --allow-read --allow-run=git tools/check-project-workflow.ts && deno task v044:orchestration:check", - "v044:orchestration:check": "deno run --allow-read tools/check-v044-orchestration.ts", + "workflow:check": "deno run --allow-read --allow-run=git tools/check-project-workflow.ts", "v044:executor:check": "deno run --allow-read --allow-run=kimi tools/check-v044-executor.ts", "v044:role": "deno run --allow-read --allow-run tools/run-v044-role.ts", "docs:check-role-neutral": "deno run --allow-read tools/check-role-neutral-docs.ts", diff --git a/docs/adr/ADR-0151-v044-release-train-retopology.md b/docs/adr/ADR-0151-v044-release-train-retopology.md index 70555c939..4c0f9a84b 100644 --- a/docs/adr/ADR-0151-v044-release-train-retopology.md +++ b/docs/adr/ADR-0151-v044-release-train-retopology.md @@ -1,5 +1,7 @@ # ADR-0151: v0.44 release train retopology +> Future release scheduling is superseded by [ADR-0152](../adr/ADR-0152-product-router-and-alpha-convergence.md). Historical execution and release evidence below are retained; they do not schedule public 1.0 Alpha. + - Status: ACCEPTED (2026-09-02, maintainer directive) - Date: 2026-09-02 - Supersedes: the Beta-topology portion of ADR-0149 — the five-Beta mapping is diff --git a/docs/adr/ADR-0152-product-router-and-alpha-convergence.md b/docs/adr/ADR-0152-product-router-and-alpha-convergence.md new file mode 100644 index 000000000..67859220e --- /dev/null +++ b/docs/adr/ADR-0152-product-router-and-alpha-convergence.md @@ -0,0 +1,131 @@ +# ADR-0152: Three products, owned routing core and public 1.0 Alpha + +- Status: ACCEPTED (2026-09-07, explicit maintainer direction) +- Supersedes: ADR-0151's future release topology; upstream-only URLPatternList + policy in #1324; cleanup-only-at-Beta.2.3 sequencing; previous file-only Router + proposal. Historical release evidence remains unchanged. +- Preserves: ADR-0148 compiler/Vite boundary, exact-SHA release evidence, + protected promotion and release GO requirements, human RC/Stable authority. +- Tracking: [#1341](https://github.com/open-element/openelement/issues/1341). + +## Context + +Current package/tool boundaries obscure product responsibilities. Repeated route, +request, document and governance owners increase maintenance work. The maintainer +approved a three-working-day convergence target using existing implementations, +with continuous deletion and a real application slice before public Alpha admission. +This is a timebox target, not proof that implementation or publication is complete. + +## Decision + +### Products and dependencies + +The products are Element, UI and Router. Element owns compiled Web Components +execution, serialization and DOM claim/update. UI is a selected component library +built on Element, independent of Router. Router has two modes: + +- Route Mode consumes explicit records; its matching core is independent of + Element, Hono, Vite and filesystem access. +- Framework Mode defaults to file routing and adds page data, forms, Document, + Element rendering, navigation, SSR/SSG and official Vite integration. UI is optional. + +Explicit and generated records converge on one RouteTable/RouteResolution. +File paths own generated route paths; no duplicate `route.path` declaration. +Composition declares mount boundaries, deterministic order and collision behavior. +Explicit order is preserved; file generation owns its documented ordering policy. +A selected URL record owns its method map: unsupported methods return 405 rather +than falling through to a different URL pattern. Query parameters and path captures +stay separate. Public browser projections exclude server handlers and host bindings. + +Products are not package counts. Existing `app`, `adapter-vite` and `create` remain +implementation/distribution/tooling surfaces until a justified migration changes +exports. No mandatory package renaming or new broad UI design system is implied. + +### URLPatternList is an owned core asset + +Start from Justin Fagnani's `url-pattern-list` v0.5.0, source commit +`4911e649cc11860c7da90c9d0d9b05626c5cbb83`, with verified MIT attribution and a +compact provenance/divergence record. Own list indexing, ordered traversal, +differential tests and measured performance. Luca Casonato's proposals inform +structured matching and conservative fallback; they are not merged production +Deno implementations to transplant. + +URLPattern remains the platform/polyfill single-pattern grammar and capture +owner. Candidate pruning must not discard a valid earlier match; final `exec` +cannot recover a candidate already discarded by an incorrect index. Unproven +optimizations use a conservative candidate path merged in the same sequence order. +The linear reference oracle belongs in tests. No global API injection, claimed +standard polyfill, public tree/parser contract, or full replacement URLPattern +engine. Initial route tables may be rebuilt and atomically replaced; complex +incremental mutation and speculative native/WASM optimization are deferred. + +References: [Justin's source](https://github.com/justinfagnani/url-pattern-list), +[Luca's proposal](https://github.com/whatwg/urlpattern/pull/166), +[Deno experiment](https://github.com/denoland/deno/pull/14502). + +### Framework responsibility + +Hono owns HTTP Context/middleware/Response integration without a second page +winner. Useful page-data/form abstractions share one request lifecycle; preserve +validation errors, status, redirects and serialization boundaries. Document owns +resolved page meaning; Element serializes it. Layouts compose presentation without +a second data scheduler. Vite owns the official build/development integration. + +SSG may read external build-time data; personalized results must not become public +static output. Sitemap/search use eligible public route identity/catalog data, +not an enumeration of private request-specific Documents. Navigation coordinates +abort, stale results, history and required browser fallbacks. + +### Continuous reduction + +Every replacement retires its displaced implementation, callers, compatibility +layers, duplicated facts and obsolete checks/docs in the same verified change. +Cleanup begins in Beta.2.1, continues in Beta.2.2 and closes in Beta.2.3. Measure +owners, execution hops, custom scripts/tasks/checkers and retained obligations; +line deletion is not a quota. Preserve required behavior with regression evidence, +not permanent duplicate paths. Git is the default operational-history archive. + +The obsolete `check-v044-orchestration` script/test/task is retired with this +planning change: it pins current documents to completed Alpha workspace IDs and +withdrawn Beta scheduling. Current workflow, version/release truth and exact-SHA +release gates remain; no replacement historical-topology checker is introduced. + +### Release topology and timebox + +```text +published v0.44.0-beta.2 + -> beta.2.1 Router/core + continuous cleanup + -> beta.2.2 Framework/Document + continuous cleanup + -> beta.2.3 cleanup closure + application admission + -> public v1.0.0-alpha.1 and subsequent Alpha iterations + -> evidence-gated v1.0.0-rc.1 + -> separately admitted Stable +``` + +The former Beta.3 lane becomes 1.0 Alpha; its unfinished work remains tracked. +Historic v0.44 alpha workspace IDs remain internal/unpublished. Public 1.0 Alpha +uses npm `alpha`; npm `latest` stays on the last admitted stable release. + +Target three working days from implementation start: Day 1 core plus a minimal +Cloudflare/Vite spike, Day 2 representative Framework flow, Day 3 remaining cleanup +and admission evidence. Intermediate public Beta checkpoints retain release gates; +development may proceed on an accepted contract without waiting for publication. +No automatic deadline waiver or Beta.2.4. A preview may enter real application work +while release blockers remain explicitly open; it is not a completed Beta release. + +## Acceptance and consequences + +[#1340](https://github.com/open-element/openelement/issues/1340) owns Alpha admission: +matching/HTTP correctness, a packed real-app flow, SSR/SSG/claim/navigation/forms, +server/client separation, required runtime/browser coverage, cleanup and exact +candidate release evidence. Cloudflare is the first integration target; Node and +Deno retain full qualification in their agreed contract, Bun/Nitro their smoke +scope, Chromium/Firefox/WebKit their required browser coverage. Missing evidence +is never a pass. Broader benchmarks, prolonged application qualification and UI +expansion do not block entry unless they expose a core correctness failure. + +Release automation must implement the new successor/channel distinction (#1323, +#1334) before publication. This ADR and its documentation PR do not change package +versions, publish artifacts, grant Stable readiness, or claim runtime migration +has already happened. Planning may change now while code still implements the +published baseline; outstanding implementation is visible in the issue graph. diff --git a/docs/architecture/product-model.md b/docs/architecture/product-model.md index 979eab92d..295a40394 100644 --- a/docs/architecture/product-model.md +++ b/docs/architecture/product-model.md @@ -1,6 +1,33 @@ # Product model -OpenElement is a Web Components-native application framework. A standard Custom -Element is the durable component boundary. JSX is compiled authoring input, not a -public runtime virtual-DOM contract. The supported package graph contains `element`, -`app`, `adapter-vite`, `create`, and optional `ui`. +OpenElement has three products: **Element**, **UI** and **Router**. This is the +accepted target under [ADR-0152](../adr/ADR-0152-product-router-and-alpha-convergence.md); +Beta.2.x issues track implementation. It does not claim all target APIs already ship. + +| Product | Responsibility | Dependency boundary | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| Element | Compiled Web Components, reactivity, lifecycle, server serialization, fresh DOM and existing-DOM claim/update, styles | Independent component execution foundation | +| UI | Selected reusable components, interaction, accessibility, themes and composition | Element; Router optional | +| Router: Route Mode | Explicit records, matching/resolution, HTTP and browser integration entry points | Matching core independent of Element, Hono, Vite and filesystem | +| Router: Framework Mode | File routing by default, page data/forms/errors, layouts, Document, SSR/SSG, navigation and Vite integration | Same Router core plus Element; UI optional | + +A standard Custom Element is the durable component boundary. JSX is compiled +input, not a public runtime virtual-DOM contract. Element's compiler semantics +remain separate from Vite integration under ADR-0148. + +Explicit routes and file-generated routes share one RouteTable/RouteResolution. +File routes never require a second handwritten path declaration. The Router owns +composition/order/collision and HTTP policy; its self-maintained URLPatternList +owns candidate indexing and ordered matching. URLPattern owns single-pattern +syntax and captures. Public browser records exclude server-only code and bindings. + +Element execution and URLPatternList are strategic technical assets. Vite adapter, +CLI/create, deployment integration and compiler tooling are supporting surfaces, +not extra product lines. Current packages remain `element`, `app`, `adapter-vite`, +`create` and optional `ui` until an explicit export migration is implemented. +Product count is not npm package count. + +The initial application target is HTML-first content and dynamic business pages, +forms and local interactive components. UI stays selected; a full design system, +generic RPC/ORM/cache/queue framework and comprehensive offline application data +layer are outside this convergence sprint. diff --git a/docs/current/DENO_DESKTOP_TARGET.md b/docs/current/DENO_DESKTOP_TARGET.md index e0498b90a..d2fee796f 100644 --- a/docs/current/DENO_DESKTOP_TARGET.md +++ b/docs/current/DENO_DESKTOP_TARGET.md @@ -67,7 +67,7 @@ plain modules is mechanical, but the `render()` bodies then fail OEC9007 re-authoring of nine large renders against the compiled grammar with no browser E2E safety net — not a bounded fix. The ruling: the desktop examples are **excluded from qualifying consumer evidence** and the re-authoring is -carried to Beta.3 (B3.8). +carried to public 1.0 Alpha (#1311); examples remain frozen during Beta.2.x. `deno task check` and `deno task smoke` stay green and CI-gated (`examples:check`), so the examples still qualify the SPA runtime, loaders, diff --git a/docs/current/VERSION_PLAN.md b/docs/current/VERSION_PLAN.md index 0650851ed..3998d4e3a 100644 --- a/docs/current/VERSION_PLAN.md +++ b/docs/current/VERSION_PLAN.md @@ -1,150 +1,96 @@ -# v0.44 version plan +# Active version plan: Beta.2.x convergence to 1.0 Alpha OpenElement = Web Components-native fullstack application framework. -The published stable line remains the 0.43 series on npm `latest`. -`v0.44.0-beta.2` is the current public v0.44 prerelease (Beta.2 — -Productization + Governance Offload) under dist-tag `beta`, succeeding the -published Beta.1. The Beta.2 stage continues with three patch checkpoints per -ADR-0151 (as amended 2026-09-05): `v0.44.0-beta.2.1` (Router convergence) -> -`v0.44.0-beta.2.2` (Document convergence) -> `v0.44.0-beta.2.3` (repository / -governance closure), before Beta.3 (`v0.44.0-beta.3`, real workload / formal -benchmark / hardening). - -ADR-0147 defines the Alpha workspace train, which is complete through Alpha.9. -ADR-0151 retopologizes the release train and supersedes the ADR-0149 five-Beta -mapping; the remainder of ADR-0149 and ADR-0150 that is not about Beta topology -is unaffected. ADR-0146 remains the release-role authority and activates at -Beta.1. - -- Repository package line: `v0.44.0-beta.2` -- npm registry line: `v0.44.0-beta.2` (prerelease, dist-tag `beta`; npm `latest` remains the stable 0.43 line) -- Current source package line: `v0.44.0-beta.2` -- Current npm registry line: `v0.44.0-beta.2` -- Latest landed train: `v0.44.0-beta.2` -- Active internal target: none — internal Alpha checkpoints closed at Alpha.10 (verifier PASS, #1150); the active line is the public Beta train (ADR-0151) -- Active release target: `v0.44.0-beta.2` -- Next planned public train: `v0.44.0-beta.3` -- Next public prerelease: `v0.44.0-beta.3` - -The coherent five-package distribution contract follows -[PACKAGE_SURFACE.md](./PACKAGE_SURFACE.md) and ADR-0114. The supported server -integration remains `nitro-mount`. - -- Browser matrix: Chromium, Firefox and WebKit. - -## Alpha is an internal workspace train - -`alpha.0` is the common foundation. `alpha.1` through `alpha.10` are internal work -identifiers, not npm versions and not release candidates. No Alpha work package -creates a tag, npm publication, GitHub Release, dist-tag change, `main` promotion, -fresh release-verifier run, or unanimous three-role release GO. - -The three-role release loop is disabled throughout Alpha. It begins at Beta.1, after -the Alpha integration workspace has produced one coherent framework SHA. - -## Start condition - -Before cloning implementation workspaces: - -1. #1193 minimum `dev`/`main` history safety is active. -2. `v0.44.0-ALPHA-CONTRACT.md` freezes the minimum semantic seams already accepted - by ADR-0143 and proved by #1160. -3. `tools/config/v044-alpha-workspaces.json` records disjoint write ownership. -4. #1193 closure evidence records the exact fast-forwarded common base SHA. - -This is a short shared freeze, not a governance wave. - -## Internal Alpha workspaces - -Alpha.1 through Alpha.9 are complete. Alpha.10 is the active internal -checkpoint. - -| Internal ID | Workspace | Issues | Agent ownership | -| ----------- | --------------------------- | -------------------------------------------- | ---------------------------------------------------------------------- | -| alpha.1 | Compiler / Part Program | #1161 #1162 #1163 compiler slice | code, tests, fixtures and branch end-to-end | -| alpha.2 | Element Runtime / Signals | #1164 #1165 #1166 #723 #1167 | code, tests, fixtures and branch end-to-end | -| alpha.3 | SSR / DOM Claim | #1168 #1169 #1170 | code, tests, fixtures and branch end-to-end | -| alpha.4 | App / Islands / Delivery | #1088 #1171 #1172 #1173 #1163 delivery slice | code, tests, fixtures and branch end-to-end | -| alpha.5 | Replacement / Migration | #1174 | legacy-absence inventory, migration and removal changes | -| alpha.6 | Interoperability | #1175 | external-component corpus and fixes | -| alpha.7 | Performance / Qualification | #1176 | budgets, benchmarks, browser/runtime and packed-consumer qualification | -| alpha.8 | Final Integration | #1181 | absorb all accepted workspace SHAs and produce one coherent candidate | -| alpha.9 | Semantic Convergence | Alpha.9 umbrella and workstreams | prove one semantic owner before Beta.1 admission | -| alpha.10 | Truth Closure | #1209-#1220 under umbrella #1155 | close remaining truth drift; hard-block Beta.1 admission | - -The first seven workspaces begin from that exact base and run concurrently. A workspace -may use contract fixtures or mocks for another workspace, but may not introduce a -fallback architecture, private cross-package import, workspace alias, compatibility -shim, or unilateral shared-contract change. - -Each workspace has one agent that owns implementation, focused RED/GREEN tests, -targeted gates, its commits and its branch. There is no thinker/implementer handoff -inside Alpha and no release verifier. Cross-workspace questions go to the frozen -contract; genuine contract changes are recorded once and broadcast to every affected -workspace. - -## Alpha integration and semantic convergence - -The alpha.8 integration workspace is the only Alpha workspace that aggregates other -branches. Its agent: - -1. consumes the exact accepted source head SHA from alpha.1 through alpha.7; -2. integrates their reviewed commit series in dependency order through linear - cherry-picks and records the source-to-integrated SHA mapping; -3. resolves interface and integration failures in the integration workspace; -4. proves compiler -> Part Program -> SSR/fresh DOM/existing-DOM claim -> signals -> - islands -> Vite -> Nitro/server -> real application; -5. opens the single integration pull request to `dev`; and -6. accepts only the exact-SHA PR full CI result. - -Alpha.9 continues in the same integration worktree, branch and PR after the explicit -Alpha.8 checkpoint. It closes the ADR-0150 semantic workstreams, records current -ownership, and produces the one final exact-head candidate. Workspace branches run -targeted gates. They do not each run the full repository matrix. Failed integration -or convergence creates another PR #1199 candidate SHA, not a release-verifier session. - -Alpha.8 and Alpha.9 are complete: the integrated candidate landed on `dev` and the -ADR-0150 semantic workstreams closed with their recorded ownership evidence in -[SEMANTIC_OWNERSHIP.md](./SEMANTIC_OWNERSHIP.md). - -## Alpha.10 truth closure - -Alpha.10, the current internal checkpoint (ADR-0151), closes the remaining -release-truth drift left after Alpha.9 semantic convergence and is a hard blocker -for Beta.1 admission: Beta.1 may not begin until every Alpha.10 issue carries -exact closure evidence. Alpha.10 is governed by umbrella issue #1155 and the -"v0.44 Alpha.10 — internal (unpublished truth closure)" milestone; its work -issues are #1209 through #1220. It creates no tag, npm publication, GitHub -Release, dist-tag change or `main` promotion, and no Alpha.10 artifact is -published. - -## Beta, RC and the Stable/1.0 decision - -ADR-0151 defines the public train. Each phase answers one question: - -| Phase | Question | Responsibility | -| ---------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| Beta.1 | Is the framework itself trustworthy? | Framework qualification and governance freeze; first public v0.44 prerelease; three-role loop on | -| Beta.2 | Can external users really use and maintain it? | Productization and governance offload | -| Beta.3 | Can real workloads break the architecture? | Final hardening, formal benchmark and real SaaS qualification | -| RC1 | Did we misjudge the candidate? | Frozen candidate and soak | -| Stable/1.0 | — | Decided on Beta.3 evidence only; never pre-declared | - -The RC1 version string is `v1.0.0-rc.1` when the Beta.3 v1-admission assessment -passes, otherwise `v0.44.0-rc.1`. An unproven surface is never relabeled as 1.0. - -At Beta.1 and later public boundaries, the configured thinker, implementer and fresh -release verifier rules apply. PR CI remains the sole authoritative full matrix for an -exact SHA. `latest` remains on stable 0.43.x until an explicitly approved 0.44 Stable -release. - -Beta.1 branch convergence is fail closed: every remote head is classified, every open -PR is resolved or explicitly carried forward, and only an explicit reviewed deletion -list may run. Unknown-owned branches and local user worktrees are never bulk deleted. - -RC1 is the immutable frozen candidate. Any code, dependency, lockfile or artifact-byte -change after candidate freeze creates a new candidate and repeats qualification. RC -promotion does not rebuild the artifacts and requires explicit human GO. - -`internal alpha.1-alpha.9 workspaces (complete) -> internal alpha.10 truth closure (complete) -> beta.1 framework qualification + governance freeze (published) -> beta.2 productization + governance offload (published) -> beta.2.1 router convergence -> beta.2.2 document convergence -> beta.2.3 repository/governance closure (cumulative Beta.2 verifier) -> beta.3 final hardening + formal benchmark + real SaaS qualification -> RC1 frozen candidate / soak -> Stable/1.0 decision on Beta.3 evidence` +Current source package line: `v0.44.0-beta.2` +Current npm registry line: `v0.44.0-beta.2` (prerelease, dist-tag `beta`; npm `latest` remains the stable 0.43 line) +Latest landed train: `v0.44.0-beta.2` +Active release target: `v0.44.0-beta.2.1` +Active internal target: none — internal Alpha checkpoints closed at Alpha.10 (verifier PASS, #1150); the active line is Beta.2.x convergence before public 1.0 Alpha (ADR-0152) +Next planned public train: `v0.44.0-beta.2.1` + +The existing five-package distribution follows [PACKAGE_SURFACE.md](./PACKAGE_SURFACE.md) +and ADR-0114; the shipped `nitro-mount` integration remains until its replacement +is qualified. These are baseline facts, not restrictions on ADR-0152's target. +Browser qualification covers Chromium, Firefox and WebKit. + +Authority: [ADR-0152](../adr/ADR-0152-product-router-and-alpha-convergence.md). +Live train: [#1155](https://github.com/open-element/openelement/issues/1155). +Execution: [Project 3](https://github.com/orgs/open-element/projects/3). +Version anchors above remain synchronized projections required by existing tooling; +#1334 owns their generated-state consolidation. Actual version/publication state is in [release-state.json](../release/release-state.json) +and immutable release records; this plan describes intended work, not release proof. + +## Objective and scope + +Converge Element / selected UI / Router, with Route Mode explicit records and +Framework Mode file-generated records sharing one routing core. Own URLPatternList +as a strategic asset; reuse existing Element/app/Vite implementation. Continuously +remove displaced semantic owners and operational machinery. Enter real application +work through an evidence-gated public `1.0.0-alpha.1`. + +## Tasks and three-day execution target + +Three working days from implementation start, not three elapsed days from this +planning PR. An elapsed deadline does not complete an issue or authorize release. + +| Checkpoint | Target | Work and evidence | +| ---------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| Beta.2.1 / Day 1 | Router/core + cleanup | #1320: records/products #1338, owned fork #1324, resolution #1325, release semantics #1323; early Cloudflare/Vite page/form spike | +| Beta.2.2 / Day 2 | Framework/Document + cleanup | #1321: real flow #1339, resolved Document #1326, public metadata projections #1327 | +| Beta.2.3 / Day 3 | Remaining cleanup and admission | #1322: #1328-#1334, #1156, #1188/#1189 residual scope; Alpha admission #1340 | + +Every replacement removes its obsolete path and dependent wrappers/checkers/docs +with regression evidence. Day 3 does not postpone cleanup from earlier changes. +No package rename campaign, second matcher or broad compatibility scaffolding. +Examples remain frozen in this sprint; #1311 is carried to public Alpha. + +## Non-goals + +UI expansion, a complete design system, full URLPattern reimplementation, +speculative matcher acceleration, native/WASM ports, complex incremental table +mutation, generic bundler/platform expansion, RPC/ORM/queue/cache frameworks, +comprehensive offline data handling, arbitrary directory/filename refactoring. + +## Acceptance + +- Both route sources use one table; deterministic composition and conflict rules. +- URLPatternList agrees with the ordered reference oracle; safe conservative + matching is allowed, silent false negatives are not. +- URL record precedes method dispatch; HEAD/404/405/Allow and query/capture + boundaries are explicit. +- A real list/detail/form/validation/success/error flow works through direct access, + refresh, navigation, cancellation, SSR/SSG and Element claim/update. +- A fresh packed consumer installs, develops, builds and runs without private + imports or routine framework changes. Browser output excludes server modules. +- Removed responsibility/operational-surface diagnostics and justified survivors + are recorded. Incomplete Beta-owned work remains open. + +## Test matrix + +Use targeted regression and differential tests while changing a responsibility; +run the existing required exact-candidate checks before release. Cloudflare first +for the vertical slice; Node and Deno retain full qualification of the agreed +contract. Bun/Nitro retain their documented smoke scope. Chromium, Firefox and +WebKit verify the required navigation/claim behavior and browser fallback policy. +UI focus/keyboard/reconnect behavior used by the application is covered. Missing +runtime/browser evidence is not success, and primary-platform success is not a +waiver for the supported matrix. + +## Release evidence requirements + +`beta.2.1 -> beta.2.2 -> beta.2.3 -> 1.0.0-alpha.1 -> Alpha iterations -> RC -> Stable`. +Beta checkpoints are engineering boundaries; any actual public checkpoint retains +standing exact-SHA, provenance, protected promotion and release GO requirements. +Development depends on accepted integration contracts, not mandatory intermediate +npm publication. #1323/#1334 must update release automation before it selects or +publishes the new train. No automatic Beta.2.4 or schedule waiver. + +Public 1.0 Alpha uses npm `alpha`. Historic v0.44 alpha.0-alpha.10 were unpublished +workspace IDs and stay historical. `latest` remains stable until separately +admitted Stable publication. RC/Stable are unscheduled and evidence-gated. + +If Day 3 admission is blocked, identify the failing gate and retain open work. A +clearly labeled preview may be used for real application development; it is not +false Beta closure. Broader benchmarks, prolonged application qualification and +performance improvements remain in the 1.0 Alpha milestone, preserving their issues. diff --git a/docs/current/v0.44.0-AUTONOMOUS-GOAL.md b/docs/current/v0.44.0-AUTONOMOUS-GOAL.md index c1d3375d6..9e360b658 100644 --- a/docs/current/v0.44.0-AUTONOMOUS-GOAL.md +++ b/docs/current/v0.44.0-AUTONOMOUS-GOAL.md @@ -1,5 +1,7 @@ # Goal: complete v0.44 Alpha integration and semantic convergence +> Future release scheduling is superseded by [ADR-0152](../adr/ADR-0152-product-router-and-alpha-convergence.md). Historical execution and release evidence below are retained; they do not schedule public 1.0 Alpha. + Finish the v0.44 framework architecture by integrating the parallel Alpha workspaces at Alpha.8, then completing internal Alpha.9 semantic convergence in the same worktree, branch and PR. Do not activate the three-role release loop during Alpha. diff --git a/docs/current/v0.44.0-EXECUTION-PLAN.md b/docs/current/v0.44.0-EXECUTION-PLAN.md index b19686ca0..3dd9937ab 100644 --- a/docs/current/v0.44.0-EXECUTION-PLAN.md +++ b/docs/current/v0.44.0-EXECUTION-PLAN.md @@ -1,84 +1,9 @@ -# v0.44 internal Alpha integration and semantic-convergence execution plan +# Execution plan -The Alpha train uses parallel development in independent workspaces, then Alpha.8 -integration and Alpha.9 semantic convergence in one final integration workspace and -PR. It does not use the three-role release loop. +The internal v0.44 Alpha workspace train is historical and complete. Its exact +execution record remains in Git and the linked issues. It must not schedule the +current public release train. -## Foundation - -| Internal phase | Work | Exit | -| -------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------- | -| `alpha.0` | #1160 accepted -> #1182 accepted -> PR #1190 accepted -> #1193 minimum safety -> contract/path freeze | exact common workspace base; no publication | - -ADR-0149 replaces the old post-Alpha numbering. #1156 and #1187 move to Beta.2, -#1150 to Beta.3, #1157 #1158 #1159 #1177 to Beta.4, and #1188 #1189 remain under -#1192 for Beta.5 final hardening and independent SaaS qualification. - -## Workspace topology - -| Internal ID | Independent workspace | Issues | Owned result | Targeted gate | -| ----------- | --------------------------- | ----------------------------- | ----------------------------------------------- | --------------------------------------------- | -| alpha.1 | Compiler / Part Program | #1161 #1162 #1163 | deterministic program, codegen and diagnostics | compiler tests, transform fixtures, typecheck | -| alpha.2 | Runtime / Signals | #1164 #1165 #1166 #723 #1167 | Parts, Regions, subscriptions and cleanup | runtime, signal and DOM-update tests | -| alpha.3 | SSR / Claim | #1168 #1169 #1170 | server HTML, markers, claim and continuity | SSR fixtures, claim parity and negative cases | -| alpha.4 | App / Delivery | #1088 #1171 #1172 #1173 #1163 | islands, Vite, Nitro/server and consumer path | build/server/island/consumer smoke | -| alpha.5 | Replacement / Migration | #1174 | legacy absence and migration | absence searches and migration fixtures | -| alpha.6 | Interoperability | #1175 | external-component compatibility | interop corpus | -| alpha.7 | Performance / Qualification | #1176 | budgets, browsers/runtimes and packed consumers | benchmarks and qualification subsets | -| alpha.8 | Final Integration | #1181 | exact accepted SHAs combined into one framework | cross-workspace harness and PR CI | -| alpha.9 | Semantic Convergence | Alpha.9 umbrella/workstreams | one owner for admission-critical semantics | conformance, parity and final exact-head CI | - -## Agent ownership - -One agent owns each alpha.1-alpha.7 workspace end-to-end: implementation, tests, -focused RED/GREEN evidence, commits and branch. Agents do not hand code back through a -thinker/implementer loop and do not invoke a release verifier. - -All seven begin from the exact #1193 closure SHA and execute concurrently. -`v0.44.0-ALPHA-CONTRACT.md` freezes their semantic seams and -`tools/config/v044-alpha-workspaces.json` freezes write ownership. Each workspace -publishes its exact accepted source head SHA and targeted gate results to its issue. - -The Alpha integration agent is the only aggregator. It verifies accepted source -SHAs, linearly cherry-picks reviewed commit series, records source-to-integrated SHA -mappings, resolves integration conflicts, runs bounded cross-workspace tests, and -produces the Alpha.8 checkpoint, completes ADR-0150 Alpha.9 work in the same PR, and -produces new exact-head candidates until green. - -## CI evidence tier - -1. **Workspace tier:** each agent runs owned RED/GREEN tests, type/package checks and - the fast push tier. -2. **Integration tier:** alpha.8 runs create/serialize/claim/update and real - app/Vite/Nitro/consumer harnesses. -3. **Authoritative tier:** PR #1199 runs the only full matrix - for its exact SHA. Missing, stale or mismatched evidence fails closed. - -No internal Alpha ID causes a tag, npm publication, GitHub Release, dist-tag change, -`main` promotion, three-role GO or fresh release-verifier run. - -## Alpha.8 integration order - -```text -alpha.1 compiler ----┐ -alpha.2 runtime -----┼--> alpha.8 integration workspace --> exact-SHA PR CI --> dev -alpha.3 SSR/claim ---┤ -alpha.4 delivery ----┤ -alpha.5 migration ---┤ -alpha.6 interop -----┤ -alpha.7 qualification┘ -``` - -## After Alpha - -- Beta.1: independently qualify and publish the integrated framework, activate the - three-role release loop, preserve exact-SHA evidence and converge remote branches. -- Beta.2: #1156 #1187 release/governance foundations. -- Beta.3: #1150 integrated UI qualification. -- Beta.4: #1157 #1158 #1159 #1177 website/content/API/Starter qualification. -- Beta.5: #1192 #1188 #1189 final hardening and independent SaaS qualification of - immutable candidate artifacts. -- RC: #1178 admits the identical Beta.5 SHA and bytes only after explicit human GO. - -GitHub issues, branches, pull requests and check runs hold operational history. Raw -agent transcripts and per-attempt packets are not committed. +The active execution contract is [VERSION_PLAN.md](./VERSION_PLAN.md), accepted +by [ADR-0152](../adr/ADR-0152-product-router-and-alpha-convergence.md). The live +work map is [v0.44.0-ISSUES.md](../roadmap/v0.44.0-ISSUES.md). diff --git a/docs/governance/GOVERNANCE_CONSTITUTION.md b/docs/governance/GOVERNANCE_CONSTITUTION.md index 804ff3cb1..befe46492 100644 --- a/docs/governance/GOVERNANCE_CONSTITUTION.md +++ b/docs/governance/GOVERNANCE_CONSTITUTION.md @@ -13,7 +13,7 @@ product code; where it and an accepted ADR disagree, the ADR wins until amended. Authority chain, highest first: 1. accepted current ADRs (ADR-0146 three-role control plane, ADR-0148 compiler - boundary, ADR-0151 release-train topology, and their predecessors); + boundary, ADR-0152 release-train topology, and their predecessors); 2. this constitution; 3. `docs/current/VERSION_PLAN.md` and the governance SOPs (`PROJECT_WORKFLOW.md`, `RELEASE_POLICY.md`, `V044_ISSUE_SOP.md`, @@ -22,12 +22,10 @@ Authority chain, highest first: ## §1 Purpose and scope -The v0.44 train exists to prove one question per phase (ADR-0151): Beta.1 asks -whether the framework itself is trustworthy. Trust requires that every semantic -surface has exactly one owner, that every duplicate-looking implementation can -defend itself, and that every contribution carries evidence a fresh reviewer -can rerun. This constitution freezes those rules at Beta.1 so that the Beta.1 -hostile audit (#1222) and every later phase audit against a stable text. +The active train is governed by ADR-0152: Beta.2.1 converges Router/core, +Beta.2.2 converges Framework/Document, and Beta.2.3 closes remaining cleanup and +Alpha admission. Cleanup starts with each replacement. Public 1.0 Alpha owns +extended real-application qualification; RC/Stable remain evidence-gated. This document governs process and semantics. It authorizes no tag, release, package publication, dist-tag change or `main` promotion; release authority @@ -196,7 +194,7 @@ the primary enforcers, and audit issues such as #1222 apply this text to the exact candidate SHA. A phase that closes with an unresolved §4.3 failure or a §5.4 violation has not closed. -§6.3. Beta.1 freezes this text. Beta.2 and Beta.3 may amend it only through +§6.3. Beta.1 freezes this text. Beta.2.x and public 1.0 Alpha may amend it only through the §6.1 path; the full #1188 consolidation (ADR classification, contributor -information architecture, path migration) remains Beta.3 scope and does not +information architecture, path migration) is continuous Beta.2.x cleanup scope and does not relax any rule here in the meantime. diff --git a/docs/governance/PROJECT_WORKFLOW.md b/docs/governance/PROJECT_WORKFLOW.md index 98691fd07..e52c26bbd 100644 --- a/docs/governance/PROJECT_WORKFLOW.md +++ b/docs/governance/PROJECT_WORKFLOW.md @@ -1,5 +1,8 @@ # openElement AutoWorkflow +Current source package line `v0.44.0-beta.2`; +npm registry line `v0.44.0-beta.2` (prerelease, dist-tag `beta`). + > Status: Mandatory project workflow. Every human maintainer and AI assistant > must read this document before planning, implementing, reviewing, or releasing > work in this repository. @@ -11,28 +14,15 @@ complete because an issue, chat message, or SOP says it is complete. It is complete only when the repository contains the decision, the execution package, the implementation, and the gates that prove the claim. -Current execution anchor: - -- source package line `v0.44.0-beta.2`; -- npm registry line `v0.44.0-beta.2` (prerelease, dist-tag `beta`; npm `latest` - remains the stable 0.43 line); -- active target `v0.44.0-beta.1`; -- current internal checkpoint: none — the internal Alpha checkpoint train closed - at Alpha.10 (Truth Closure; verifier PASS, #1150); -- next public prerelease: `v0.44.0-beta.2`. - -ADR-0143 explicitly reopens the minor train after the 0.43 maintenance freeze. -The compiled OpenElement execution order — internal Alpha workspaces (complete -through Alpha.10) → Beta.1 framework qualification and -governance freeze → Beta.2 productization and governance offload → Beta.3 final -hardening, formal benchmark and real SaaS qualification → RC1 frozen candidate -and soak → Stable/1.0 decision on Beta.3 evidence — lives in -`docs/current/VERSION_PLAN.md` (ADR-0151); 0.43.x remains the stable -maintenance fallback until 0.44 reaches stable. -OpenElement is one Web Components-native, -static-first application framework: Basic Element is an authoring mode, not a -second product. Beta names product-qualification boundaries, not a second -architecture or product line. +Current execution direction is owned by +[VERSION_PLAN.md](../current/VERSION_PLAN.md) and +[ADR-0152](../adr/ADR-0152-product-router-and-alpha-convergence.md): Beta.2.x +convergence -> public 1.0 Alpha -> evidence-gated RC/Stable. Actual version and +publication facts remain in `docs/release/release-state.json`. + +The products are Element, UI and Router. Supporting packages are not additional +product lines. Cleanup is part of every replacement, beginning in Beta.2.1. +Historic v0.44 Alpha workspace instructions do not govern public 1.0 Alpha. ## Required Reading Order @@ -131,7 +121,7 @@ Use this order for a minor release: 6. run release gates including publish dry-run; 7. push `dev`; 8. wait for all `dev` CI jobs; -9. merge `dev` into `main`; +9. promote the exact accepted SHA to `main` through the protected release workflow; 10. wait for all `main` CI jobs; 11. create and push the release tag; 12. publish the GitHub release note; diff --git a/docs/governance/RELEASE_CONTRACT.md b/docs/governance/RELEASE_CONTRACT.md index 82472d849..dd4048d68 100644 --- a/docs/governance/RELEASE_CONTRACT.md +++ b/docs/governance/RELEASE_CONTRACT.md @@ -3,7 +3,7 @@ > Status: Mandatory POLICY for the v0.44 train, binding from Beta.1 (Framework > Qualification + Governance Freeze) onward. Adopted under ADR-0151 as the > Beta.1 release-contract instrument. Part of #1187 (Beta.1 slice per the -> thinker's 2026-09-03 scope rulings; the Beta.3 hardening remainder stays +> thinker's 2026-09-03 scope rulings; the 1.0 Alpha hardening remainder stays > open). Refs stage #1150, umbrella #1155. This contract specifies the release-binding rules that @@ -103,8 +103,8 @@ floor. Flaky or unavailable infrastructure is BLOCKED, never PASS. enforced procedurally through the closure report on the stage issue: the thinker verifies the three GOs and the §3.2 fields against durable records before preparing closure. The machine-readable closure-evidence gate (the -PR #1191 lineage) is explicitly Beta.3 scope: PR #1191 stays unmerged and is -carried to Beta.3 unchanged per its own deferral condition and the thinker's +PR #1191 lineage) is explicitly 1.0 Alpha scope: PR #1191 stays unmerged and is +carried to 1.0 Alpha unchanged per its own deferral condition and the thinker's 2026-09-03 amendment ruling on #1187. Nothing in this contract merges, rebases or modifies that tooling. @@ -112,8 +112,8 @@ rebases or modifies that tooling. §5.1. `v0.44.0-beta.1` publishes under a prerelease dist-tag (for example `beta`), never under `latest`. The `latest` dist-tag stays on the stable -0.43.x line until a Stable/1.0 decision is earned on Beta.3 evidence -(ADR-0151 §5). +0.43.x line until a Stable/1.0 decision is earned on 1.0 Alpha evidence +(ADR-0152). §5.2. The git tag and the GitHub Release notes for every v0.44 prerelease must state that the release is a prerelease published under a prerelease diff --git a/docs/governance/RELEASE_POLICY.md b/docs/governance/RELEASE_POLICY.md index 7676cb5f6..3801fab34 100644 --- a/docs/governance/RELEASE_POLICY.md +++ b/docs/governance/RELEASE_POLICY.md @@ -17,13 +17,18 @@ and GitHub Rulesets hold merge authority. Tags, Releases, assets, attestations, provenance are durable published-version proof; temporary Actions artifacts are not the sole future proof. -All Alpha identifiers are internal and unpublished: they receive no tag, npm -publication, GitHub Release, dist-tag or `main` promotion. ADR-0149 makes Beta.1 the -first public v0.44 framework qualification and assigns Beta.2 the final Trusted -Publishing, provenance and release-protection foundation. +Historic v0.44 alpha.0-alpha.10 identifiers were internal/unpublished and retain +that status. Under [ADR-0152](../adr/ADR-0152-product-router-and-alpha-convergence.md), +Beta.2.1/2.2/2.3 convergence is followed by public `1.0.0-alpha.1`, using npm +`alpha`. The `latest` dist-tag remains on the last admitted stable release. +Release automation must implement this distinction before publication (#1323/#1334). -Beta.5 is the immutable RC candidate. Independent SaaS qualification binds to its -exact commit SHA, package bytes, integrity records and provenance. RC may admit only -those identical artifacts after explicit human GO. Any code, dependency, lockfile or -artifact change creates a new Beta.5 candidate and invalidates the previous SaaS -qualification for promotion. +The three-working-day implementation target is not a release waiver. A blocked +candidate stays unpublished with explicit remaining issues. Public Beta checkpoints +and public 1.0 Alpha retain the existing exact-SHA and release GO requirements. + +RC admission follows sufficient 1.0 Alpha evidence (#1243), with no fixed date. +Independent application qualification binds to the candidate's exact SHA, package +bytes, integrity records and provenance. Any candidate-byte change invalidates its +qualification for promotion and requires a new qualified candidate. Stable admission +requires the separate human GO and final gate #37; Alpha is not a stability claim. diff --git a/docs/governance/V044_AGENT_LOOP_SOP.md b/docs/governance/V044_AGENT_LOOP_SOP.md index 1f56577ca..624610fce 100644 --- a/docs/governance/V044_AGENT_LOOP_SOP.md +++ b/docs/governance/V044_AGENT_LOOP_SOP.md @@ -68,7 +68,8 @@ The thinker performs these checks before selecting work: 1. Confirm the task model is the configured thinker model and reasoning effort is `low`. If the host cannot prove this, report the configuration requirement and stop. 2. Read the files in the execution plan's required order. -3. Run `deno task v044:orchestration:check`. +3. Run `deno task workflow:check` and the current release/version checks. The + completed Alpha workspace topology is no longer a current workflow gate. 4. Run `deno task v044:executor:check`. 5. Inspect `git status --short`, current branch, recent commits and open milestone issues. 6. Compare the JSON execution state with GitHub issue state and latest evidence. @@ -279,7 +280,7 @@ After an ordinary loop PASS, the thinker records: - immutable check-run or release links when they exist. Do not commit raw execution transcripts, conversation-derived summaries, or a growing -per-attempt evidence tree. Beta.3 may migrate durable historical records only after it +per-attempt evidence tree. Beta.2.x cleanup may migrate durable historical records only after it proves the replacement source is complete and records any required before/after blob identity without copying prohibited identifiers into current documentation. @@ -292,13 +293,12 @@ The bootstrap prompt may explicitly authorize the thinker to create scoped branc local commits, pushes, PRs and issue comments/closure after gates pass. Executor roles never perform those actions. -The active bootstrap authorizes the full prerelease release flow for `beta.1` through -`beta.3` — `dev`→`main` integration, version tag, -npm publication, dist-tag change, -GitHub Release, evidence and issue updates, and execution-cursor advancement — after a -unanimous implementer/release-verifier/thinker GO against the exact candidate SHA with -every deterministic gate green. The internal Alpha workspace train stays strictly -unpublished and outside this SOP: no tag, no publish, no release entry. +ADR-0152's public train includes `beta.2.N` and `1.0.0-alpha.N`. When release +execution is authorized, promotion, tags, npm publication, dist-tag changes and +GitHub Releases require unanimous implementer/release-verifier/thinker GO against +the exact candidate SHA with every required gate green. A planning update does +not itself authorize or perform publication. Historic v0.44 internal Alpha +workspace IDs stay unpublished and outside this public-release SOP. Exact-SHA integration topology: PR CI proves the exact PR head SHA, and that SHA is the candidate. `dev` advances only by fast-forward to the proved PR head (`git merge diff --git a/docs/release/release-state.json b/docs/release/release-state.json index 72562266a..905fcb148 100644 --- a/docs/release/release-state.json +++ b/docs/release/release-state.json @@ -3,7 +3,7 @@ "sourceVersion": "0.44.0-beta.2", "publishedVersion": "0.44.0-beta.2", "latestLandedTrain": "v0.44.0-beta.2", - "activeTarget": "v0.44.0-beta.2", - "nextPlannedTrain": "v0.44.0-beta.3", + "activeTarget": "v0.44.0-beta.2.1", + "nextPlannedTrain": "v0.44.0-beta.2.1", "maturity": "beta" } diff --git a/docs/roadmap/ROADMAP.md b/docs/roadmap/ROADMAP.md index 0cfc39b69..86d70e3fc 100644 --- a/docs/roadmap/ROADMAP.md +++ b/docs/roadmap/ROADMAP.md @@ -5,48 +5,46 @@ OpenElement = Web Components-native fullstack application framework. Source package line: `v0.44.0-beta.2`. npm registry line: `v0.44.0-beta.2` (prerelease, dist-tag `beta`). The npm `latest` dist-tag remains on the published stable 0.43 line. -Active execution target: `v0.44.0-beta.2`. +Active execution target: `v0.44.0-beta.2.1`. Latest landed train: `v0.44.0-beta.2`. -Next planned train: `v0.44.0-beta.3`. -Next public prerelease: `v0.44.0-beta.3`. -Long-term stable product target: `1.0.0` (unscheduled). - -Execution follows -[PROJECT_WORKFLOW.md](../governance/PROJECT_WORKFLOW.md). - -## Current: Beta.2 productization + governance offload - -The internal Alpha workspace train is complete through Alpha.10. Alpha.0 supplied -the accepted compiler proof, exact-SHA CI foundation and minimum history safety; -alpha.1 through alpha.7 ran as independent parallel workspaces; Alpha.8 was the -sole aggregation workspace; Alpha.9 closed semantic convergence before Beta.1 -admission; Alpha.10 (Truth Closure, umbrella issue #1155, work issues #1209 -through #1220) closed with verifier PASS at #1150 and admitted Beta.1. - -Alpha identifiers were internal work identifiers, not package releases, and the -three-role loop was off for all Alpha work. - -Beta.1 (`v0.44.0-beta.1`) is published as the first public v0.44 prerelease -under dist-tag `beta`; npm `latest` stays on the stable 0.43 line. Beta.2 -(`v0.44.0-beta.2`) is the active public prerelease line; the next stage is -Beta.3 (`v0.44.0-beta.3`). - -## Release train - -ADR-0151 defines the canonical train and supersedes the ADR-0149 five-Beta -mapping. Each phase answers one question: - -1. Beta.1 — Framework Qualification + Governance Freeze: is the framework itself - trustworthy? First public v0.44 prerelease (`v0.44.0-beta.1`), published - under dist-tag `beta`; the three-role release loop activated here. -2. Beta.2 — Productization + Governance Offload: can external users really use - and maintain it? -3. Beta.3 — Final Hardening + Formal Benchmark + Real SaaS Qualification: can - real workloads break the architecture? -4. RC1 — Frozen Candidate / Soak: did we misjudge the candidate? The RC1 version - string is `v1.0.0-rc.1` if the Beta.3 v1-admission assessment passes, - otherwise `v0.44.0-rc.1`. -5. Stable/1.0 is decided on Beta.3 evidence only and is never pre-declared; an - unproven surface is never relabeled as 1.0. - -The detailed mapping lives in `docs/roadmap/v0.44.0-ISSUES.md`. +Next planned train: `v0.44.0-beta.2.1`. +Stable `1.0.0` remains unscheduled. + +Execution follows [PROJECT_WORKFLOW.md](../governance/PROJECT_WORKFLOW.md). + +OpenElement's products are **Element / UI / Router**. See the +[product model](../architecture/product-model.md) and accepted +[ADR-0152](../adr/ADR-0152-product-router-and-alpha-convergence.md). + +## Active direction + +The published Beta.2 baseline is followed by three convergence checkpoints: + +1. **Beta.2.1:** general Router core, self-maintained URLPatternList, explicit and + file-generated records, shared resolution and immediate cleanup. +2. **Beta.2.2:** Framework Mode page/form/navigation lifecycle, Document, Element + and Vite integration, with replacement-time cleanup. +3. **Beta.2.3:** remaining repository/governance reduction and real-application + admission evidence. +4. **Public 1.0 Alpha:** real application development, compatibility fixes, + measured optimization and extended qualification. +5. **1.0 RC / Stable:** separately evidence-gated and unscheduled. + +Target three working days from implementation start to a usable real-app slice. +This is not a guarantee of full migration or permission to skip a release blocker. +The [active plan](../current/VERSION_PLAN.md) owns scope and acceptance; +[the issue map](./v0.44.0-ISSUES.md) links execution work. Every replacement includes +removing displaced code, callers, obsolete checks and duplicated current facts. + +## Tracking and state + +- [Train root #1155](https://github.com/open-element/openelement/issues/1155) +- [Approved plan #1341](https://github.com/open-element/openelement/issues/1341) +- [Execution Project](https://github.com/orgs/open-element/projects/3) +- [Release state](../release/release-state.json): actual versions and planned target +- [Release policy](../governance/RELEASE_POLICY.md): publication evidence + +The old Beta.3 lane is superseded; its open qualification work moves to 1.0 Alpha. +Historical internal v0.44 Alpha workspaces remain unpublished historical work IDs. +Upcoming 1.0 Alpha is public and uses npm `alpha`; `latest` remains the admitted +stable line. Changing this roadmap does not publish a version or complete a task. diff --git a/docs/roadmap/v0.44.0-ISSUES.md b/docs/roadmap/v0.44.0-ISSUES.md index 21ffa9855..2ae9343d1 100644 --- a/docs/roadmap/v0.44.0-ISSUES.md +++ b/docs/roadmap/v0.44.0-ISSUES.md @@ -1,49 +1,29 @@ -# v0.44 issue graph - -GitHub issue [#1155](https://github.com/open-element/openelement/issues/1155) is the -live train root. - -## Internal Alpha workspace train - -```text -alpha.0 foundation: #1160 #1182 PR #1190 #1193 + contract/path freeze - | - exact common base SHA - | -alpha.1 Compiler #1161 #1162 #1163(part) ----\ -alpha.2 Runtime #1164 #1165 #1166 #723 #1167 --\ -alpha.3 SSR/Claim #1168 #1169 #1170 -------------> alpha.8 integration #1181 -alpha.4 App #1088 #1171 #1172 #1173 #1163 --/ | -alpha.5 Migration #1174 --------------------------/ | -alpha.6 Interop #1175 -------------------------/ exact-SHA PR CI -> dev -alpha.7 Quality #1176 ------------------------/ -``` - -`alpha.0` through `alpha.8` are internal identifiers. None is publishable: no tag, -no npm publication, no GitHub Release, no dist-tag, no `main` promotion and no -external release action. - -Each alpha.1-alpha.7 workspace has one end-to-end agent. Alpha.8 has one integration -agent and is the only aggregator. The three-role loop is disabled for the complete -Alpha train. - -## Product and release ladder - -```text -internal alpha.8 integration - -> Beta.1 framework qualification, publication, three-role activation and branch convergence - -> Beta.2 release/governance foundations (#1156 #1187) - -> Beta.3 UI qualification (#1150) - -> Beta.4 website/content/API/Starter (#1157 #1158 #1159 #1177) - -> Beta.5 final hardening and immutable-artifact SaaS qualification (#1192 #1188 #1189) - -> exact-artifact RC admission with human GO (#1178) - -> Stable -``` - -Workspace agents run targeted validation. The alpha.8 PR owns the exact-SHA full -matrix. GitHub holds operational history; the repository does not duplicate raw -transcripts. - -#1193 owns the immutable common-base evidence. The collaboration contract lives in -`docs/current/v0.44.0-ALPHA-CONTRACT.md`; executable write ownership lives in -`tools/config/v044-alpha-workspaces.json`. +# Beta.2.x to 1.0 Alpha issue map + +[ADR-0152](../adr/ADR-0152-product-router-and-alpha-convergence.md) owns the +accepted direction. GitHub owns live task status; this is a responsibility map, +not a manually duplicated completion ledger. + +- [#1155](https://github.com/open-element/openelement/issues/1155): train root. +- [#1341](https://github.com/open-element/openelement/issues/1341): planning/docs/Project reconciliation. +- [#1288](https://github.com/open-element/openelement/issues/1288): Beta.2 umbrella. +- [Project 3](https://github.com/orgs/open-element/projects/3): execution board. + +| Phase | Parent | Work | +| ------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Beta.2.1 | #1320 | #1338 products/record model; #1324 owned URLPatternList; #1325 shared resolution; #1323 version and channel semantics | +| Beta.2.2 | #1321 | #1339 Framework application flow; #1326 Document; #1327 public metadata projections | +| Beta.2.3 | #1322 | #1328 lifecycle census; #1329 debris; #1330 tools; #1331 tasks; #1332 CI; #1333 fixtures; #1334 release-state authority; #1340 Alpha admission | +| Continuous cleanup | #1322 | #1156 mature tooling; #1188 governance/docs; #1189 repository weight; #1192 distinct residual closure obligations | +| 1.0 Alpha | #1155 | #1179 real SaaS; #1234-#1242 artifact/benchmark/performance/error/consumer/runtime/lifecycle qualification; #1187 release-hardening remainder; #1311 deferred examples | +| RC admission | #1243 | Assess Alpha evidence and either admit 1.0 RC or continue Alpha | +| RC / Stable | #1178 | #1244 soak; #1245 regression; #1246 triage; #1180 human GO; #37 final Stable gate | + +Dependencies: core contract -> Framework integration -> Alpha admission. The +Cloudflare/Vite spike starts during core work. Cleanup accompanies each replacement; +only its final residual closure waits for Beta.2.3. Intermediate npm publication +is not a prerequisite for implementing the next accepted contract. + +Historic Alpha workspace graphs and obsolete Beta numbering remain in Git/issue +history. Do not reopen completed work or close an unfinished feature because its +milestone was renamed. The three-day target never overrides evidence requirements. diff --git a/docs/status/STATUS.md b/docs/status/STATUS.md index 650df9584..e20dbaf89 100644 --- a/docs/status/STATUS.md +++ b/docs/status/STATUS.md @@ -1,45 +1,26 @@ # OpenElement status -Updated: 2026-09-02 - -- Repository package line: `v0.44.0-beta.2` -- npm registry line: `v0.44.0-beta.2` (prerelease, dist-tag `beta`) -- Latest landed train: `v0.44.0-beta.2` -- Active release target: `v0.44.0-beta.2` -- Next planned train: `v0.44.0-beta.3` -- Next public prerelease: `v0.44.0-beta.3` -- Published stable package line: `v0.43.3` on npm `latest` -- Current development mode: public Beta train — Beta.1 published as a - prerelease under dist-tag `beta`; Beta.2 in flight, next stage Beta.3 - (ADR-0151) -- Minimum branch rules: active on `dev` and `main` through ruleset `21775463` -- Deferred hardening: #1192 Beta.3 with #1156 #1187 #1188 #1189 -- Long-term `1.0.0` target: unscheduled - -Contributor execution follows -[PROJECT_WORKFLOW.md](../governance/PROJECT_WORKFLOW.md). The authoritative active -contract is [VERSION_PLAN.md](../current/VERSION_PLAN.md). - -## Current position - -PR #1194 landed the earlier acceleration baseline at exact SHA -`e3e7b8ae5ddc7faddb8267c36494be73f18701e8` with 9/9 PR checks green. ADR-0147 -supersedes that baseline's Alpha execution topology with the workspace train. - -PR #1195 landed the corrected workspace train at exact SHA -`cdfcb5433e58f9fde68377afc12643b045bfd385`. The exact #1193 closure SHA freezes the -collaboration contract and executable ownership map and is the alpha.1-alpha.7 common -base. - -Alpha.1 through Alpha.10 are complete: the parallel workspaces, the Alpha.8 -integration, the ADR-0150 Alpha.9 semantic convergence and the Alpha.10 Truth -Closure checkpoint (umbrella issue #1155, issue tree #1209 through #1220, -verifier PASS at #1150) all closed with their recorded evidence. Alpha -checkpoints were internal-only: no tag, npm publication, GitHub Release, -dist-tag change, `main` promotion or external release claim. - -Beta.1 (`v0.44.0-beta.1`) is published as the first public v0.44 prerelease -under dist-tag `beta` — the first phase of the -thinker/implementer/fresh-verifier release loop. The npm `latest` -dist-tag stays stable at -`0.43.3` until an explicitly approved 0.44 Stable release. +Repository package line: `v0.44.0-beta.2` +npm registry line: `v0.44.0-beta.2` (prerelease, dist-tag `beta`) +Active release target: `v0.44.0-beta.2.1` +Latest landed train: `v0.44.0-beta.2` +Next planned train: `v0.44.0-beta.2.1` +The npm `latest` dist-tag stays stable at `0.43.3`; Stable `1.0.0` is unscheduled. + +Execution follows [PROJECT_WORKFLOW.md](../governance/PROJECT_WORKFLOW.md). + +Current version and publication facts are owned by +[release-state.json](../release/release-state.json) and the corresponding immutable +release records. Package manifests own source package versions. + +The accepted development direction is Beta.2.1 Router/core, Beta.2.2 +Framework/Document and Beta.2.3 cleanup closure, followed by public 1.0 Alpha. +See [VERSION_PLAN.md](../current/VERSION_PLAN.md), +[ADR-0152](../adr/ADR-0152-product-router-and-alpha-convergence.md) and +[Project 3](https://github.com/orgs/open-element/projects/3) for scope and live work. +This planning update is not an implementation-completion or publication claim. + +Historical v0.44 internal Alpha workspaces are complete; their evidence stays in +issues and release history. Upcoming public 1.0 Alpha is a separate release phase. +Existing exact-SHA CI, provenance, protected promotion and release GO requirements +continue under [RELEASE_POLICY.md](../governance/RELEASE_POLICY.md). diff --git a/tools/check-v044-orchestration.test.ts b/tools/check-v044-orchestration.test.ts deleted file mode 100644 index 299b9b020..000000000 --- a/tools/check-v044-orchestration.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { assert, assertEquals } from '@std/assert'; -import { loadV044RoleConfig, V044_ROLE_CONFIG_PATH } from './config/load-v044-roles.ts'; -import { - type ReleaseDoctrineTexts, - validateAlphaWorkspaceConfig, - validateAlphaWorkspaceTopology, - validateExecutionState, - validateExecutorContract, - validateReleaseDoctrine, -} from './check-v044-orchestration.ts'; - -const config = await loadV044RoleConfig(); - -function validState(): Record { - return { - schemaVersion: 2, - train: '0.44.0', - status: 'READY', - currentIssue: 1193, - executionMode: 'independent-alpha-workspaces', - threeRoleLoopActive: false, - integrationBaseSha: null, - workspaces: Object.fromEntries([ - ...Array.from({ length: 7 }, (_, index) => [`alpha.${index + 1}`, 'PENDING_BASE']), - ['alpha.8', 'WAITING_FOR_WORKSPACE_SHAS'], - ]), - authoritativeCiOwner: 'alpha.8 exact-SHA pull request', - betaThreeRoleExecutorConfig: V044_ROLE_CONFIG_PATH, - parallelReady: true, - integrationBaseEvidenceIssue: 1193, - workspaceConfig: 'tools/config/v044-alpha-workspaces.json', - collaborationContract: 'docs/current/v0.44.0-ALPHA-CONTRACT.md', - }; -} - -Deno.test('Alpha workspace execution state validates without three-role sessions', () => { - assertEquals(validateExecutionState(validState(), config), []); -}); - -Deno.test('Alpha.8 plus Alpha.9 convergence keeps PR #1199 as exact-head authority', () => { - const state = validState(); - state.executionMode = 'alpha8-integration-plus-alpha9-semantic-convergence'; - state.authoritativeCiOwner = 'PR #1199 final exact head'; - (state.workspaces as Record)['alpha.9'] = 'BLOCKING_BETA1'; - assertEquals(validateExecutionState(state, config), []); -}); - -Deno.test('Alpha state rejects an active three-role loop or embedded role sessions', () => { - const active = validState(); - active.threeRoleLoopActive = true; - assert(validateExecutionState(active, config).some((failure) => failure.includes('disabled'))); - - const embedded = validState(); - embedded.implementer = { sessionId: 'forbidden' }; - embedded.releaseVerifier = { sessionId: 'forbidden' }; - const failures = validateExecutionState(embedded, config); - assert(failures.some((failure) => failure.includes('implementer'))); - assert(failures.some((failure) => failure.includes('releaseVerifier'))); -}); - -Deno.test('Alpha state rejects a missing workspace or non-authoritative CI owner', () => { - const missing = validState(); - delete (missing.workspaces as Record)['alpha.4']; - assert(validateExecutionState(missing, config).some((failure) => failure.includes('alpha.4'))); - - const wrongOwner = validState(); - wrongOwner.authoritativeCiOwner = 'every workspace'; - assert( - validateExecutionState(wrongOwner, config).some((failure) => failure.includes('PR #1199')), - ); -}); - -Deno.test('Beta executor capability remains pinned but is not activated by Alpha state', () => { - assertEquals(validateExecutorContract(config), []); - const drifted = structuredClone(config); - drifted.executor.contextTokens = 128000; - drifted.executor.defaultEffort = 'low'; - assert(validateExecutorContract(drifted).length >= 2); -}); - -Deno.test('executable Alpha workspace config has seven disjoint writers and one aggregator', async () => { - const workspaceConfig = JSON.parse( - await Deno.readTextFile('tools/config/v044-alpha-workspaces.json'), - ); - assertEquals(validateAlphaWorkspaceConfig(workspaceConfig), []); - - const overlap = structuredClone(workspaceConfig); - overlap.workspaces[1].writePaths.push(overlap.workspaces[0].writePaths[0]); - assert( - validateAlphaWorkspaceConfig(overlap).some((failure) => failure.includes('overlaps')), - ); - - const missing = structuredClone(workspaceConfig); - missing.workspaces = missing.workspaces.filter((entry: { id: string }) => entry.id !== 'alpha.6'); - assert(validateAlphaWorkspaceConfig(missing).some((failure) => failure.includes('alpha.6'))); -}); - -Deno.test('alpha.8 integration whitelist is required, bounded and safe', async () => { - const workspaceConfig = JSON.parse( - await Deno.readTextFile('tools/config/v044-alpha-workspaces.json'), - ); - - const absent = structuredClone(workspaceConfig); - delete absent.integration.writePaths; - assert( - validateAlphaWorkspaceConfig(absent).some((failure) => - failure.includes('write-path whitelist') - ), - ); - - const bare = structuredClone(workspaceConfig); - bare.integration.writePaths = ['packages/']; - assert( - validateAlphaWorkspaceConfig(bare).some((failure) => failure.includes('bare top-level')), - ); - - const unsafe = structuredClone(workspaceConfig); - unsafe.integration.writePaths = ['../outside.ts']; - assert(validateAlphaWorkspaceConfig(unsafe).some((failure) => failure.includes('unsafe'))); - - const glob = structuredClone(workspaceConfig); - glob.integration.writePaths = ['packages/element/**']; - assert(validateAlphaWorkspaceConfig(glob).some((failure) => failure.includes('unsafe'))); -}); - -function alphaPlan(): string { - return [ - 'The Alpha train uses parallel development in independent workspaces and one final integration workspace.', - 'It does not use the three-role release loop.', - '| `alpha.0` | #1160 #1182 #1193 | common base |', - '| alpha.1 | Compiler | #1161 #1162 #1163 |', - '| alpha.2 | Runtime | #1164 #1165 #1166 #723 #1167 |', - '| alpha.3 | SSR / Claim | #1168 #1169 #1170 |', - '| alpha.4 | App / Delivery | #1088 #1171 #1172 #1173 #1163 |', - '| alpha.5 | Migration | #1174 |', - '| alpha.6 | Interoperability | #1175 |', - '| alpha.7 | Qualification | #1176 |', - '| alpha.8 | Final Integration | #1181 |', - 'One agent owns each alpha.1-alpha.7 workspace end-to-end.', - 'The Alpha integration agent is the only aggregator.', - 'PR #1199 runs the only full matrix for its exact SHA.', - 'No internal Alpha ID causes a tag, npm publication, GitHub Release, dist-tag change, `main` promotion, three-role GO or fresh release-verifier run.', - 'Beta.1 activates the three-role release loop. Beta.3 owns #1192 #1156 #1187 #1188 #1189.', - '#1150 #1157 #1158 #1159 #1177 #1178', - ].join('\n'); -} - -Deno.test('internal Alpha workspace topology accepts alpha.1-alpha.8 and one aggregator', () => { - assertEquals(validateAlphaWorkspaceTopology(alphaPlan()), []); -}); - -Deno.test('internal Alpha workspace topology matches the live execution plan', async () => { - const plan = await Deno.readTextFile('docs/current/v0.44.0-EXECUTION-PLAN.md'); - assertEquals(validateAlphaWorkspaceTopology(plan), []); -}); - -Deno.test('workspace topology rejects missing workspaces and missing final aggregator', () => { - const missing = alphaPlan().replace( - '| alpha.4 | App / Delivery | #1088 #1171 #1172 #1173 #1163 |\n', - '', - ); - assert(validateAlphaWorkspaceTopology(missing).some((failure) => failure.includes('alpha.4'))); - - const noAggregator = alphaPlan().replace('is the only aggregator', 'is an aggregator'); - assert( - validateAlphaWorkspaceTopology(noAggregator).some((failure) => - failure.includes('only aggregator') - ), - ); -}); - -Deno.test('workspace topology rejects three-role Alpha execution and per-workspace full matrices', () => { - const roles = alphaPlan().replace( - 'It does not use the three-role release loop.', - 'Invoke the configured implementer and a fresh release verifier session for Alpha.', - ); - assert(validateAlphaWorkspaceTopology(roles).some((failure) => failure.includes('three-role'))); - - const matrices = alphaPlan().replace( - 'PR #1199 runs the only full matrix', - 'every workspace runs the full matrix', - ); - assert(validateAlphaWorkspaceTopology(matrices).some((failure) => failure.includes('PR #1199'))); -}); - -async function doctrineCorpus(): Promise { - return { - issueMap: await Deno.readTextFile('docs/roadmap/v0.44.0-ISSUES.md'), - versionPlan: await Deno.readTextFile('docs/current/VERSION_PLAN.md'), - alphaSop: await Deno.readTextFile('docs/governance/V044_ALPHA_WORKSPACE_SOP.md'), - alphaPrompt: await Deno.readTextFile( - 'docs/prompts/v0.44.0-ALPHA-SEVEN-SUBAGENTS.md', - ), - betaSop: await Deno.readTextFile('docs/governance/V044_AGENT_LOOP_SOP.md'), - betaPrompt: await Deno.readTextFile('docs/prompts/v0.44.0-THINKER-ORCHESTRATOR.md'), - plan: await Deno.readTextFile('docs/current/v0.44.0-EXECUTION-PLAN.md'), - }; -} - -Deno.test('real doctrine disables three-role Alpha and activates it at Beta.1', async () => { - assertEquals(validateReleaseDoctrine(await doctrineCorpus()), []); -}); - -Deno.test('doctrine rejects publishable Alpha, Alpha verifier use, executor preflight and missing Beta activation', async () => { - const publishable = await doctrineCorpus(); - publishable.issueMap += '\nAlpha.4 may publish to npm.'; - assert(validateReleaseDoctrine(publishable).some((failure) => failure.includes('publishable'))); - - const verifier = await doctrineCorpus(); - verifier.alphaSop += '\nStart a fresh release verifier for alpha.8.'; - assert(validateReleaseDoctrine(verifier).some((failure) => failure.includes('release verifier'))); - - const preflight = await doctrineCorpus(); - preflight.alphaSop = preflight.alphaSop.replace( - 'Do not run `v044:executor:check` during Alpha', - 'The executor preflight is optional during Alpha', - ); - assert( - validateReleaseDoctrine(preflight).some((failure) => failure.includes('v044:executor:check')), - ); - - const noBeta = await doctrineCorpus(); - noBeta.versionPlan = noBeta.versionPlan.replace('It begins at Beta.1', 'It stays disabled'); - assert(validateReleaseDoctrine(noBeta).some((failure) => failure.includes('Beta.1'))); -}); - -Deno.test('Beta SOP and prompt preserve exact-SHA fast-forward and #1178 stop', async () => { - const texts = await doctrineCorpus(); - for (const text of [texts.betaSop, texts.betaPrompt]) { - assert(text.includes('beta.1') || text.includes('Beta.1')); - assert(text.includes('beta.3') || text.includes('Beta.3')); - assert(text.includes('--ff-only')); - assert(text.includes('#1178')); - } -}); diff --git a/tools/check-v044-orchestration.ts b/tools/check-v044-orchestration.ts deleted file mode 100644 index cbe9e639b..000000000 --- a/tools/check-v044-orchestration.ts +++ /dev/null @@ -1,504 +0,0 @@ -/** Deterministic v0.44 Alpha-workspace and Beta-release orchestration gate. */ - -import { - loadV044RoleConfig, - V044_ROLE_CONFIG_PATH, - type V044RoleConfig, -} from './config/load-v044-roles.ts'; - -const root = new URL('../', import.meta.url); - -function requiredFiles(config: V044RoleConfig): string[] { - return [ - config.profiles.implementer.agentFile, - config.profiles.releaseVerifier.agentFile, - V044_ROLE_CONFIG_PATH, - 'tools/run-v044-role.ts', - 'tools/check-role-neutral-docs.ts', - 'docs/adr/ADR-0146-three-role-agent-execution-control-plane.md', - 'docs/adr/ADR-0147-internal-alpha-workspace-train.md', - 'docs/current/v0.44.0-ALPHA-CONTRACT.md', - 'docs/current/v0.44.0-AUTONOMOUS-GOAL.md', - 'docs/current/v0.44.0-EXECUTION-PLAN.md', - 'docs/current/v0.44.0-EXECUTION-STATE.json', - 'docs/current/VERSION_PLAN.md', - 'docs/governance/V044_ALPHA_WORKSPACE_SOP.md', - 'docs/governance/V044_AGENT_LOOP_SOP.md', - 'docs/governance/V044_ISSUE_SOP.md', - 'docs/prompts/v0.44.0-ALPHA-WORKSPACE-TRAIN.md', - 'docs/prompts/v0.44.0-ALPHA-SEVEN-SUBAGENTS.md', - 'docs/prompts/v0.44.0-THINKER-ORCHESTRATOR.md', - 'docs/roadmap/v0.44.0-ISSUES.md', - 'tools/config/v044-alpha-workspaces.json', - ]; -} - -const allowedStatuses = new Set([ - 'READY', - 'DISPATCHED', - 'IMPLEMENTED', - 'REVIEWED', - 'VERIFIED', - 'REPAIR', - 'VERSION_CLOSURE', - 'AWAITING_HUMAN_GO', - 'BLOCKED_DIRTY_WORKTREE', - 'BLOCKED_EXECUTOR_UNAVAILABLE', - 'BLOCKED_TRUTH_DRIFT', - 'BLOCKED_EXTERNAL', - 'COMPLETE', -]); - -const ALPHA_WORKSPACES = Array.from({ length: 8 }, (_, index) => `alpha.${index + 1}`); - -export function validateExecutionState( - state: Record, - config: V044RoleConfig, -): string[] { - const failures: string[] = []; - if (state.schemaVersion !== 2) failures.push('execution state schemaVersion must be 2'); - if (state.train !== '0.44.0') failures.push('execution state train must be 0.44.0'); - if (typeof state.status !== 'string' || !allowedStatuses.has(state.status)) { - failures.push(`execution state status is invalid: ${String(state.status)}`); - } - if (typeof state.currentIssue !== 'number' || !Number.isInteger(state.currentIssue)) { - failures.push('execution state currentIssue must be an integer'); - } - const alphaExecutionModes = new Set([ - 'independent-alpha-workspaces', - 'alpha8-integration-plus-alpha9-semantic-convergence', - ]); - if (typeof state.executionMode !== 'string' || !alphaExecutionModes.has(state.executionMode)) { - failures.push('Alpha executionMode must be the workspace or integration/convergence mode'); - } - if (state.threeRoleLoopActive !== false) { - failures.push('three-role loop must be disabled throughout Alpha'); - } - for (const forbidden of ['implementer', 'releaseVerifier']) { - if (forbidden in state) { - failures.push(`Alpha execution state must not embed ${forbidden} session state`); - } - } - const workspaces = state.workspaces; - if (!workspaces || typeof workspaces !== 'object') { - failures.push('execution state workspaces must be an object'); - } else { - for (const workspace of ALPHA_WORKSPACES) { - if (!(workspace in workspaces)) failures.push(`execution state omits ${workspace}`); - } - if ( - state.executionMode === 'alpha8-integration-plus-alpha9-semantic-convergence' && - !('alpha.9' in workspaces) - ) { - failures.push('Alpha integration/convergence state omits alpha.9'); - } - } - if ( - typeof state.authoritativeCiOwner !== 'string' || - !(state.authoritativeCiOwner.includes('alpha.8') || - state.authoritativeCiOwner.includes('PR #1199')) - ) { - failures.push('authoritative full CI owner must be PR #1199 exact head'); - } - if (state.betaThreeRoleExecutorConfig !== V044_ROLE_CONFIG_PATH) { - failures.push(`Beta executor configuration must reference ${V044_ROLE_CONFIG_PATH}`); - } - if (!config.profiles.implementer || !config.profiles.releaseVerifier) { - failures.push('Beta role profiles must remain configured for post-Alpha release work'); - } - if (state.parallelReady !== true) { - failures.push('Alpha workspace state must be parallelReady after #1193 closes'); - } - if (state.integrationBaseEvidenceIssue !== 1193) { - failures.push('Alpha common-base evidence must resolve from issue #1193'); - } - if (state.workspaceConfig !== 'tools/config/v044-alpha-workspaces.json') { - failures.push('Alpha state must reference the executable workspace configuration'); - } - if (state.collaborationContract !== 'docs/current/v0.44.0-ALPHA-CONTRACT.md') { - failures.push('Alpha state must reference the collaboration contract'); - } - return failures; -} - -/** Beta capability contract remains available but is not activated by Alpha state. */ -export function validateExecutorContract(config: V044RoleConfig): string[] { - const failures: string[] = []; - if (config.executor.contextTokens !== 262144) { - failures.push(`executor contextTokens must be 262144, got ${config.executor.contextTokens}`); - } - if (config.executor.defaultEffort !== 'high') { - failures.push(`executor defaultEffort must be high, got ${config.executor.defaultEffort}`); - } - for (const capability of ['thinking', 'tool_use']) { - if (!config.executor.requiredCapabilities.includes(capability)) { - failures.push(`executor requiredCapabilities must include ${capability}`); - } - } - return failures; -} - -const REQUIRED_ISSUES = [ - 723, - 1088, - 1150, - 1156, - 1157, - 1158, - 1159, - 1160, - 1161, - 1162, - 1163, - 1164, - 1165, - 1166, - 1167, - 1168, - 1169, - 1170, - 1171, - 1172, - 1173, - 1174, - 1175, - 1176, - 1177, - 1178, - 1181, - 1182, - 1187, - 1188, - 1189, - 1192, - 1193, -]; - -const WORKSPACE_ISSUES = [ - { workspace: 'alpha.1', issues: [1161, 1162, 1163] }, - { workspace: 'alpha.2', issues: [1164, 1165, 1166, 723, 1167] }, - { workspace: 'alpha.3', issues: [1168, 1169, 1170] }, - { workspace: 'alpha.4', issues: [1088, 1171, 1172, 1173, 1163] }, - { workspace: 'alpha.5', issues: [1174] }, - { workspace: 'alpha.6', issues: [1175] }, - { workspace: 'alpha.7', issues: [1176] }, - { workspace: 'alpha.8', issues: [1181] }, -] as const; - -type AlphaWorkspaceConfig = { - schemaVersion?: unknown; - commonBase?: Record; - contract?: unknown; - workspaces?: Array>; - integration?: Record; -}; - -export function validateAlphaWorkspaceConfig(raw: AlphaWorkspaceConfig): string[] { - const failures: string[] = []; - if (raw.schemaVersion !== 1) failures.push('Alpha workspace config schemaVersion must be 1'); - if (raw.commonBase?.branch !== 'dev' || raw.commonBase?.evidenceIssue !== 1193) { - failures.push('Alpha workspace config common base must resolve from dev and issue #1193'); - } - if (raw.contract !== 'docs/current/v0.44.0-ALPHA-CONTRACT.md') { - failures.push('Alpha workspace config must reference the collaboration contract'); - } - - const workspaces = raw.workspaces ?? []; - const owners = new Map(); - for (const expected of WORKSPACE_ISSUES.slice(0, 7)) { - const workspace = workspaces.find((entry) => entry.id === expected.workspace); - if (!workspace) { - failures.push(`Alpha workspace config omits ${expected.workspace}`); - continue; - } - const issues = Array.isArray(workspace.issues) ? workspace.issues : []; - for (const issue of expected.issues) { - if (!issues.includes(issue)) failures.push(`${expected.workspace} config omits #${issue}`); - } - const writePaths = Array.isArray(workspace.writePaths) ? workspace.writePaths : []; - if (writePaths.length === 0 || writePaths.some((path) => typeof path !== 'string')) { - failures.push(`${expected.workspace} must own one or more string write paths`); - continue; - } - for (const path of writePaths as string[]) { - if (path.startsWith('/') || path.includes('..')) { - failures.push(`${expected.workspace} has unsafe write path ${path}`); - } - for (const [ownedPath, owner] of owners) { - const overlap = path === ownedPath || - (path.endsWith('/') && ownedPath.startsWith(path)) || - (ownedPath.endsWith('/') && path.startsWith(ownedPath)); - if (overlap) { - failures.push(`${expected.workspace} write path ${path} overlaps ${owner}:${ownedPath}`); - } - } - owners.set(path, expected.workspace); - } - } - if (workspaces.length !== 7) { - failures.push('Alpha workspace config must contain exactly 7 writers'); - } - if ( - raw.integration?.id !== 'alpha.8' || raw.integration?.issue !== 1181 || - raw.integration?.role !== 'sole-aggregator' || raw.integration?.authoritativeFullCi !== true - ) { - failures.push('Alpha integration config must make alpha.8 the sole full-CI aggregator'); - } - // #1181: the alpha.8 restricted integration permission must be recorded as an - // executable write-path whitelist, not prose. Entries are repo-relative file - // paths or '/'-suffixed directory prefixes; bare top-level directories and - // glob/absolute/parent-escaping entries are rejected so the boundary stays - // bounded and machine-checkable. - const integrationPaths = raw.integration?.writePaths; - if ( - !Array.isArray(integrationPaths) || integrationPaths.length === 0 || - integrationPaths.some((path) => typeof path !== 'string') - ) { - failures.push('Alpha integration config must record a non-empty string write-path whitelist'); - } else { - const bareTopLevel = [ - 'packages/', - 'tools/', - 'docs/', - 'www/', - 'examples/', - 'tests/', - 'benchmarks/', - 'e2e/', - ]; - for (const path of integrationPaths as string[]) { - if ( - path === '' || path === '.' || path.startsWith('/') || path.includes('..') || - path.includes('*') - ) { - failures.push(`Alpha integration has unsafe write path ${path}`); - } else if (bareTopLevel.includes(path)) { - failures.push(`Alpha integration write path ${path} is a bare top-level directory`); - } - } - } - return failures; -} - -export function validateAlphaWorkspaceTopology(plan: string): string[] { - const failures: string[] = []; - const lines = plan.split('\n'); - const alphaZero = lines.find((line) => line.includes('`alpha.0`')); - if (!alphaZero) { - failures.push('execution plan omits alpha.0 foundation'); - } else { - for (const issue of [1160, 1182, 1193]) { - if (!alphaZero.includes(`#${issue}`)) failures.push(`alpha.0 foundation omits #${issue}`); - } - } - - for (const { workspace, issues } of WORKSPACE_ISSUES) { - const row = lines.find((line) => line.startsWith('|') && line.includes(workspace)); - if (!row) { - failures.push(`execution plan omits ${workspace} workspace`); - continue; - } - for (const issue of issues) { - if (!row.includes(`#${issue}`)) failures.push(`${workspace} workspace omits #${issue}`); - } - } - - const normalized = plan.replaceAll(/\s+/gu, ' '); - for ( - const required of [ - 'parallel development in independent workspaces', - 'one final integration workspace', - 'does not use the three-role release loop', - 'One agent owns each alpha.1-alpha.7 workspace end-to-end', - 'Alpha integration agent is the only aggregator', - 'PR #1199 runs the only full matrix', - 'No internal Alpha ID causes a tag', - 'Beta.1', - 'Beta.3', - ] - ) { - if (!normalized.includes(required)) failures.push(`execution plan omits "${required}"`); - } - if ( - /invoke the configured implementer/iu.test(plan) || - /fresh release verifier session/iu.test(plan) - ) { - failures.push('execution plan activates a three-role Alpha implementation or verifier flow'); - } - return failures; -} - -export interface ReleaseDoctrineTexts { - issueMap: string; - versionPlan: string; - alphaSop: string; - alphaPrompt: string; - betaSop: string; - betaPrompt: string; - plan: string; -} - -export function validateReleaseDoctrine(texts: ReleaseDoctrineTexts): string[] { - const failures: string[] = []; - const normalize = (text: string) => text.replaceAll(/^>\s?/gmu, '').replaceAll(/\s+/gu, ' '); - const issueMap = normalize(texts.issueMap); - const versionPlan = normalize(texts.versionPlan); - const alphaSop = normalize(texts.alphaSop); - const alphaPrompt = normalize(texts.alphaPrompt); - const betaSop = normalize(texts.betaSop); - const betaPrompt = normalize(texts.betaPrompt); - const plan = normalize(texts.plan); - - if (/alpha\.\d+\s+may\s+publish/iu.test(issueMap)) { - failures.push('issue map describes an internal Alpha identifier as publishable'); - } - for ( - const required of [ - 'internal identifiers', - 'no tag', - 'no npm publication', - 'no GitHub Release', - 'no dist-tag', - 'no `main` promotion', - 'three-role loop is disabled', - ] - ) { - if (!issueMap.includes(required)) failures.push(`issue map omits "${required}"`); - } - - for ( - const required of [ - 'internal work identifiers', - 'three-role release loop is disabled throughout Alpha', - 'It begins at Beta.1', - 'not npm versions', - ] - ) { - if (!versionPlan.includes(required)) failures.push(`version plan omits "${required}"`); - } - - for ( - const required of [ - 'The three-role release SOP does not apply during this phase', - 'One worktree, branch and writing agent per Alpha workspace', - 'Alpha.8 is created as the integration workspace', - 'No three-role GO or release-verifier session', - 'Do not run `v044:executor:check` during Alpha', - '`tools/config/v044-alpha-workspaces.json` is the executable write boundary', - ] - ) { - if (!alphaSop.includes(required)) failures.push(`Alpha workspace SOP omits "${required}"`); - } - if (/start a fresh release verifier/iu.test(alphaSop)) { - failures.push('Alpha workspace SOP starts a release verifier'); - } - - for ( - const required of [ - 'Launch one subagent per workspace', - 'fewer than seven subagent slots', - 'source-to-integrated SHA mapping', - 'Only alpha.8 opens a pull request to `dev`', - 'does not use the thinker, implementer or release-verifier loop', - ] - ) { - if (!alphaPrompt.includes(required)) failures.push(`seven-subagent prompt omits "${required}"`); - } - - for ( - const [name, text] of [ - ['Beta agent loop SOP', betaSop], - ['Beta bootstrap prompt', betaPrompt], - ] as const - ) { - for (const required of ['Beta.1', 'Beta.3', '--ff-only', '#1178']) { - if (!text.includes(required) && !text.includes(required.toLowerCase())) { - failures.push(`${name} omits "${required}"`); - } - } - } - if (!betaSop.includes('does not govern the internal Alpha workspace train')) { - failures.push('Beta agent loop SOP does not exclude Alpha'); - } - if (!betaPrompt.includes('Do not use this prompt during Alpha')) { - failures.push('Beta bootstrap prompt does not exclude Alpha'); - } - - for ( - const required of [ - 'does not use the three-role release loop', - 'No internal Alpha ID causes a tag', - 'PR #1199 runs the only full matrix', - ] - ) { - if (!plan.includes(required)) failures.push(`execution plan omits "${required}"`); - } - return failures; -} - -async function main(): Promise { - const failures: string[] = []; - const config = await loadV044RoleConfig(root); - - async function read(path: string): Promise { - try { - return await Deno.readTextFile(new URL(path, root)); - } catch (error) { - failures.push(`missing or unreadable ${path}: ${String(error)}`); - return ''; - } - } - - for (const file of requiredFiles(config)) await read(file); - - let state: Record = {}; - try { - state = JSON.parse(await read('docs/current/v0.44.0-EXECUTION-STATE.json')); - } catch (error) { - failures.push(`execution state is not valid JSON: ${String(error)}`); - } - failures.push(...validateExecutionState(state, config)); - failures.push(...validateExecutorContract(config)); - - let alphaWorkspaceConfig: AlphaWorkspaceConfig = {}; - try { - alphaWorkspaceConfig = JSON.parse( - await read('tools/config/v044-alpha-workspaces.json'), - ) as AlphaWorkspaceConfig; - } catch (error) { - failures.push(`Alpha workspace config is not valid JSON: ${String(error)}`); - } - failures.push(...validateAlphaWorkspaceConfig(alphaWorkspaceConfig)); - - const plan = await read('docs/current/v0.44.0-EXECUTION-PLAN.md'); - const issueMap = await read('docs/roadmap/v0.44.0-ISSUES.md'); - for (const issue of REQUIRED_ISSUES) { - if (!plan.includes(`#${issue}`)) failures.push(`execution plan omits #${issue}`); - if (!issueMap.includes(`#${issue}`)) failures.push(`issue map omits #${issue}`); - } - failures.push(...validateAlphaWorkspaceTopology(plan)); - - failures.push(...validateReleaseDoctrine({ - issueMap, - versionPlan: await read('docs/current/VERSION_PLAN.md'), - alphaSop: await read('docs/governance/V044_ALPHA_WORKSPACE_SOP.md'), - alphaPrompt: await read('docs/prompts/v0.44.0-ALPHA-SEVEN-SUBAGENTS.md'), - betaSop: await read('docs/governance/V044_AGENT_LOOP_SOP.md'), - betaPrompt: await read('docs/prompts/v0.44.0-THINKER-ORCHESTRATOR.md'), - plan, - })); - - if (failures.length > 0) { - console.error('v0.44 orchestration check failed:'); - for (const failure of failures) console.error(`- ${failure}`); - Deno.exit(1); - } - - console.log( - `v0.44 orchestration check passed (${requiredFiles(config).length} control files, ` + - `${REQUIRED_ISSUES.length} scheduled issues, 8 internal Alpha workspaces, ` + - 'three-role release loop deferred to Beta.1).', - ); -} - -if (import.meta.main) await main(); diff --git a/tools/project-constants.ts b/tools/project-constants.ts index dc82eb09f..4383018c8 100644 --- a/tools/project-constants.ts +++ b/tools/project-constants.ts @@ -9,15 +9,15 @@ export const PACKAGE_VERSION_TAG = `v${PACKAGE_VERSION}`; // Release-train truth is intentionally separate from the package and registry // line: work may be landed on main before the next package is published. export const LATEST_LANDED_TRAIN = 'v0.44.0-beta.2'; -export const ACTIVE_EXECUTION_VERSION = 'v0.44.0-beta.2'; -export const NEXT_EXECUTION_VERSION = 'v0.44.0-beta.3'; +export const ACTIVE_EXECUTION_VERSION = 'v0.44.0-beta.2.1'; +export const NEXT_EXECUTION_VERSION = 'v0.44.0-beta.2.1'; // Internal v0.44 admission checkpoints are not package versions. The Alpha // checkpoint train closed at Alpha.10 (verifier PASS, #1150); with Beta.1 // published as a public prerelease there is no active internal checkpoint — // the value records that closure so the VERSION_PLAN anchor stays honest. export const ACTIVE_INTERNAL_CHECKPOINT = - 'none — internal Alpha checkpoints closed at Alpha.10 (verifier PASS, #1150); the active line is the public Beta train (ADR-0151)'; -export const NEXT_PUBLIC_PRERELEASE = 'v0.44.0-beta.3'; + 'none — internal Alpha checkpoints closed at Alpha.10 (verifier PASS, #1150); the active line is Beta.2.x convergence before public 1.0 Alpha (ADR-0152)'; +export const NEXT_PUBLIC_PRERELEASE = 'v0.44.0-beta.2.1'; export const RETAINED_PACKAGE_NAMES = Object.freeze([ '@openelement/adapter-vite', '@openelement/app', From 0d826954cb96b3a9306119830defd6000a798c95 Mon Sep 17 00:00:00 2001 From: DevBot Date: Mon, 7 Sep 2026 16:09:44 +0800 Subject: [PATCH 02/17] docs(plan): focus Element and Router cores with UI dogfood (ADR-0152) --- README.md | 5 +-- README.zh.md | 2 +- ...52-product-router-and-alpha-convergence.md | 32 ++++++++++++++++--- docs/architecture/product-model.md | 27 ++++++++++++++-- docs/current/VERSION_PLAN.md | 23 +++++++++---- docs/governance/PROJECT_WORKFLOW.md | 2 +- docs/roadmap/ROADMAP.md | 8 +++-- docs/roadmap/v0.44.0-ISSUES.md | 4 +-- packages/ui/README.md | 7 +++- 9 files changed, 86 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 5c3943f11..c540595e9 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,9 @@ current proven scope = static-first applications with fullstack output paths Source package line: `0.44.0-beta.2` (`v0.44.0-beta.2`). npm registry line: `v0.44.0-beta.2` (prerelease, dist-tag `beta`); npm `latest` remains the stable `0.43.3` line. -The accepted product direction is **Element / UI / Router**, with Route Mode -and Framework Mode sharing one core. [Product boundaries](docs/architecture/product-model.md) +The accepted core products are **Element / Router**, with Route Mode +and Framework Mode sharing one Router core. UI is dogfood and a reference +implementation, not a third core product. [Product boundaries](docs/architecture/product-model.md) and [the active plan](docs/current/VERSION_PLAN.md) distinguish planned convergence from the currently shipped package surface. diff --git a/README.zh.md b/README.zh.md index f778d125f..c2208d556 100644 --- a/README.zh.md +++ b/README.zh.md @@ -12,7 +12,7 @@ DOM 是默认服务端表示;交互区域按需升级。 npm registry 行为 `v0.44.0-beta.2`——预发布版本(dist-tag `beta`);npm `latest` 仍为 已发布的稳定 0.43 线。 -已确认的产品方向是 **Element / UI / Router**。Route Mode 接受显式路由记录, +已确认的核心产品是 **Element / Router**,UI 用作 dogfood 和参考实现,不是第三条核心产品线。Route Mode 接受显式路由记录, Framework Mode 默认由文件路由生成记录,两者共用内核。自维护 URLPatternList 是核心技术资产;每次替换同步清理旧实现。下一步为 Beta.2.1–Beta.2.3,验收后 进入公开 `1.0.0-alpha.1`。详见[产品边界](docs/architecture/product-model.md)和 diff --git a/docs/adr/ADR-0152-product-router-and-alpha-convergence.md b/docs/adr/ADR-0152-product-router-and-alpha-convergence.md index 67859220e..b89e05157 100644 --- a/docs/adr/ADR-0152-product-router-and-alpha-convergence.md +++ b/docs/adr/ADR-0152-product-router-and-alpha-convergence.md @@ -1,4 +1,4 @@ -# ADR-0152: Three products, owned routing core and public 1.0 Alpha +# ADR-0152: Element / Router cores, reference UI and public 1.0 Alpha - Status: ACCEPTED (2026-09-07, explicit maintainer direction) - Supersedes: ADR-0151's future release topology; upstream-only URLPatternList @@ -20,9 +20,10 @@ This is a timebox target, not proof that implementation or publication is comple ### Products and dependencies -The products are Element, UI and Router. Element owns compiled Web Components -execution, serialization and DOM claim/update. UI is a selected component library -built on Element, independent of Router. Router has two modes: +The core products are Element and Router. UI is dogfood and a reference implementation. Element owns compiled Web Components +execution, serialization and DOM claim/update. UI is a selected component reference +built on Element, independent of Router. It validates authoring, interoperability +and real application use; it does not promise a comprehensive design system. Router has two modes: - Route Mode consumes explicit records; its matching core is independent of Element, Hono, Vite and filesystem access. @@ -41,6 +42,29 @@ Products are not package counts. Existing `app`, `adapter-vite` and `create` rem implementation/distribution/tooling surfaces until a justified migration changes exports. No mandatory package renaming or new broad UI design system is implied. +### Maintainer refinement: two core products and independent Element delivery + +The 2026-09-07 follow-up refines the earlier three-product wording: Element and +Router are the core products; Framework Mode belongs to Router. UI is dogfood and +reference implementation, alongside the website/representative consumer application. +Its current package may remain available without an independent design-system +roadmap. Do not force two physical npm packages or replace all UI in this sprint. + +The private compiler has an explicit distribution path through Element tooling; +published artifacts cannot depend on unpublished workspaces. Compiler and runtime +meet at versioned generated artifacts. Router tooling reuses Element's build path, +never defines component semantics or ships a duplicate compiler. Separate build, +browser, server and declaration entry graphs; preserve source maps and compiler +source diagnostics. Public tooling subpaths are permitted without publishing a +stable compiler API or hiding Vite behind a second configuration system. + +Standalone qualification means author -> compile -> pack -> ordinary HTML consumer, +without Router or a workspace alias. A lightweight Element runtime dependency is +allowed; compiler/Node/Vite code must not leak into browser output or browser types. +Foreign CE property/attribute/event/upgrade boundaries need real browser evidence. +Internal foreign SSR/hydration remains component-specific, not a universal DSD +promise. Broader React/Vue/etc. integration matrices are Alpha follow-up work. + ### URLPatternList is an owned core asset Start from Justin Fagnani's `url-pattern-list` v0.5.0, source commit diff --git a/docs/architecture/product-model.md b/docs/architecture/product-model.md index 295a40394..ffdf66784 100644 --- a/docs/architecture/product-model.md +++ b/docs/architecture/product-model.md @@ -1,13 +1,14 @@ # Product model -OpenElement has three products: **Element**, **UI** and **Router**. This is the +OpenElement has two core products: **Element** and **Router**. UI is dogfood +and a reference implementation, not a third core product. This is the accepted target under [ADR-0152](../adr/ADR-0152-product-router-and-alpha-convergence.md); Beta.2.x issues track implementation. It does not claim all target APIs already ship. | Product | Responsibility | Dependency boundary | | ---------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | Element | Compiled Web Components, reactivity, lifecycle, server serialization, fresh DOM and existing-DOM claim/update, styles | Independent component execution foundation | -| UI | Selected reusable components, interaction, accessibility, themes and composition | Element; Router optional | +| UI (reference) | Dogfood components demonstrating interaction, accessibility, themes and composition | Element; Router optional | | Router: Route Mode | Explicit records, matching/resolution, HTTP and browser integration entry points | Matching core independent of Element, Hono, Vite and filesystem | | Router: Framework Mode | File routing by default, page data/forms/errors, layouts, Document, SSR/SSG, navigation and Vite integration | Same Router core plus Element; UI optional | @@ -28,6 +29,26 @@ not extra product lines. Current packages remain `element`, `app`, `adapter-vite Product count is not npm package count. The initial application target is HTML-first content and dynamic business pages, -forms and local interactive components. UI stays selected; a full design system, +forms and local interactive components. UI stays a selected dogfood/reference surface; a full design system, generic RPC/ORM/cache/queue framework and comprehensive offline application data layer are outside this convergence sprint. + +## Standalone delivery and reference applications + +Element must be independently authored, built, packed and consumed from plain HTML +without Router or private workspace dependencies. Its compiler remains private, +with a versioned artifact contract; build integration delivers the compiler through +an explicit tooling entry. Runtime/browser/type entry graphs must exclude Vite, +TypeScript compiler and Node-only implementation. Framework tooling reuses the same +Element compilation path; it does not own Element language semantics. + +UI supplies selected dogfood components. The website and a representative consumer +supply reference application evidence. Their defects can reveal core blockers, but +component count, UI redesign, replacement with Web Awesome, and reference-site +expansion are not independent Beta.2.x release objectives. The existing ui package +may remain published; product demotion is not a package removal or migration claim. + +Foreign Custom Elements are opaque browser-standard boundaries. OE binds host +properties/attributes/events/slots without compiling their internals. Foreign SSR +and hydration require an explicit supported integration; DSD alone is not a shared +hydration protocol. No mandatory Web Awesome dependency or wrapper library. diff --git a/docs/current/VERSION_PLAN.md b/docs/current/VERSION_PLAN.md index 3998d4e3a..ef5fda3f6 100644 --- a/docs/current/VERSION_PLAN.md +++ b/docs/current/VERSION_PLAN.md @@ -23,7 +23,7 @@ and immutable release records; this plan describes intended work, not release pr ## Objective and scope -Converge Element / selected UI / Router, with Route Mode explicit records and +Converge Element and Router, with UI as dogfood/reference, with Route Mode explicit records and Framework Mode file-generated records sharing one routing core. Own URLPatternList as a strategic asset; reuse existing Element/app/Vite implementation. Continuously remove displaced semantic owners and operational machinery. Enter real application @@ -34,11 +34,11 @@ work through an evidence-gated public `1.0.0-alpha.1`. Three working days from implementation start, not three elapsed days from this planning PR. An elapsed deadline does not complete an issue or authorize release. -| Checkpoint | Target | Work and evidence | -| ---------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| Beta.2.1 / Day 1 | Router/core + cleanup | #1320: records/products #1338, owned fork #1324, resolution #1325, release semantics #1323; early Cloudflare/Vite page/form spike | -| Beta.2.2 / Day 2 | Framework/Document + cleanup | #1321: real flow #1339, resolved Document #1326, public metadata projections #1327 | -| Beta.2.3 / Day 3 | Remaining cleanup and admission | #1322: #1328-#1334, #1156, #1188/#1189 residual scope; Alpha admission #1340 | +| Checkpoint | Target | Work and evidence | +| ---------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Beta.2.1 / Day 1 | Router/core + cleanup | #1320: core products/records/Element delivery contract #1338, owned fork #1324, resolution #1325, release semantics #1323; early Cloudflare/Vite page/form spike | +| Beta.2.2 / Day 2 | Framework/Document + cleanup | #1321: standalone Element + reference application flow #1339, resolved Document #1326, public metadata projections #1327 | +| Beta.2.3 / Day 3 | Remaining cleanup and admission | #1322: #1328-#1334, #1156, #1188/#1189 residual scope; Alpha admission #1340 | Every replacement removes its obsolete path and dependent wrappers/checkers/docs with regression evidence. Day 3 does not postpone cleanup from earlier changes. @@ -47,13 +47,22 @@ Examples remain frozen in this sprint; #1311 is carried to public Alpha. ## Non-goals -UI expansion, a complete design system, full URLPattern reimplementation, +UI expansion or wholesale replacement, a complete design system, public compiler API, +mandatory two-package consolidation, full URLPattern reimplementation, speculative matcher acceleration, native/WASM ports, complex incremental table mutation, generic bundler/platform expansion, RPC/ORM/queue/cache frameworks, comprehensive offline data handling, arbitrary directory/filename refactoring. ## Acceptance +- Element independently compiles/packs and runs in a plain HTML consumer without + Router or private workspace imports. The private compiler is delivered through + tooling; its versioned artifacts work with the admitted runtime. Browser JS and + type graphs exclude compiler/Vite/Node-only dependencies. +- UI and the representative application provide dogfood/reference evidence, not a + third product-release checklist. Existing UI remains usable; no forced replacement. +- Foreign CE host binding/upgrade behavior is tested; internal foreign SSR/hydration + is supported only through an explicit integration, not inferred from DSD. - Both route sources use one table; deterministic composition and conflict rules. - URLPatternList agrees with the ordered reference oracle; safe conservative matching is allowed, silent false negatives are not. diff --git a/docs/governance/PROJECT_WORKFLOW.md b/docs/governance/PROJECT_WORKFLOW.md index e52c26bbd..88364f067 100644 --- a/docs/governance/PROJECT_WORKFLOW.md +++ b/docs/governance/PROJECT_WORKFLOW.md @@ -20,7 +20,7 @@ Current execution direction is owned by convergence -> public 1.0 Alpha -> evidence-gated RC/Stable. Actual version and publication facts remain in `docs/release/release-state.json`. -The products are Element, UI and Router. Supporting packages are not additional +The core products are Element and Router. UI is dogfood and a reference implementation. Supporting packages are not additional product lines. Cleanup is part of every replacement, beginning in Beta.2.1. Historic v0.44 Alpha workspace instructions do not govern public 1.0 Alpha. diff --git a/docs/roadmap/ROADMAP.md b/docs/roadmap/ROADMAP.md index 86d70e3fc..68a04ca04 100644 --- a/docs/roadmap/ROADMAP.md +++ b/docs/roadmap/ROADMAP.md @@ -12,7 +12,7 @@ Stable `1.0.0` remains unscheduled. Execution follows [PROJECT_WORKFLOW.md](../governance/PROJECT_WORKFLOW.md). -OpenElement's products are **Element / UI / Router**. See the +OpenElement's core products are **Element / Router**; UI is dogfood and a reference implementation. See the [product model](../architecture/product-model.md) and accepted [ADR-0152](../adr/ADR-0152-product-router-and-alpha-convergence.md). @@ -20,9 +20,11 @@ OpenElement's products are **Element / UI / Router**. See the The published Beta.2 baseline is followed by three convergence checkpoints: -1. **Beta.2.1:** general Router core, self-maintained URLPatternList, explicit and +1. **Beta.2.1:** core product boundaries and standalone Element delivery contract, + general Router core, self-maintained URLPatternList, explicit and file-generated records, shared resolution and immediate cleanup. -2. **Beta.2.2:** Framework Mode page/form/navigation lifecycle, Document, Element +2. **Beta.2.2:** standalone Element packed-consumer proof, Framework Mode + page/form/navigation lifecycle, Document, Element and Vite integration, with replacement-time cleanup. 3. **Beta.2.3:** remaining repository/governance reduction and real-application admission evidence. diff --git a/docs/roadmap/v0.44.0-ISSUES.md b/docs/roadmap/v0.44.0-ISSUES.md index 2ae9343d1..c242af702 100644 --- a/docs/roadmap/v0.44.0-ISSUES.md +++ b/docs/roadmap/v0.44.0-ISSUES.md @@ -11,8 +11,8 @@ not a manually duplicated completion ledger. | Phase | Parent | Work | | ------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Beta.2.1 | #1320 | #1338 products/record model; #1324 owned URLPatternList; #1325 shared resolution; #1323 version and channel semantics | -| Beta.2.2 | #1321 | #1339 Framework application flow; #1326 Document; #1327 public metadata projections | +| Beta.2.1 | #1320 | #1338 core products/record model/standalone Element contract; #1324 owned URLPatternList; #1325 shared resolution; #1323 version and channel semantics | +| Beta.2.2 | #1321 | #1339 standalone Element + Framework reference flow; #1326 Document; #1327 public metadata projections | | Beta.2.3 | #1322 | #1328 lifecycle census; #1329 debris; #1330 tools; #1331 tasks; #1332 CI; #1333 fixtures; #1334 release-state authority; #1340 Alpha admission | | Continuous cleanup | #1322 | #1156 mature tooling; #1188 governance/docs; #1189 repository weight; #1192 distinct residual closure obligations | | 1.0 Alpha | #1155 | #1179 real SaaS; #1234-#1242 artifact/benchmark/performance/error/consumer/runtime/lifecycle qualification; #1187 release-hardening remainder; #1311 deferred examples | diff --git a/packages/ui/README.md b/packages/ui/README.md index f0717aef2..96944dba1 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -1,6 +1,11 @@ # @openelement/ui -First-party reference UI package for the OpenElement framework. +First-party dogfood and reference UI package for OpenElement. + +Element and Router are the two core products; Router includes Framework Mode. +UI demonstrates and tests those products rather than defining a third core product +or a comprehensive design-system roadmap. The existing package remains available; +this positioning does not remove components or force migration to another library. The components are first-party `open-*` Web Components. They are designed to prove the OpenElement authoring model with shadow/DSD output, explicit light DOM From f9a30a9296e61ac7efd9eb9a00dac3cfd2c6eafe Mon Sep 17 00:00:00 2001 From: DevBot Date: Tue, 8 Sep 2026 12:30:19 +0800 Subject: [PATCH 03/17] feat(router): converge Beta.2.1 routing and Element tooling (ADR-0152) --- docs/current/PACKAGE_SURFACE.md | 131 ++++++++-- docs/current/SEMANTIC_OWNERSHIP.md | 67 +++--- docs/governance/RELEASE_POLICY.md | 9 + docs/release/public-interface-snapshot.json | 48 +++- packages/adapter-vite/README.md | 15 +- .../request-time/e2e/live.spec.ts | 2 +- .../__tests__/entry-descriptor.test.ts | 8 +- .../__tests__/entry-renderer.test.ts | 2 +- .../__tests__/foreign-tag-scanner.test.ts | 1 - .../request-time-admission-parity.test.ts | 31 ++- .../__tests__/route-manifest.test.ts | 90 +++++-- .../__tests__/ssg-helpers.test.ts | 9 - packages/adapter-vite/deno.json | 1 + packages/adapter-vite/src/element.ts | 2 + .../src/generated-export-files.ts | 3 + .../src/internal/ssg/entry-codegen.ts | 18 +- .../src/internal/ssg/entry-orchestrator.ts | 21 ++ .../src/internal/ssg/route-scanner.ts | 45 +++- .../src/internal/ssg/ssg-helpers.ts | 19 +- packages/adapter-vite/src/route-manifest.ts | 18 +- packages/app/__tests__/authoring.test.ts | 4 +- packages/app/__tests__/client-router.test.ts | 45 ++-- packages/app/__tests__/route-pattern.test.ts | 14 ++ .../app/__tests__/route-resolution.test.ts | 49 ++++ packages/app/__tests__/router-browser.test.ts | 137 +++++++++++ packages/app/__tests__/router-http.test.ts | 78 ++++++ packages/app/__tests__/spa.test.ts | 63 ++++- .../app/__tests__/url-pattern-list.bench.ts | 94 ++++++++ .../app/__tests__/url-pattern-list.test.ts | 190 +++++++++++++++ packages/app/deno.json | 2 + packages/app/src/authoring.ts | 6 +- .../app/src/internal/router/client-router.ts | 121 ++++++++-- .../app/src/internal/router/route-pattern.ts | 11 + .../app/src/internal/router/route-table.ts | 225 +++++++----------- .../internal/router/url-pattern-list/LICENSE | 7 + .../router/url-pattern-list/PROVENANCE.md | 26 ++ .../internal/router/url-pattern-list/index.ts | 101 ++++++++ packages/app/src/router-http.ts | 58 +++++ packages/app/src/router.ts | 9 + packages/app/src/spa.ts | 27 ++- .../__tests__/html-route-utils.test.ts | 14 +- packages/element/src/build-utils.ts | 5 +- .../src/internal/core/html-route-utils.ts | 12 - packages/element/src/public-build-runtime.ts | 5 +- tools/autoflow/__tests__/release.test.ts | 24 +- tools/autoflow/release.ts | 2 + tools/autoflow/version-anchors.ts | 11 +- tools/bump-version.test.ts | 1 + tools/bump-version.ts | 36 +-- tools/consumer-packaged-element.ts | 167 +++++++++++++ tools/lib/npm-release-verifier.ts | 7 +- tools/lib/version.test.ts | 54 ++++- tools/lib/version.ts | 114 +++++++-- tools/project-constants.ts | 13 +- tools/publish-npm.ts | 20 +- www/app/data/_generated-api-reference.ts | 193 +++++++++++++-- 56 files changed, 2010 insertions(+), 475 deletions(-) create mode 100644 packages/adapter-vite/src/element.ts create mode 100644 packages/app/__tests__/route-pattern.test.ts create mode 100644 packages/app/__tests__/route-resolution.test.ts create mode 100644 packages/app/__tests__/router-browser.test.ts create mode 100644 packages/app/__tests__/router-http.test.ts create mode 100644 packages/app/__tests__/url-pattern-list.bench.ts create mode 100644 packages/app/__tests__/url-pattern-list.test.ts create mode 100644 packages/app/src/internal/router/route-pattern.ts create mode 100644 packages/app/src/internal/router/url-pattern-list/LICENSE create mode 100644 packages/app/src/internal/router/url-pattern-list/PROVENANCE.md create mode 100644 packages/app/src/internal/router/url-pattern-list/index.ts create mode 100644 packages/app/src/router-http.ts create mode 100644 packages/app/src/router.ts create mode 100644 tools/consumer-packaged-element.ts diff --git a/docs/current/PACKAGE_SURFACE.md b/docs/current/PACKAGE_SURFACE.md index 02a7c10bf..e1cd30895 100644 --- a/docs/current/PACKAGE_SURFACE.md +++ b/docs/current/PACKAGE_SURFACE.md @@ -12,13 +12,13 @@ authoring modes = Basic Element standalone + full application ## Current five-package surface -| Package | Responsibility | Supported public interface | -| --------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------- | -| `@openelement/element` | JSX, Custom Elements, DSD, hydration, signals and component runtime contracts | root, `jsx-runtime`, `jsx-dev-runtime`, `build-utils`, `sanitize` | -| `@openelement/app` | Pages, routes, loaders, actions, islands and normalized request semantics | root, `model`, `spa`, `preact` | -| `@openelement/adapter-vite` | Vite, content, SSG, generated data, Hono and Nitro build/deploy implementation | root, `nitro-mount`, `cli/build`, `cli/start`, `sitemap` | -| `@openelement/create` | Version-coherent starter generation and consumer lifecycle | CLI binary (root) | -| `@openelement/ui` | Optional, reusable and dogfood-proven Web Component primitives | root and retained primitive subpaths | +| Package | Responsibility | Supported public interface | +| --------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------- | +| `@openelement/element` | JSX, Custom Elements, DSD, hydration, signals and component runtime contracts | root, `jsx-runtime`, `jsx-dev-runtime`, `build-utils`, `sanitize` | +| `@openelement/app` | Pages, routes, loaders, actions, islands and normalized request semantics | root, `model`, `spa`, `preact`, `router`, `router/http` | +| `@openelement/adapter-vite` | Vite, content, SSG, generated data, Hono and Nitro build/deploy implementation | root, `element`, `nitro-mount`, `cli/build`, `cli/start`, `sitemap` | +| `@openelement/create` | Version-coherent starter generation and consumer lifecycle | CLI binary (root) | +| `@openelement/ui` | Optional, reusable and dogfood-proven Web Component primitives | root and retained primitive subpaths | Responsibility wording follows [`STACK_CONTRACT.md`](./STACK_CONTRACT.md), the source of truth for the five-package responsibility table. @@ -61,24 +61,63 @@ promise and are not application-authoring surface. @@ -86,7 +125,7 @@ promise and are not application-authoring surface. - `@openelement/element/build-utils` (alpha.17): build-time helpers (`transformIslandSource`, `formatJson`, `pathToTagName`, `normalizeSeparators`, `insertBeforeBodyClose`, - `normalizeRoutePatternForURLPattern`, `SsrRenderError`, + `SsrRenderError`, `createRuntimeAdapter`, `composeFetchMiddleware` and the runtime handler types) for build adapters. They were removed from the element root export; application code must not @@ -112,7 +151,7 @@ promise and are not application-authoring surface. supported implementations through named public modules and never name an `internal/` module specifier. - `@openelement/app/i18n` is the optional locale-expansion integration point. -- App's router implementation (`internal/router`) is not exported; the router +- `@openelement/app/router` exports pure Route Mode matching; `router/http` adds Hono execution. Private tree/parser details remain unexported. The browser router types (`RouteConfig`, `RouterInstance`, `RouterMode`) were removed from the app root export in alpha.17 — SPA consumers derive the instance from `ReturnType` and the options from @@ -264,7 +303,6 @@ classified name missing from the prose fails the gate. "createRuntimeAdapter": "internal-importable", "formatJson": "internal-importable", "insertBeforeBodyClose": "internal-importable", - "normalizeRoutePatternForURLPattern": "internal-importable", "normalizeSeparators": "internal-importable", "OpenElementRequestHandler": "internal-importable", "pathToTagName": "internal-importable", @@ -335,6 +373,18 @@ classified name missing from the prose fails the gate. "definePreactIsland": "stable-candidate", "PreactIslandConstructor": "stable-candidate", "PreactIslandOptions": "stable-candidate" + }, + "router": { + "RouteTable": "experimental", + "RouteRecord": "experimental", + "RouteMatch": "experimental", + "RouteResolution": "experimental", + "RouteTableOptions": "experimental", + "normalizeRoutePatternForURLPattern": "internal-importable" + }, + "router/http": { + "createRouteMiddleware": "experimental", + "HttpRouteRecord": "experimental" } }, "@openelement/adapter-vite": { @@ -396,6 +446,9 @@ classified name missing from the prose fails the gate. "cli/build": {}, "cli/start": { "extractServeMode": "internal-importable" + }, + "element": { + "element": "experimental" } }, "@openelement/create": { @@ -471,7 +524,7 @@ classified name missing from the prose fails the gate. | `jsx-runtime` | stable-candidate | `Fragment`, `jsx`, `JSX`, `jsxs` | | `jsx-dev-runtime` | stable-candidate | `Fragment`, `JSX`, `jsxDEV` | | `sanitize` | stable-candidate | `isSafeUrl`, `sanitizeHtml`, `SanitizeOptions` | -| `build-utils` | internal-importable | `composeFetchMiddleware`, `createRuntimeAdapter`, `formatJson`, `insertBeforeBodyClose`, `normalizeRoutePatternForURLPattern`, `normalizeSeparators`, `OpenElementRequestHandler`, `pathToTagName`, `RuntimeContext`, `SsrRenderError`, `transformIslandSource` | +| `build-utils` | internal-importable | `composeFetchMiddleware`, `createRuntimeAdapter`, `formatJson`, `insertBeforeBodyClose`, `normalizeSeparators`, `OpenElementRequestHandler`, `pathToTagName`, `RuntimeContext`, `SsrRenderError`, `transformIslandSource` | ### `@openelement/app` @@ -628,7 +681,7 @@ Earlier removed product experiments and adapters remain historical only: - Element owns the browser/runtime implementation and runtime contracts. - `@preact/signals-core` is Element's internal signal engine, not a consumer OpenElement package surface. -- App owns routing and application semantics; its router is internal. +- App owns routing and application semantics; its pure router and HTTP integration have separate entry points. - Adapter Vite owns content, static generation, deployment and build contracts. - Create templates and current docs may import only retained product packages. - Runtime-free packages (`element`, `app`, `ui`) contain no Deno or Node host API @@ -646,3 +699,43 @@ export stability classification above are checked together by `deno task package-surface:check`. Historical ADR and release evidence retain their original package names; they are not current usage documentation. + +## Beta.2.1 entry migration (ADR-0152) + +`@openelement/app/router` owns records, ordered resolution, separate pathname +captures and `URLSearchParams`. Its dependency graph excludes Element/Hono/Vite. +`@openelement/app/router/http` provides `createRouteMiddleware`, mounted after +host middleware/API routes; URL winner precedes method dispatch. Records group +method handlers, explicit HEAD wins, implicit HEAD uses GET, 405 lists only the +selected record's methods (sorted uppercase, with implicit HEAD). Missing URLs +continue to the host. `methods` defaults to GET in the pure table. + +The Element build-utils route normalizer moves to App/router. Generated SPA +manifests now export ordered `routeRecords` (id/path/methods/load), replacing the +path-keyed `routeManifest`. File identity is its normalized relative source path; +file routes sort static, parameter, catch-all, then code-point path/file order. +`definePage().route.path` is removed: filenames own Framework URLs. SPA loaders +and actions receive `searchParams` separately; params no longer project query +values onto a component. + +`@openelement/adapter-vite/element` exports `element()` for standalone authoring. +Use that plugin with Vite's normal library build, and register the exported class +in a separate ordinary JS entry. Compiler/runtime communicate through existing +versioned Part Programs; the semantic compiler remains private and is delivered +inside the tooling package. Router is an optional peer of adapter-vite; Framework +applications must install App themselves. Standalone authors install Element, +adapter-vite and Vite; bundled plain-HTML consumers need only the resulting JS. +The packed consumer proof checks registration, event updates, source maps and +browser JS/declaration graphs; this is not full Beta.2.2 qualification. + +Navigation API is used where present; history fallback remains for hosts without +it and file:// hash mode retains its demonstrated existing use. Matching is the +same RouteTable in every driver. Chromium 147, Firefox 148 and WebKit 26.4 are the +local test environments, not a newly promised minimum browser version. + +| Subpath | Classification | Named exports | +| ----------------- | ------------------- | --------------------------------------------------------------------------------- | +| App `router` | experimental | `RouteTable`, `RouteRecord`, `RouteMatch`, `RouteResolution`, `RouteTableOptions` | +| App `router` | internal-importable | `normalizeRoutePatternForURLPattern` | +| App `router/http` | experimental | `createRouteMiddleware`, `HttpRouteRecord` | +| Adapter `element` | experimental | `element` | diff --git a/docs/current/SEMANTIC_OWNERSHIP.md b/docs/current/SEMANTIC_OWNERSHIP.md index 80e95de91..4f2267c58 100644 --- a/docs/current/SEMANTIC_OWNERSHIP.md +++ b/docs/current/SEMANTIC_OWNERSHIP.md @@ -16,39 +16,40 @@ evolves specific rows of this table through its issue tree (#1209-#1220). The table remains the Alpha.9 baseline until it is re-baselined at Alpha.10 closure. -| Concept | Canonical owner and representation | Executors, alternatives, and accepted decision | Parity evidence and known divergence | Forbidden duplicate ownership; issue | -| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| Component identity | Bundler-neutral compiler semantic core; decorator-derived tag/class records in Part Program metadata | Vite discovers modules and registers the compiled class; Element consumes identity | Compiler boundary, deterministic fixture, CEM records; closed | No Vite regex identity grammar or runtime tag inference; #1208 | -| OpenElement language semantics | Compiler semantic core; admitted decorator, property, computed, TSX, Part and Region grammar | Vite hook is integration only; unsupported syntax fails closed | `compiler-semantic-core-boundary.test.ts`, `compiler-fail-closed-matrix.test.ts`, `compiled-element-v1.test.ts`; closed | No bundler-owned parser or runtime JSX fallback; #1208, #1204 | -| Part Program v1 | Compiler `semantic-core/program.ts`; exact versioned serialized wire artifact | Element has an independent wire validator and all executors consume the validated artifact | Bidirectional validator corpus and deterministic fixture; competing runtime-only grammar removed | No Element-only v1 instruction or compatibility grammar; #1208 | -| RuntimeProgramIR | Element `runtime-program.ts`; normalized, recursively frozen representation created only after exact wire validation | Fresh DOM, claim, reactive runtime and server serializers | `part-program-validation-adversarial.test.ts`, compiled runtime/server suites; closed | No direct cast or widening before validation; #1208 | -| Part, Region, anchor, dependency and location identity | Part Program v1 ownership/location tables | Element indexes those records but does not rediscover them | Adversarial duplicate, missing, misplaced and malformed-record matrix; closed | No executor-generated identity or alternate condition representation; #1208 | -| Signals and reactive invalidation | Element signal engine; dependency records map signals to owning Parts/Regions | Fresh and claimed DOM executors subscribe through the same runtime context | Part/Region update, keyed collection and disposal suites; closed | No VNode walk or adapter-level reactive graph; #1208 | -| Context discovery and transport | Community Context Protocol `context-request` composed/bubbling event | Element Signal bridge provides/consumes values; Lit provider/consumer is the conforming alternative | `signal-context.test.ts` including Lit interop, reconnect and disposal; private ancestry walk removed | No parentNode/root-host discovery or global context registry; #1205 | -| DOM context propagation and lifecycle | Platform event propagation plus Element connection-scoped subscriptions | Shadow/light DOM and reconnect are ordinary protocol cases | Compiled context lifecycle matrix; closed | No transport tied to one renderer or permanent disconnected subscription; #1205 | -| SSR initial DOM, fresh DOM, claim and activation | Element compiled server/runtime/claim semantics over one program | Server string serializer, fresh builder and existing-DOM claimant are alternative executors | Part Program conformance, compiled server/fresh/claim/update suites; closed | No second renderer, generic hydration or binding discovery; #1203 | -| Nested component composition, slots and DSD | Element compiled serializer; structured nested records and admitted tags | Vite supplies registry modules and immutable admission list only | `compiled-composition.test.ts`, `nested-static-ssr.test.ts`; duplicate Vite renderer removed; accepted divergence: components embedded in opaque Trusted HTML content stay SSR-inert and upgrade client-side (#1203) | No adapter reparsing or independent slot/DSD semantics; #1203 | -| Trusted HTML | Caller/compiler creates `TrustedHtml` identity capability; Element sink validates identity | SSR, fresh DOM, claim and public light-child projection consume the same capability | Trusted sink matrix and forged-projection rejection; raw-string projection leak fixed | No string branding, Vite sanitizer, or HTML-based component discovery; #1203 | -| Document, head and body serialization | Element `wrapInDocument`; structured options to deterministic document HTML | Generated Hono and SSG entries call it | `html-escape.test.ts`, entry generation and request-time suites; duplicate entry wrapper removed | No generated document renderer; #1203 | -| Compiled serializer text-node escaping | Element `internal/compiled/escape-text.ts`; reduced contract (`&`, `<`, `>` only — quotes pass through in text content) | Runtime seed serializer and server serializer both call the shared helper; the two private copies converged | `compiled-escape-parity.test.ts` byte-level text corpus (static text nodes and text Parts across both serializers); closed. The entity-preserving `escapeText` in `sanitize.ts` is a deliberately different contract, not this surface | No per-serializer private copy or quote-escaping text contract; #1272 | -| CSS and visual theme | CSS cascade, Custom Properties, authored styles and scoped compiled light CSS | Shadow/light style installation is an Element projection; Context may carry theme identity only | StyleSheet, compiled style and website browser suites; closed | No Context recreation of color, spacing, typography, radii or visual-token inheritance; #1201 | -| Route pathname grammar | Native `URLPattern`, or admitted `urlpattern-polyfill` with the same corpus | App `RouteTable` compiles route declarations once | Native/polyfill adversarial `client-router.test.ts`; legacy regex/trie matcher removed | No SPA-only pathname parser; #1204 | -| Route matching, order, params, query, base and trailing policy | App `RouteTable`; ordered route records and URLPattern results | SPA, generated Hono/request-time, SSG and Nitro project the same route records | Route parity corpus plus request-time browser matrix; closed | No transport-specific precedence or unsafe-param map; #1204, #1206 | -| Route file-convention grammar and declaration ordering | Adapter `route-scanner.ts`; `parseRouteFilePath` (`[id]`→`:id`, `[...path]`→`:path{.+}`, index stripping, `api/` classification) and the static-first sort (special files last, then static-before-dynamic, then lexicographic) | App `RouteTable` honors the declaration order through index-based precedence; SSG, request-time codegen and the SPA route manifest consume the same scanned records | `route-scanner-*.test.ts`, `route-manifest.test.ts`, `request-time-parity.test.ts` (dev-vs-build semantic parity); closed | No second file-convention grammar or transport-specific declaration ordering; #1270 | -| Renderer scope matching | Adapter `entry-route-helpers.ts` `rendererScopeMatches`; case-sensitive exact-or-`scope/` boundary-prefix predicate | Generated `__matchingRenderers` re-expresses the predicate inside self-contained generated entries, which cannot import adapter internals (derived projection) | `renderer-scope-parity.test.ts` binding corpus evaluates the generated matcher verbatim against the predicate (exact, prefix, nested, non-match, boundary separators, case); closed | No second runtime scope grammar or per-transport scope policy; #1271 | -| Request and Response semantics | Web platform `Request`, `Response`, `Headers`, `FormData`, `URL` | Hono executes requests; Nitro adapts deployment transports | HTTP bridge, malformed-body, headers, abort/cancel and Nitro proof suites; closed | No framework-private HTTP value model; #1206 | -| Loader | App authoring contract and outcome classification | SPA and generated Hono handlers project the result | App authoring/SPA/request-time parity; closed | No adapter-specific loader result grammar; #1206 | -| Action | App `classifyActionResult`; `ActionOutcome` discriminated representation | SPA and generated Hono action runtime consume the classifier | App authoring/SPA/entry-renderer/request-time suites; prior `fail()` shape divergence fixed | No Hono-only `.data` interpretation or raw `Response` action result; #1206 | -| Redirect, NotFound, expected failure and Problem Details | App authoring control/outcome semantics | SPA navigation and Hono response projection are alternative executors | App outcome matrix, action protocol and request-time parity; closed | No transport-owned outcome classification; #1206 | -| Hono execution | Hono request/middleware execution model | Generated entry binds App-owned route/outcome semantics to Hono | Request-time fixture and browser suites; closed | Hono does not own App classification; #1206 | -| Nitro deployment | Nitro adapters and generated deployment artifacts | Node server and Cloudflare module are environment alternatives | `nitro:proof:node`, `nitro:proof:workers`; closed | Nitro does not redefine routes, outcomes or documents; #1206 | -| Compiler-known interaction facts | Compiler semantic analysis and emitted event records | Client admission aggregation reads facts directly | Module-analysis, scanner and compiler determinism suites; closed | No Vite AST rediscovery of compiler-owned behavior; #1202, #1207 | -| Explicit Island and imperative behavior policy | Author declaration validated by adapter protocol | Client admission aggregation and delivery strategy projection | Island scanner/delivery and exact client-output tests; closed | No assumption that imperative browser behavior is compiler-knowable; #1202 | -| Third-party delivery capability | Package manifest and CEM classification | Adapter aggregates package islands and foreign-tag admission | CEM compatibility, foreign-tag and package-island tests; closed | No guessing third-party behavior from OpenElement compiler data; #1202 | -| Client reachability, activation and zero-JS | Adapter `client-admission.ts`; deterministic union of route reachability, compiler facts, explicit policy and third-party declarations | Vite chunking/client entry generation implements the plan | Admission, generator, exact-output, static-only zero-runtime and three-browser suites; false zero-JS for interactive static components fixed | No hidden 0.43 island fallback or independent compiler-event scan; #1202 | -| Vite build, HMR, resolution and source-map composition | Vite integration layer; module graph, cache and generated source maps | Bundler-neutral compiler returns code, diagnostics and source records | HMR/delivery, direct-core, source-map and build suites; closed | Compiler core must not import Vite or invent a generic bundler abstraction; #1207 | -| Source maps and diagnostics | Compiler semantic core owns source spans/records; Vite composes standard module maps | Browser/tooling consumes generated maps | Frozen source-map fixture, located fail-closed diagnostics and HMR tests; closed | No adapter recreation of semantic locations; #1207 | -| Public metadata and package interface | Package root exports, package manifests and checked interface snapshot | Docs, packed packages, Starter and third-party consumers | Interface snapshot, packed dry run and packed-consumer qualification; pending only the final exact-head rerun | No unpublished deep-import contract or stale provisional name; #1201 | +| Concept | Canonical owner and representation | Executors, alternatives, and accepted decision | Parity evidence and known divergence | Forbidden duplicate ownership; issue | +| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| Component identity | Bundler-neutral compiler semantic core; decorator-derived tag/class records in Part Program metadata | Vite discovers modules and registers the compiled class; Element consumes identity | Compiler boundary, deterministic fixture, CEM records; closed | No Vite regex identity grammar or runtime tag inference; #1208 | +| OpenElement language semantics | Compiler semantic core; admitted decorator, property, computed, TSX, Part and Region grammar | Vite hook is integration only; unsupported syntax fails closed | `compiler-semantic-core-boundary.test.ts`, `compiler-fail-closed-matrix.test.ts`, `compiled-element-v1.test.ts`; closed | No bundler-owned parser or runtime JSX fallback; #1208, #1204 | +| Part Program v1 | Compiler `semantic-core/program.ts`; exact versioned serialized wire artifact | Element has an independent wire validator and all executors consume the validated artifact | Bidirectional validator corpus and deterministic fixture; competing runtime-only grammar removed | No Element-only v1 instruction or compatibility grammar; #1208 | +| RuntimeProgramIR | Element `runtime-program.ts`; normalized, recursively frozen representation created only after exact wire validation | Fresh DOM, claim, reactive runtime and server serializers | `part-program-validation-adversarial.test.ts`, compiled runtime/server suites; closed | No direct cast or widening before validation; #1208 | +| Part, Region, anchor, dependency and location identity | Part Program v1 ownership/location tables | Element indexes those records but does not rediscover them | Adversarial duplicate, missing, misplaced and malformed-record matrix; closed | No executor-generated identity or alternate condition representation; #1208 | +| Signals and reactive invalidation | Element signal engine; dependency records map signals to owning Parts/Regions | Fresh and claimed DOM executors subscribe through the same runtime context | Part/Region update, keyed collection and disposal suites; closed | No VNode walk or adapter-level reactive graph; #1208 | +| Context discovery and transport | Community Context Protocol `context-request` composed/bubbling event | Element Signal bridge provides/consumes values; Lit provider/consumer is the conforming alternative | `signal-context.test.ts` including Lit interop, reconnect and disposal; private ancestry walk removed | No parentNode/root-host discovery or global context registry; #1205 | +| DOM context propagation and lifecycle | Platform event propagation plus Element connection-scoped subscriptions | Shadow/light DOM and reconnect are ordinary protocol cases | Compiled context lifecycle matrix; closed | No transport tied to one renderer or permanent disconnected subscription; #1205 | +| SSR initial DOM, fresh DOM, claim and activation | Element compiled server/runtime/claim semantics over one program | Server string serializer, fresh builder and existing-DOM claimant are alternative executors | Part Program conformance, compiled server/fresh/claim/update suites; closed | No second renderer, generic hydration or binding discovery; #1203 | +| Nested component composition, slots and DSD | Element compiled serializer; structured nested records and admitted tags | Vite supplies registry modules and immutable admission list only | `compiled-composition.test.ts`, `nested-static-ssr.test.ts`; duplicate Vite renderer removed; accepted divergence: components embedded in opaque Trusted HTML content stay SSR-inert and upgrade client-side (#1203) | No adapter reparsing or independent slot/DSD semantics; #1203 | +| Trusted HTML | Caller/compiler creates `TrustedHtml` identity capability; Element sink validates identity | SSR, fresh DOM, claim and public light-child projection consume the same capability | Trusted sink matrix and forged-projection rejection; raw-string projection leak fixed | No string branding, Vite sanitizer, or HTML-based component discovery; #1203 | +| Document, head and body serialization | Element `wrapInDocument`; structured options to deterministic document HTML | Generated Hono and SSG entries call it | `html-escape.test.ts`, entry generation and request-time suites; duplicate entry wrapper removed | No generated document renderer; #1203 | +| Compiled serializer text-node escaping | Element `internal/compiled/escape-text.ts`; reduced contract (`&`, `<`, `>` only — quotes pass through in text content) | Runtime seed serializer and server serializer both call the shared helper; the two private copies converged | `compiled-escape-parity.test.ts` byte-level text corpus (static text nodes and text Parts across both serializers); closed. The entity-preserving `escapeText` in `sanitize.ts` is a deliberately different contract, not this surface | No per-serializer private copy or quote-escaping text contract; #1272 | +| CSS and visual theme | CSS cascade, Custom Properties, authored styles and scoped compiled light CSS | Shadow/light style installation is an Element projection; Context may carry theme identity only | StyleSheet, compiled style and website browser suites; closed | No Context recreation of color, spacing, typography, radii or visual-token inheritance; #1201 | +| Route pathname grammar | Native `URLPattern`, or admitted `urlpattern-polyfill` with the same corpus | App `RouteTable` compiles route declarations once | Native/polyfill adversarial `client-router.test.ts`; legacy regex/trie matcher removed | No SPA-only pathname parser; #1204 | +| Route identity, URL winner before method, separate captures/query, base and trailing policy | App `RouteTable`; ordered records resolved through the owned URLPatternList (ADR-0152) | SPA, generated Hono/request-time, SSG and Nitro project the same route records | Route resolution and differential corpus; server/browser convergence tracked in #1325 | No transport-specific precedence or unsafe-param map; #1204, #1206 | +| Ordered pattern collection/index | OE URLPatternList, derived from pinned url-pattern-list 0.5.0 | Literal pathname prefix tree plus sequence-merged conservative candidates; URLPattern exec owns results | url-pattern-list.test.ts native/polyfill differential corpus; #1324 | No second production winner scan or exposed parser/tree | +| Route file-convention grammar and declaration ordering | Adapter `route-scanner.ts`; `parseRouteFilePath` (`[id]`→`:id`, `[...path]`→`:path{.+}`, index stripping, `api/` classification) and the deterministic sort (special files last; static, parameters, catch-all; code-point path/file ties) | App `RouteTable` honors the declaration order through index-based precedence; SSG, request-time codegen and the SPA route manifest consume the same scanned records | `route-scanner-*.test.ts`, `route-manifest.test.ts`, `request-time-parity.test.ts` (dev-vs-build semantic parity); closed | No second file-convention grammar or transport-specific declaration ordering; #1270 | +| Renderer scope matching | Adapter `entry-route-helpers.ts` `rendererScopeMatches`; case-sensitive exact-or-`scope/` boundary-prefix predicate | Generated `__matchingRenderers` re-expresses the predicate inside self-contained generated entries, which cannot import adapter internals (derived projection) | `renderer-scope-parity.test.ts` binding corpus evaluates the generated matcher verbatim against the predicate (exact, prefix, nested, non-match, boundary separators, case); closed | No second runtime scope grammar or per-transport scope policy; #1271 | +| Request and Response semantics | Web platform `Request`, `Response`, `Headers`, `FormData`, `URL` | Hono executes requests; Nitro adapts deployment transports | HTTP bridge, malformed-body, headers, abort/cancel and Nitro proof suites; closed | No framework-private HTTP value model; #1206 | +| Loader | App authoring contract and outcome classification | SPA and generated Hono handlers project the result | App authoring/SPA/request-time parity; closed | No adapter-specific loader result grammar; #1206 | +| Action | App `classifyActionResult`; `ActionOutcome` discriminated representation | SPA and generated Hono action runtime consume the classifier | App authoring/SPA/entry-renderer/request-time suites; prior `fail()` shape divergence fixed | No Hono-only `.data` interpretation or raw `Response` action result; #1206 | +| Redirect, NotFound, expected failure and Problem Details | App authoring control/outcome semantics | SPA navigation and Hono response projection are alternative executors | App outcome matrix, action protocol and request-time parity; closed | No transport-owned outcome classification; #1206 | +| Hono execution | Hono request/middleware execution model | Generated entry binds App-owned route/outcome semantics to Hono | Request-time fixture and browser suites; closed | Hono does not own App classification; #1206 | +| Nitro deployment | Nitro adapters and generated deployment artifacts | Node server and Cloudflare module are environment alternatives | `nitro:proof:node`, `nitro:proof:workers`; closed | Nitro does not redefine routes, outcomes or documents; #1206 | +| Compiler-known interaction facts | Compiler semantic analysis and emitted event records | Client admission aggregation reads facts directly | Module-analysis, scanner and compiler determinism suites; closed | No Vite AST rediscovery of compiler-owned behavior; #1202, #1207 | +| Explicit Island and imperative behavior policy | Author declaration validated by adapter protocol | Client admission aggregation and delivery strategy projection | Island scanner/delivery and exact client-output tests; closed | No assumption that imperative browser behavior is compiler-knowable; #1202 | +| Third-party delivery capability | Package manifest and CEM classification | Adapter aggregates package islands and foreign-tag admission | CEM compatibility, foreign-tag and package-island tests; closed | No guessing third-party behavior from OpenElement compiler data; #1202 | +| Client reachability, activation and zero-JS | Adapter `client-admission.ts`; deterministic union of route reachability, compiler facts, explicit policy and third-party declarations | Vite chunking/client entry generation implements the plan | Admission, generator, exact-output, static-only zero-runtime and three-browser suites; false zero-JS for interactive static components fixed | No hidden 0.43 island fallback or independent compiler-event scan; #1202 | +| Vite build, HMR, resolution and source-map composition | Vite integration layer; module graph, cache and generated source maps | Bundler-neutral compiler returns code, diagnostics and source records | HMR/delivery, direct-core, source-map and build suites; closed | Compiler core must not import Vite or invent a generic bundler abstraction; #1207 | +| Source maps and diagnostics | Compiler semantic core owns source spans/records; Vite composes standard module maps | Browser/tooling consumes generated maps | Frozen source-map fixture, located fail-closed diagnostics and HMR tests; closed | No adapter recreation of semantic locations; #1207 | +| Public metadata and package interface | Package root exports, package manifests and checked interface snapshot | Docs, packed packages, Starter and third-party consumers | Interface snapshot, packed dry run and packed-consumer qualification; pending only the final exact-head rerun | No unpublished deep-import contract or stale provisional name; #1201 | ## Boundary rules diff --git a/docs/governance/RELEASE_POLICY.md b/docs/governance/RELEASE_POLICY.md index 3801fab34..b931b98a1 100644 --- a/docs/governance/RELEASE_POLICY.md +++ b/docs/governance/RELEASE_POLICY.md @@ -32,3 +32,12 @@ Independent application qualification binds to the candidate's exact SHA, packag bytes, integrity records and provenance. Any candidate-byte change invalidates its qualification for promotion and requires a new qualified candidate. Stable admission requires the separate human GO and final gate #37; Alpha is not a stability claim. + +Version parsing and comparison live in `tools/lib/version.ts`. It accepts +SemVer prerelease identifiers in sequence (numeric identifiers compare +numerically); build metadata and v-prefixed input remain unsupported and core +numbers must be safe integers. Formatting retains every identifier. Checkpoint +succession stops at beta.2.3; the separate admitted product-stage successor is +1.0.0-alpha.1. Planning this successor never executes a bump or publication. +Historical 0.44.0-alpha.0 through alpha.10 remain unpublishable; public 1.0 Alpha +still requires all standing exact-SHA release gates. diff --git a/docs/release/public-interface-snapshot.json b/docs/release/public-interface-snapshot.json index 3fd1d7cc6..d7f2227c4 100644 --- a/docs/release/public-interface-snapshot.json +++ b/docs/release/public-interface-snapshot.json @@ -92,7 +92,7 @@ ] }, "./build-utils": { - "publicShapeSha256": "429b8fc3c1240c2d81d2cd2a414e9365e0f3fbb3fb23b566ecc439ebc448e9e6", + "publicShapeSha256": "ef569a279e1bdcc1c16571a3bba98996f94768489d523d9ca87d000ad6adc856", "publicSymbols": [ "OpenElementRequestHandler=type:{call:(request:Request,context:union(undefined|{env?:union(Env|undefined);params?:union(Record|undefined);platform?:unknown}))=>union(Promise|Response)}", "RuntimeContext=type:{env?:union(Env|undefined);params?:union(Record|undefined);platform?:unknown}", @@ -101,7 +101,6 @@ "createRuntimeAdapter=value:{call: =Record>(options:{fetch:{call:(request:Request,context:union(undefined|{env?:union(Env|undefined);params?:union(Record|undefined);platform?:unknown}))=>union(Promise|Response)};name:string;prerender?:union(undefined|{call:()=>union(AsyncIterable|Iterable)})})=>{fetch:{call:(request:Request,context:union(undefined|{env?:union(Env|undefined);params?:union(Record|undefined);platform?:unknown}))=>union(Promise|Response)};name:string;prerender?:union(undefined|{call:()=>union(AsyncIterable|Iterable)})}}", "formatJson=value:{call:(value:unknown)=>string}", "insertBeforeBodyClose=value:{call:(html:string,content:string)=>string}", - "normalizeRoutePatternForURLPattern=value:{call:(path:string)=>string}", "normalizeSeparators=value:{call:(path:string,sep:union(\"-\"|\"/\"))=>string}", "pathToTagName=value:{call:(filePath:string)=>string}", "transformIslandSource=value:{call:(source:string,options:{filePath:string;islandsDir:string})=>{code:string;islands:array({filePath:string;tagName:string});map?:union(string|undefined)}}" @@ -141,11 +140,13 @@ "./i18n": "./src/i18n.ts", "./model": "./src/model.ts", "./preact": "./src/preact.ts", + "./router": "./src/router.ts", + "./router/http": "./src/router-http.ts", "./spa": "./src/spa.ts" }, "declarations": { ".": { - "publicShapeSha256": "a8e913e30fa95c22a52b1bf7dbf9cb57849eb1ed7109b85a495a9600159a78ce", + "publicShapeSha256": "bf3288657d9fabbc50ee7f2013c137664182132b8dd19da6bbacaba43d720c81", "publicSymbols": [ "ACTION_FETCH_HEADER=value:any", "Action=value:any", @@ -159,11 +160,11 @@ "LoaderContext=value:any", "OpenElementActionFailure=type:{readonly data:Data;readonly name:\"OpenElementActionFailure\";readonly status:number}|value:{construct:(status:number,data:Data)=>{readonly data:Data;readonly name:\"OpenElementActionFailure\";readonly status:number}}", "OpenElementNotFound=type:{readonly status:404}|value:{construct:(message:string)=>{readonly status:404}}", - "OpenElementPageDescriptor=type:{error?:union(undefined|{call:(error:unknown,context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});head?:union(undefined|{dangerouslyHeadFragments?:union(array(string)|undefined);description?:union(string|undefined);meta?:union(array(Record)|undefined);title?:union(string|undefined)});kind:\"page\";props?:union(undefined|{call:(context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});renderIntent:{mode:union(\"dynamic\"|\"static\")};route?:union(undefined|{id?:union(string|undefined);params?:union(array(string)|undefined);path?:union(string|undefined)})}", + "OpenElementPageDescriptor=type:{error?:union(undefined|{call:(error:unknown,context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});head?:union(undefined|{dangerouslyHeadFragments?:union(array(string)|undefined);description?:union(string|undefined);meta?:union(array(Record)|undefined);title?:union(string|undefined)});kind:\"page\";props?:union(undefined|{call:(context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});renderIntent:{mode:union(\"dynamic\"|\"static\")};route?:union(undefined|{id?:union(string|undefined);params?:union(array(string)|undefined)})}", "OpenElementRedirect=type:{readonly location:string;readonly status:number}|value:{construct:(location:union(URL|string),status:number)=>{readonly location:string;readonly status:number}}", "OpenElementRequestContext=type:{env?:union(Env|undefined);method:string;params:Record;path:string;platform?:unknown;request:Request;searchParams:URLSearchParams;url:URL}", "PROBLEM_JSON_MEDIA_TYPE=value:any", - "PageComponentConstructor=type:intersection(CustomElementConstructor&{openElementPage:{error?:union(undefined|{call:(error:unknown,context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});head?:union(undefined|{dangerouslyHeadFragments?:union(array(string)|undefined);description?:union(string|undefined);meta?:union(array(Record)|undefined);title?:union(string|undefined)});kind:\"page\";props?:union(undefined|{call:(context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});renderIntent:{mode:union(\"dynamic\"|\"static\")};route?:union(undefined|{id?:union(string|undefined);params?:union(array(string)|undefined);path?:union(string|undefined)})}})", + "PageComponentConstructor=type:intersection(CustomElementConstructor&{openElementPage:{error?:union(undefined|{call:(error:unknown,context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});head?:union(undefined|{dangerouslyHeadFragments?:union(array(string)|undefined);description?:union(string|undefined);meta?:union(array(Record)|undefined);title?:union(string|undefined)});kind:\"page\";props?:union(undefined|{call:(context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});renderIntent:{mode:union(\"dynamic\"|\"static\")};route?:union(undefined|{id?:union(string|undefined);params?:union(array(string)|undefined)})}})", "PageErrorProjector=type:{call:(error:unknown,context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record}", "PagePropsContext=type:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}}", "PagePropsProjector=type:{call:(context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record}", @@ -172,14 +173,14 @@ "ServerRouteMetadata=value:any", "SpaAction=value:any", "SpaActionContext=value:any", - "SpaAppInstance=type:{dispose:{call:()=>void};mount:{call:(selector:string)=>void};readonly router:union(null|{currentPath:string;currentRoute:union(null|{action?:union(undefined|{call:(ctx:SpaActionContext)=>Promise});guard?:union(undefined|{call:()=>Promise});loader?:union(undefined|{call:(ctx:SpaLoaderContext)=>Promise});path:string;tagName:string});dispose:{call:()=>void};navigate:{call:(path:string)=>Promise};params:Record;replace:{call:(path:string)=>Promise}})}", + "SpaAppInstance=type:{dispose:{call:()=>void};mount:{call:(selector:string)=>void};readonly router:union(null|{currentPath:string;currentRoute:union(null|{action?:union(undefined|{call:(ctx:any)=>Promise});guard?:union(undefined|{call:()=>Promise});id?:union(string|undefined);loader?:union(undefined|{call:(ctx:any)=>Promise});methods?:union(array(string)|undefined);path:string;pattern?:union(URLPatternInit|undefined);tagName:string});dispose:{call:()=>void};navigate:{call:(path:string)=>Promise};params:Record;readonly searchParams:URLSearchParams;replace:{call:(path:string)=>Promise}})}", "SpaLoader=value:any", "SpaLoaderContext=value:any", "classifyActionResult=value:{call:(result:Data)=>union({data:Data;kind:\"success\"}|{data:unknown;kind:\"failure\";status:number})}", "createRequestContext=value:{call: =Record>(options:{env?:union(Env|undefined);params?:union(Record|undefined);platform?:unknown;request:Request})=>{env?:union(Env|undefined);method:string;params:Record;path:string;platform?:unknown;request:Request;searchParams:URLSearchParams;url:URL}}", - "defineApp=value:{call:(options:{mode:\"spa\";routerMode?:union(\"auto\"|\"hash\"|\"history\"|undefined);routes?:union(array({action?:union(undefined|{call:(ctx:SpaActionContext)=>Promise});guard?:union(undefined|{call:()=>Promise});loader?:union(undefined|{call:(ctx:SpaLoaderContext)=>Promise});path:string;tagName:string})|undefined)})=>{dispose:{call:()=>void};mount:{call:(selector:string)=>void};readonly router:union(null|{currentPath:string;currentRoute:union(null|{action?:union(undefined|{call:(ctx:SpaActionContext)=>Promise});guard?:union(undefined|{call:()=>Promise});loader?:union(undefined|{call:(ctx:SpaLoaderContext)=>Promise});path:string;tagName:string});dispose:{call:()=>void};navigate:{call:(path:string)=>Promise};params:Record;replace:{call:(path:string)=>Promise}})}}", + "defineApp=value:{call:(options:{mode:\"spa\";routerMode?:union(\"auto\"|\"hash\"|\"history\"|undefined);routes?:union(array({action?:union(undefined|{call:(ctx:any)=>Promise});guard?:union(undefined|{call:()=>Promise});id?:union(string|undefined);loader?:union(undefined|{call:(ctx:any)=>Promise});methods?:union(array(string)|undefined);path:string;pattern?:union(URLPatternInit|undefined);tagName:string})|undefined)})=>{dispose:{call:()=>void};mount:{call:(selector:string)=>void};readonly router:union(null|{currentPath:string;currentRoute:union(null|{action?:union(undefined|{call:(ctx:any)=>Promise});guard?:union(undefined|{call:()=>Promise});id?:union(string|undefined);loader?:union(undefined|{call:(ctx:any)=>Promise});methods?:union(array(string)|undefined);path:string;pattern?:union(URLPatternInit|undefined);tagName:string});dispose:{call:()=>void};navigate:{call:(path:string)=>Promise};params:Record;readonly searchParams:URLSearchParams;replace:{call:(path:string)=>Promise}})}}", "defineIslandConfig=value:{call:(config:{dsd?:union(false|true|undefined);exportNames?:union(Readonly>|undefined);hydrate?:any;media?:union(string|undefined);ssr?:union(false|true|undefined);tagNames?:union(array(string)|undefined);tags?:union(array(string)|undefined)})=>{dsd?:union(false|true|undefined);exportNames?:union(Readonly>|undefined);hydrate?:any;media?:union(string|undefined);ssr?:union(false|true|undefined);tagNames?:union(array(string)|undefined);tags?:union(array(string)|undefined)}}", - "definePage=value:{call: =Record>(componentClass:CustomElementConstructor,descriptor:union(undefined|{error?:union(undefined|{call:(error:unknown,context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});head?:union(undefined|{dangerouslyHeadFragments?:union(array(string)|undefined);description?:union(string|undefined);meta?:union(array(Record)|undefined);title?:union(string|undefined)});props?:union(undefined|{call:(context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});renderIntent?:union(undefined|{mode?:union(\"dynamic\"|\"static\"|undefined)});route?:union(undefined|{id?:union(string|undefined);params?:union(array(string)|undefined);path?:union(string|undefined)})}))=>intersection(CustomElementConstructor&{openElementPage:{error?:union(undefined|{call:(error:unknown,context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});head?:union(undefined|{dangerouslyHeadFragments?:union(array(string)|undefined);description?:union(string|undefined);meta?:union(array(Record)|undefined);title?:union(string|undefined)});kind:\"page\";props?:union(undefined|{call:(context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});renderIntent:{mode:union(\"dynamic\"|\"static\")};route?:union(undefined|{id?:union(string|undefined);params?:union(array(string)|undefined);path?:union(string|undefined)})}})}", + "definePage=value:{call: =Record>(componentClass:CustomElementConstructor,descriptor:union(undefined|{error?:union(undefined|{call:(error:unknown,context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});head?:union(undefined|{dangerouslyHeadFragments?:union(array(string)|undefined);description?:union(string|undefined);meta?:union(array(Record)|undefined);title?:union(string|undefined)});props?:union(undefined|{call:(context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});renderIntent?:union(undefined|{mode?:union(\"dynamic\"|\"static\"|undefined)});route?:union(undefined|{id?:union(string|undefined);params?:union(array(string)|undefined)})}))=>intersection(CustomElementConstructor&{openElementPage:{error?:union(undefined|{call:(error:unknown,context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});head?:union(undefined|{dangerouslyHeadFragments?:union(array(string)|undefined);description?:union(string|undefined);meta?:union(array(Record)|undefined);title?:union(string|undefined)});kind:\"page\";props?:union(undefined|{call:(context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});renderIntent:{mode:union(\"dynamic\"|\"static\")};route?:union(undefined|{id?:union(string|undefined);params?:union(array(string)|undefined)})}})}", "fail=value:{call:(status:number,data:Data)=>{readonly data:Data;readonly name:\"OpenElementActionFailure\";readonly status:number}}", "isActionFailure=value:{call:(error:unknown)=>union(false|true)}", "isOpenElementNotFound=value:{call:(error:unknown)=>union(false|true)}", @@ -214,11 +215,29 @@ "definePreactIsland=value:{call:unknown} ={index:string=>unknown}>(tagName:string,Component:{call:(props:Props)=>union(VNode|bigint|false|null|number|object|string|true|undefined)},options:{props?:union(undefined|{index:string=>unknown});ssr?:union(false|true|undefined)})=>{construct:(...params:array(any))=>HTMLElement;renderSsr:{call:(props:union(undefined|{index:string=>unknown}))=>string}}}" ] }, + "./router": { + "publicShapeSha256": "792f56d62a1ec7c69641be87a0972f73d5a73a369584575213ee2ef688d02dde", + "publicSymbols": [ + "RouteMatch=type:{id:string;params:Record;patternResult:URLPatternResult;route:T;searchParams:URLSearchParams}", + "RouteRecord=type:{id?:union(string|undefined);methods?:union(array(string)|undefined);path:string;pattern?:union(URLPatternInit|undefined)}", + "RouteResolution=type:union(intersection({id:string;params:Record;patternResult:URLPatternResult;route:T;searchParams:URLSearchParams}&{kind:\"match\";method:string})|{allow:array(string);kind:\"method-not-allowed\"}|{kind:\"not-found\"})", + "RouteTable=type:{candidateCount:{call:(input:union(URL|string))=>number};match:{call:(input:union(URL|string),search:string)=>union(null|{id:string;params:Record;patternResult:URLPatternResult;route:T;searchParams:URLSearchParams})};readonly options:{basePath?:union(string|undefined);trailingSlash?:union(\"ignore\"|\"strict\"|undefined)};readonly routes:array(T);resolve:{call:(input:union(URL|string),search:string,method:string)=>union(intersection({id:string;params:Record;patternResult:URLPatternResult;route:T;searchParams:URLSearchParams}&{kind:\"match\";method:string})|{allow:array(string);kind:\"method-not-allowed\"}|{kind:\"not-found\"})}}|value:{construct:(routes:array(T),Pattern:{construct:(init:URLPatternInit)=>URLPattern},options:{basePath?:union(string|undefined);trailingSlash?:union(\"ignore\"|\"strict\"|undefined)})=>{candidateCount:{call:(input:union(URL|string))=>number};match:{call:(input:union(URL|string),search:string)=>union(null|{id:string;params:Record;patternResult:URLPatternResult;route:T;searchParams:URLSearchParams})};readonly options:{basePath?:union(string|undefined);trailingSlash?:union(\"ignore\"|\"strict\"|undefined)};readonly routes:array(T);resolve:{call:(input:union(URL|string),search:string,method:string)=>union(intersection({id:string;params:Record;patternResult:URLPatternResult;route:T;searchParams:URLSearchParams}&{kind:\"match\";method:string})|{allow:array(string);kind:\"method-not-allowed\"}|{kind:\"not-found\"})}}}", + "RouteTableOptions=type:{basePath?:union(string|undefined);trailingSlash?:union(\"ignore\"|\"strict\"|undefined)}", + "normalizeRoutePatternForURLPattern=value:{call:(path:string)=>string}" + ] + }, + "./router/http": { + "publicShapeSha256": "8a8e56188d517d7aa62abdd1028fa6d096383a69869eb2446b87267f1e3f5a47", + "publicSymbols": [ + "HttpRouteRecord=type:{handlers:Readonly>;id?:union(string|undefined);path:string;pattern?:union(URLPatternInit|undefined)}", + "createRouteMiddleware=value:{call:(records:array({handlers:Readonly>;id?:union(string|undefined);path:string;pattern?:union(URLPatternInit|undefined)}),options:intersection({basePath?:union(string|undefined);trailingSlash?:union(\"ignore\"|\"strict\"|undefined)}&{methodNotAllowed?:union(undefined|{call:(context:Context,allow:array(string))=>union(Promise|Response)})}))=>MiddlewareHandler}" + ] + }, "./spa": { - "publicShapeSha256": "f9be0b01b6f69ef3e03c01b6d04e90f58564c0b8495495c79a4dbaba06a64d9a", + "publicShapeSha256": "43a82110011e3fab7b8eaa5dad3694ca05eeec97b8253509e091f947d8369611", "publicSymbols": [ - "SpaAppInstance=type:{dispose:{call:()=>void};mount:{call:(selector:string)=>void};readonly router:union(null|{currentPath:string;currentRoute:union(null|{action?:union(undefined|{call:(ctx:SpaActionContext)=>Promise});guard?:union(undefined|{call:()=>Promise});loader?:union(undefined|{call:(ctx:SpaLoaderContext)=>Promise});path:string;tagName:string});dispose:{call:()=>void};navigate:{call:(path:string)=>Promise};params:Record;replace:{call:(path:string)=>Promise}})}", - "defineApp=value:{call:(options:{mode:\"spa\";routerMode?:union(\"auto\"|\"hash\"|\"history\"|undefined);routes?:union(array({action?:union(undefined|{call:(ctx:SpaActionContext)=>Promise});guard?:union(undefined|{call:()=>Promise});loader?:union(undefined|{call:(ctx:SpaLoaderContext)=>Promise});path:string;tagName:string})|undefined)})=>{dispose:{call:()=>void};mount:{call:(selector:string)=>void};readonly router:union(null|{currentPath:string;currentRoute:union(null|{action?:union(undefined|{call:(ctx:SpaActionContext)=>Promise});guard?:union(undefined|{call:()=>Promise});loader?:union(undefined|{call:(ctx:SpaLoaderContext)=>Promise});path:string;tagName:string});dispose:{call:()=>void};navigate:{call:(path:string)=>Promise};params:Record;replace:{call:(path:string)=>Promise}})}}" + "SpaAppInstance=type:{dispose:{call:()=>void};mount:{call:(selector:string)=>void};readonly router:union(null|{currentPath:string;currentRoute:union(null|{action?:union(undefined|{call:(ctx:any)=>Promise});guard?:union(undefined|{call:()=>Promise});id?:union(string|undefined);loader?:union(undefined|{call:(ctx:any)=>Promise});methods?:union(array(string)|undefined);path:string;pattern?:union(URLPatternInit|undefined);tagName:string});dispose:{call:()=>void};navigate:{call:(path:string)=>Promise};params:Record;readonly searchParams:URLSearchParams;replace:{call:(path:string)=>Promise}})}", + "defineApp=value:{call:(options:{mode:\"spa\";routerMode?:union(\"auto\"|\"hash\"|\"history\"|undefined);routes?:union(array({action?:union(undefined|{call:(ctx:any)=>Promise});guard?:union(undefined|{call:()=>Promise});id?:union(string|undefined);loader?:union(undefined|{call:(ctx:any)=>Promise});methods?:union(array(string)|undefined);path:string;pattern?:union(URLPatternInit|undefined);tagName:string})|undefined)})=>{dispose:{call:()=>void};mount:{call:(selector:string)=>void};readonly router:union(null|{currentPath:string;currentRoute:union(null|{action?:union(undefined|{call:(ctx:any)=>Promise});guard?:union(undefined|{call:()=>Promise});id?:union(string|undefined);loader?:union(undefined|{call:(ctx:any)=>Promise});methods?:union(array(string)|undefined);path:string;pattern?:union(URLPatternInit|undefined);tagName:string});dispose:{call:()=>void};navigate:{call:(path:string)=>Promise};params:Record;readonly searchParams:URLSearchParams;replace:{call:(path:string)=>Promise}})}}" ] } } @@ -229,6 +248,7 @@ ".": "./src/index.ts", "./cli/build": "./src/cli/build.ts", "./cli/start": "./src/cli/start.ts", + "./element": "./src/element.ts", "./nitro-mount": "./src/nitro-mount.ts", "./sitemap": "./src/sitemap.ts" }, @@ -292,6 +312,12 @@ "extractServeMode=value:{call:(argv:array(string))=>{mode:union(\"preview\"|\"start\");rest:array(string)}}" ] }, + "./element": { + "publicShapeSha256": "20f99852cc54efbb297149f6a1b776bec63821b4760ba00381e5691cef3e3d89", + "publicSymbols": [ + "element=value:{call:()=>Plugin}" + ] + }, "./nitro-mount": { "publicShapeSha256": "7b4a0b7d3228db51275a8c198bffb84291345f126243e02a97a1c9ef8f5d4523", "publicSymbols": [ diff --git a/packages/adapter-vite/README.md b/packages/adapter-vite/README.md index f24e96f41..4bef8ef85 100644 --- a/packages/adapter-vite/README.md +++ b/packages/adapter-vite/README.md @@ -99,7 +99,7 @@ Phase 2: client island entry and browser chunks - Otherwise the adapter writes a _fallback shell_: a bare `
` plus a `console.info` placeholder script. It is a build marker so the output directory is servable — not a runnable app. Either way, a route - manifest module (`route-manifest.ts`, exporting `routeManifest`) is + manifest module (`route-manifest.ts`, exporting ordered `routeRecords`) is written next to it for client-side routing; import it from your bootstrap as `../dist/route-manifest.ts`. @@ -150,3 +150,16 @@ and only then update config and generated registration. ## License MIT + +### Standalone Element authoring + +Install Element, Vite and this adapter; import `element` from +`@openelement/adapter-vite/element` and put `element()` in Vite's `plugins`. +This entry does not load Router, SSG or deployment tooling. Router is an optional +peer; Framework applications install `@openelement/app` explicitly. + +Keep the decorated component in a `.tsx` module and register its exported class +from a separate JS entry. Use a Vite library build to bundle that entry, then +load the resulting JS from ordinary HTML. The consumer needs the bundled Element +runtime, not the compiler. `deno run -A tools/consumer-packaged-element.ts` in the +repository exercises this path using packed artifacts. diff --git a/packages/adapter-vite/__fixtures__/request-time/e2e/live.spec.ts b/packages/adapter-vite/__fixtures__/request-time/e2e/live.spec.ts index cd8756691..0f263dc88 100644 --- a/packages/adapter-vite/__fixtures__/request-time/e2e/live.spec.ts +++ b/packages/adapter-vite/__fixtures__/request-time/e2e/live.spec.ts @@ -309,7 +309,7 @@ test.describe('protocol hardening (ADR-0121, 0.42.0-alpha.5)', () => { test('non-GET/POST methods are a defined 405 with Allow (#572)', async ({ request }) => { const response = await request.put('/form', { data: 'x=1' }); expect(response.status()).toBe(405); - expect(response.headers()['allow']).toBe('GET, POST'); + expect(response.headers()['allow']).toBe('GET, HEAD, POST'); }); test('POST takes the same error-boundary channel as GET (#551)', async ({ request }) => { diff --git a/packages/adapter-vite/__tests__/entry-descriptor.test.ts b/packages/adapter-vite/__tests__/entry-descriptor.test.ts index f82d7f865..335d065b8 100644 --- a/packages/adapter-vite/__tests__/entry-descriptor.test.ts +++ b/packages/adapter-vite/__tests__/entry-descriptor.test.ts @@ -234,10 +234,10 @@ Deno.test('renderEntry: page routes use SSR helper and wrapInDocument', () => { const desc = buildEntryDescriptor(sampleRoutes); const code = renderEntry(desc); - assertStringIncludes(code, 'app.get("/",'); + assertStringIncludes(code, '__pageHandlers["/"].GET = ['); // v0.5.0: __ssr takes route params as second arg for SSR-time data access assertStringIncludes(code, '__ssr(tag'); - assertStringIncludes(code, 'c.req.param()'); + assertStringIncludes(code, "c.get('routeResolution').params"); // v0.3.4: SSR automatically registers page components for Shadow DOM rendering assertStringIncludes(code, 'customElements.define('); // v0.5.0: DSD renderer uses customElements.get(tag) to find component class @@ -296,8 +296,8 @@ Deno.test('buildEntryDescriptor + renderEntry: end-to-end produces runnable code assertStringIncludes(code, "import { Hono } from 'hono'"); assertStringIncludes(code, 'export default app'); assertStringIncludes(code, 'app.route("/api/hello"'); - assertStringIncludes(code, 'app.get("/",'); - assertStringIncludes(code, 'app.get("/about",'); + assertStringIncludes(code, '__pageHandlers["/"].GET = ['); + assertStringIncludes(code, '__pageHandlers["/about"].GET = ['); // No process.env call in non-comment lines const codeLines = code.split('\n').filter((l) => !l.trimStart().startsWith('//')); assertEquals(codeLines.some((l) => l.includes('process.env')), false); diff --git a/packages/adapter-vite/__tests__/entry-renderer.test.ts b/packages/adapter-vite/__tests__/entry-renderer.test.ts index 5f951d468..5c4b62854 100644 --- a/packages/adapter-vite/__tests__/entry-renderer.test.ts +++ b/packages/adapter-vite/__tests__/entry-renderer.test.ts @@ -916,7 +916,7 @@ Deno.test('renderEntry: ADR-0121 hardening is present in the action codegen', () // #568: action POSTs carry a default body limit. assertStringIncludes(code, '__bodyLimit({ maxSize: 10 * 1024 * 1024'); // #572: non-GET/POST methods on page routes are a defined 405. - assertStringIncludes(code, "c.text('Method Not Allowed', 405, { Allow: 'GET, POST' })"); + assertStringIncludes(code, "app.all('*', __createRouteMiddleware(["); }); Deno.test('renderEntry: action protocol is emitted once for many routes (#1098)', () => { diff --git a/packages/adapter-vite/__tests__/foreign-tag-scanner.test.ts b/packages/adapter-vite/__tests__/foreign-tag-scanner.test.ts index 1c0e7691e..873a790e0 100644 --- a/packages/adapter-vite/__tests__/foreign-tag-scanner.test.ts +++ b/packages/adapter-vite/__tests__/foreign-tag-scanner.test.ts @@ -51,7 +51,6 @@ const PAGE_SOURCE = ` import { definePage } from '@openelement/app'; export default definePage({ - route: { path: '/' }, render() { return (
diff --git a/packages/adapter-vite/__tests__/request-time-admission-parity.test.ts b/packages/adapter-vite/__tests__/request-time-admission-parity.test.ts index 999ca759c..45ccb91fd 100644 --- a/packages/adapter-vite/__tests__/request-time-admission-parity.test.ts +++ b/packages/adapter-vite/__tests__/request-time-admission-parity.test.ts @@ -21,8 +21,9 @@ import { assert, assertEquals, assertStringIncludes } from '@std/assert'; import { join } from '@std/path'; import { Hono } from 'hono'; +import { createRouteMiddleware } from '../../app/src/router-http.ts'; import { RouteTable } from '../../app/src/internal/router/route-table.ts'; -import { normalizeRoutePatternForURLPattern } from '@openelement/element/build-utils'; +import { normalizeRoutePatternForURLPattern } from '@openelement/app/router'; import { renderRequestTimeServerModule } from '../src/internal/ssg/ssg-helpers.ts'; interface CorpusRoute { @@ -167,7 +168,7 @@ const CORPUS: CorpusCase[] = [ pathname: '/item/42', search: '?id=query-loses&extra=query-wins', winner: '/item/:id', - params: { id: '42', extra: 'query-wins' }, + params: { id: '42' }, }, ], }, @@ -236,17 +237,21 @@ function derivedAdmission(routes: CorpusRoute[], pathname: string): boolean { */ function honoEntryFor(routes: CorpusRoute[]): Hono { const app = new Hono(); - for (const route of routes) { - const methods = route.methods ?? ['GET']; - const handler = (c: { req: { param: () => Record } }) => - new Response(JSON.stringify({ path: route.path, params: c.req.param() }), { - headers: { 'content-type': 'application/json' }, - }); - app.on([...methods], route.path, handler); - if (methods.includes('POST')) { - app.all(route.path, () => new Response('Method Not Allowed', { status: 405 })); - } - } + app.all( + '*', + createRouteMiddleware(routes.map((route) => ({ + path: route.path, + handlers: Object.fromEntries( + (route.methods ?? ['GET']).map(( + method, + ) => [ + method, + (c: import('hono').Context) => + c.json({ path: route.path, params: c.get('routeResolution').params }), + ]), + ), + }))), + ); return app; } diff --git a/packages/adapter-vite/__tests__/route-manifest.test.ts b/packages/adapter-vite/__tests__/route-manifest.test.ts index 358e124b1..d29e7f71f 100644 --- a/packages/adapter-vite/__tests__/route-manifest.test.ts +++ b/packages/adapter-vite/__tests__/route-manifest.test.ts @@ -56,7 +56,7 @@ Deno.test({ const manifestPath = join(dir.path, '.openelement/route-manifest.ts'); const content = await generateContent(routesDir, manifestPath); - assertStringIncludes(content, '"/": () => import('); + assertStringIncludes(content, "path: \"/\", methods: ['GET', 'POST'], load: () => import("); assertMatch(content, /\/routes\/index\.tsx/); } finally { dir.cleanup(); @@ -76,7 +76,10 @@ Deno.test({ const manifestPath = join(dir.path, '.openelement/route-manifest.ts'); const content = await generateContent(routesDir, manifestPath); - assertStringIncludes(content, '"/products": () => import('); + assertStringIncludes( + content, + "path: \"/products\", methods: ['GET', 'POST'], load: () => import(", + ); } finally { dir.cleanup(); } @@ -95,7 +98,10 @@ Deno.test({ const manifestPath = join(dir.path, '.openelement/route-manifest.ts'); const content = await generateContent(routesDir, manifestPath); - assertStringIncludes(content, '"/products/:id": () => import('); + assertStringIncludes( + content, + "path: \"/products/:id\", methods: ['GET', 'POST'], load: () => import(", + ); } finally { dir.cleanup(); } @@ -114,7 +120,10 @@ Deno.test({ const manifestPath = join(dir.path, '.openelement/route-manifest.ts'); const content = await generateContent(routesDir, manifestPath); - assertStringIncludes(content, '"/products": () => import('); + assertStringIncludes( + content, + "path: \"/products\", methods: ['GET', 'POST'], load: () => import(", + ); } finally { dir.cleanup(); } @@ -133,7 +142,10 @@ Deno.test({ const manifestPath = join(dir.path, '.openelement/route-manifest.ts'); const content = await generateContent(routesDir, manifestPath); - assertStringIncludes(content, '"/products/reviews": () => import('); + assertStringIncludes( + content, + "path: \"/products/reviews\", methods: ['GET', 'POST'], load: () => import(", + ); } finally { dir.cleanup(); } @@ -154,7 +166,10 @@ Deno.test({ // scanRoutes (#556) converts a catch-all segment to the Hono named // regex parameter :slug{.+} (matches across '/'). - assertStringIncludes(content, '"/products/:slug{.+}": () => import('); + assertStringIncludes( + content, + "path: \"/products/:slug{.+}\", methods: ['GET', 'POST'], load: () => import(", + ); } finally { dir.cleanup(); } @@ -175,7 +190,7 @@ Deno.test({ const manifestPath = join(dir.path, '.openelement/route-manifest.ts'); const content = await generateContent(routesDir, manifestPath); - assertStringIncludes(content, 'export const routeManifest = {} as const;'); + assertStringIncludes(content, 'export const routeRecords = [] as const;'); assertStringIncludes(content, 'No page routes found'); } finally { dir.cleanup(); @@ -194,7 +209,7 @@ Deno.test({ const manifestPath = join(dir.path, '.openelement/route-manifest.ts'); const content = await generateContent(routesDir, manifestPath); - assertStringIncludes(content, '{} as const;'); + assertStringIncludes(content, '[] as const;'); } finally { dir.cleanup(); } @@ -215,7 +230,7 @@ Deno.test({ const manifestPath = join(dir.path, '.openelement/route-manifest.ts'); const content = await generateContent(routesDir, manifestPath); - assertStringIncludes(content, '"/": () => import('); + assertStringIncludes(content, "path: \"/\", methods: ['GET', 'POST'], load: () => import("); assertEquals(content.includes('_renderer'), false); assertEquals(content.includes('_middleware'), false); } finally { @@ -237,7 +252,7 @@ Deno.test({ const manifestPath = join(dir.path, '.openelement/route-manifest.ts'); const content = await generateContent(routesDir, manifestPath); - assertStringIncludes(content, '"/": () => import('); + assertStringIncludes(content, "path: \"/\", methods: ['GET', 'POST'], load: () => import("); assertEquals(content.includes('/api/posts'), false); } finally { dir.cleanup(); @@ -262,7 +277,7 @@ Deno.test({ // Verify structural elements assertStringIncludes(content, 'Auto-generated'); - assertStringIncludes(content, 'export const routeManifest'); + assertStringIncludes(content, 'export const routeRecords'); assertStringIncludes(content, 'as const;'); // Must start with comment/export assertMatch(content, /^\/\//); @@ -312,9 +327,15 @@ Deno.test({ // Verify the file was written const written = await Deno.readTextFile(join(outDir, 'route-manifest.ts')); - assertStringIncludes(written, '"/": () => import('); - assertStringIncludes(written, '"/about": () => import('); - assertStringIncludes(written, '"/products": () => import('); + assertStringIncludes(written, "path: \"/\", methods: ['GET', 'POST'], load: () => import("); + assertStringIncludes( + written, + "path: \"/about\", methods: ['GET', 'POST'], load: () => import(", + ); + assertStringIncludes( + written, + "path: \"/products\", methods: ['GET', 'POST'], load: () => import(", + ); } finally { dir.cleanup(); } @@ -359,10 +380,19 @@ Deno.test({ const manifestPath = join(dir.path, '.openelement/route-manifest.ts'); const content = await generateContent(routesDir, manifestPath); - assertStringIncludes(content, '"/": () => import('); - assertStringIncludes(content, '"/products": () => import('); - assertStringIncludes(content, '"/products/:id": () => import('); - assertStringIncludes(content, '"/about": () => import('); + assertStringIncludes(content, "path: \"/\", methods: ['GET', 'POST'], load: () => import("); + assertStringIncludes( + content, + "path: \"/products\", methods: ['GET', 'POST'], load: () => import(", + ); + assertStringIncludes( + content, + "path: \"/products/:id\", methods: ['GET', 'POST'], load: () => import(", + ); + assertStringIncludes( + content, + "path: \"/about\", methods: ['GET', 'POST'], load: () => import(", + ); } finally { dir.cleanup(); } @@ -383,7 +413,10 @@ Deno.test({ const manifestPath = join(dir.path, '.openelement/route-manifest.ts'); const content = await generateContent(routesDir, manifestPath); - assertStringIncludes(content, '"/about": () => import('); + assertStringIncludes( + content, + "path: \"/about\", methods: ['GET', 'POST'], load: () => import(", + ); } finally { dir.cleanup(); } @@ -406,9 +439,26 @@ Deno.test({ // Bare interpolation used to emit import('../routes/it's.tsx') — a // syntax error. Literals now go through codegen-literals quoting. - assertStringIncludes(content, '"/it\'s": () => import("../routes/it\'s.tsx")'); + assertStringIncludes( + content, + "path: \"/it's\", methods: ['GET', 'POST'], load: () => import(\"../routes/it's.tsx\")", + ); } finally { dir.cleanup(); } }, }); + +Deno.test('file records order parameters before catch-all regardless of parameter names', async () => { + const dir = tempDir(); + try { + await writeRoute(dir.path, 'products/[...a].tsx'); + await writeRoute(dir.path, 'products/[z].tsx'); + await writeRoute(dir.path, 'products/new.tsx'); + const { scanRoutes } = await import('../src/internal/ssg/route-scanner.ts'); + const records = await scanRoutes(dir.path); + assertEquals(records.map((r) => r.path), ['/products/new', '/products/:z', '/products/:a{.+}']); + } finally { + dir.cleanup(); + } +}); diff --git a/packages/adapter-vite/__tests__/ssg-helpers.test.ts b/packages/adapter-vite/__tests__/ssg-helpers.test.ts index d769576d0..33fd3d5e9 100644 --- a/packages/adapter-vite/__tests__/ssg-helpers.test.ts +++ b/packages/adapter-vite/__tests__/ssg-helpers.test.ts @@ -3,7 +3,6 @@ import { renderRequestTimeServerModule, renderStandaloneServerModule, resolveDynamicRoutePath, - routePatternToURLPatternPath, } from '../src/internal/ssg/ssg-helpers.ts'; import { parseRouteFilePath } from '../src/internal/ssg/route-scanner.ts'; import { cacheControlFor, contentTypeFor } from '../src/internal/static-serve.ts'; @@ -43,14 +42,6 @@ Deno.test('resolveDynamicRoutePath rejects traversal segments inside catch-all v assertThrows(() => resolveDynamicRoutePath('/docs/:path{.+}', ['path'], { path: '..' })); }); -Deno.test('routePatternToURLPatternPath covers exact, param and catch-all patterns (#556, #856)', () => { - // Exact and plain param segments are already valid URLPattern pathnames. - assertEquals(routePatternToURLPatternPath('/form'), '/form'); - assertEquals(routePatternToURLPatternPath('/item/:id'), '/item/:id'); - // The Hono-style `:name{regex}` catch-all rewrites to URLPattern `:name(regex)`. - assertEquals(routePatternToURLPatternPath('/docs/:path{.+}'), '/docs/:path(.+)'); -}); - Deno.test('request-time client injection embeds portable tolerant helper and preserves statusText (#1103)', () => { const code = renderRequestTimeServerModule([]); assertStringIncludes(code, 'function insertBeforeBodyClose(html, fragment)'); diff --git a/packages/adapter-vite/deno.json b/packages/adapter-vite/deno.json index 8c5149a77..e2408d6b7 100644 --- a/packages/adapter-vite/deno.json +++ b/packages/adapter-vite/deno.json @@ -3,6 +3,7 @@ "version": "0.44.0-beta.2", "exports": { ".": "./src/index.ts", + "./element": "./src/element.ts", "./nitro-mount": "./src/nitro-mount.ts", "./sitemap": "./src/sitemap.ts", "./cli/build": "./src/cli/build.ts", diff --git a/packages/adapter-vite/src/element.ts b/packages/adapter-vite/src/element.ts new file mode 100644 index 000000000..97aa27ad8 --- /dev/null +++ b/packages/adapter-vite/src/element.ts @@ -0,0 +1,2 @@ +/** Standalone Element authoring plugin. No Router, SSG or deployment imports. */ +export { compiledElementPlugin as element } from './internal/compiler/plugin.ts'; diff --git a/packages/adapter-vite/src/generated-export-files.ts b/packages/adapter-vite/src/generated-export-files.ts index a832ef0bc..8e3f3443c 100644 --- a/packages/adapter-vite/src/generated-export-files.ts +++ b/packages/adapter-vite/src/generated-export-files.ts @@ -6,6 +6,7 @@ export const OPENELEMENT_EXPORT_FILES: Record> = '.': 'src/index.ts', 'cli/build': 'src/cli/build.ts', 'cli/start': 'src/cli/start.ts', + 'element': 'src/element.ts', 'nitro-mount': 'src/nitro-mount.ts', 'sitemap': 'src/sitemap.ts', }, @@ -14,6 +15,8 @@ export const OPENELEMENT_EXPORT_FILES: Record> = 'i18n': 'src/i18n.ts', 'model': 'src/model.ts', 'preact': 'src/preact.ts', + 'router': 'src/router.ts', + 'router/http': 'src/router-http.ts', 'spa': 'src/spa.ts', }, 'create': { diff --git a/packages/adapter-vite/src/internal/ssg/entry-codegen.ts b/packages/adapter-vite/src/internal/ssg/entry-codegen.ts index 0b98c6573..768a3f254 100644 --- a/packages/adapter-vite/src/internal/ssg/entry-codegen.ts +++ b/packages/adapter-vite/src/internal/ssg/entry-codegen.ts @@ -76,10 +76,10 @@ function renderRouteHandlerPreamble(lines: string[], ctx: RouteHandlerEmitContex // ADR-0121 (#568): conservative default body limit on action POSTs; // larger uploads belong on API routes with explicit limits. lines.push( - `app.post(${pathLiteral}, __bodyLimit({ maxSize: 10 * 1024 * 1024, onError: (c) => { c.header('Cache-Control', 'no-store'); c.header('Vary', __actionFetchHeader); return c.text('Payload Too Large', 413); } }), async (c) => {`, + `__pageHandlers[${pathLiteral}].POST = [__bodyLimit({ maxSize: 10 * 1024 * 1024, onError: (c) => { c.header('Cache-Control', 'no-store'); c.header('Vary', __actionFetchHeader); return c.text('Payload Too Large', 413); } }), async (c) => {`, ); } else { - lines.push(`app.get(${pathLiteral}, async (c) => {`); + lines.push(`__pageHandlers[${pathLiteral}].GET = [async (c) => {`); } // ADR-0129: one mutable response-header channel per request, shared by the // loader and the action (the spread into the action context carries the @@ -105,7 +105,7 @@ function renderRouteHandlerPreamble(lines: string[], ctx: RouteHandlerEmitContex lines.push(` const __actionState = { isFetch: false };`); } lines.push(` try {`); - lines.push(` __params = c.req.param() || {}`); + lines.push(` __params = c.get('routeResolution').params`); lines.push(` const __loadContext = {`); lines.push(` params: __params,`); lines.push(` request: c.req.raw,`); @@ -321,7 +321,7 @@ function renderRouteResponseAndCatch(lines: string[], ctx: RouteHandlerEmitConte // ADR-0129: close the handler-body IIFE and merge the response-header // channel into whatever response the body produced. lines.push(` })(), __responseHeaders);`); - lines.push(`})`); + lines.push(`}];`); lines.push(''); } @@ -371,16 +371,6 @@ export function renderActionRoute( isSSG: boolean, ): void { renderRouteHandler(lines, { method: 'post', route, renderers, docConfig, isSSG }); - // ADR-0121 (#572): only GET/POST are defined for page routes — other - // methods get a defined 405 instead of the server fallback 404. The - // method-specific handlers above are registered first and win for - // GET/POST/HEAD. no-store/Vary apply to the 405 as well (#586). - lines.push( - `app.all(${ - quoteGeneratedJavaScriptValue(route.path) - }, (c) => { c.header('Cache-Control', 'no-store'); c.header('Vary', __actionFetchHeader); return c.text('Method Not Allowed', 405, { Allow: 'GET, POST' }); });`, - ); - lines.push(''); } /** diff --git a/packages/adapter-vite/src/internal/ssg/entry-orchestrator.ts b/packages/adapter-vite/src/internal/ssg/entry-orchestrator.ts index a31be8f01..519123098 100644 --- a/packages/adapter-vite/src/internal/ssg/entry-orchestrator.ts +++ b/packages/adapter-vite/src/internal/ssg/entry-orchestrator.ts @@ -49,6 +49,10 @@ export function renderEntry(desc: EntryDescriptor): string { const ssrAdmissionPlan = desc.ssrAdmissionPlan; for (const island of desc.islands) validateIslandModuleSpecifier(island.modulePath); + lines.push( + "import { createRouteMiddleware as __createRouteMiddleware } from '@openelement/app/router/http';", + ); + // --- Imports --- for (const imp of desc.imports) { lines.push(renderImport(imp)); @@ -318,6 +322,11 @@ export function renderEntry(desc: EntryDescriptor): string { renderApiRoute(lines, route); } + lines.push( + `const __pageHandlers = Object.fromEntries(${ + JSON.stringify(desc.pageRoutes.map((r) => r.path)) + }.map(path => [path, {}]));`, + ); // --- Page routes --- const docConfig = { title: desc.document.title, @@ -334,6 +343,18 @@ export function renderEntry(desc: EntryDescriptor): string { renderActionRoute(lines, route, desc.renderers, docConfig, desc.isSSG); } + lines.push(`app.all('*', __createRouteMiddleware([`); + for (const route of desc.pageRoutes) { + lines.push( + ` { id: ${JSON.stringify(route.filePath)}, path: ${ + JSON.stringify(route.path) + }, handlers: __pageHandlers[${JSON.stringify(route.path)}] },`, + ); + } + lines.push( + `], { methodNotAllowed: (c, allow) => { c.header('Cache-Control', 'no-store'); c.header('Vary', __actionFetchHeader); return c.text('Method Not Allowed', 405, { Allow: allow.join(', ') }); } }));`, + ); + // --- Styled 404 (#923): unmatched paths render the /404 page --- const notFoundPage = desc.pageRoutes.find((r) => r.path === '/404'); if (notFoundPage) { diff --git a/packages/adapter-vite/src/internal/ssg/route-scanner.ts b/packages/adapter-vite/src/internal/ssg/route-scanner.ts index 16ea5f8f4..068f9d7d7 100644 --- a/packages/adapter-vite/src/internal/ssg/route-scanner.ts +++ b/packages/adapter-vite/src/internal/ssg/route-scanner.ts @@ -329,18 +329,22 @@ export async function scanRoutes( } } - // Sort routes: static paths first, then dynamic + // Generated order: special files last; static, parameters, catch-all; + // ties use code-point path/file ordering independent of machine locale. entries.sort((a, b) => { - // Special files go to the end - if (a.special || b.special) { - if (a.special && !b.special) return 1; - if (!a.special && b.special) return -1; - return 0; - } - const aDynamic = a.path.includes(':'); - const bDynamic = b.path.includes(':'); - if (aDynamic !== bDynamic) return aDynamic ? 1 : -1; - return a.path.localeCompare(b.path); + const rank = (r: RouteEntry) => + r.special ? 3 : !r.path.includes(':') ? 0 : r.path.includes('{.+}') ? 2 : 1; + const difference = rank(a) - rank(b); + if (difference) return difference; + return a.path < b.path + ? -1 + : a.path > b.path + ? 1 + : a.filePath < b.filePath + ? -1 + : a.filePath > b.filePath + ? 1 + : 0; }); // #1029: pathToVarName folds '/', '-', and '_' into '_', so paths like @@ -350,6 +354,25 @@ export async function scanRoutes( // both source paths. Checked once at the top-level call (recursion passes a // non-empty baseDir). if (baseDir === '') { + const seenPaths = new Map(); + for (const entry of entries) { + if (entry.special) continue; + // Only compare the scanner's bracket-file grammar; no regex-language + // equivalence or arbitrary overlap rejection is attempted. + const shape = parseRouteFilePath( + entry.filePath.replace(/\[\.\.\.[^\]]+\]/g, '[...param]').replace( + /\[(?!\.\.\.)[^\]]+\]/g, + '[param]', + ), + ); + const previous = seenPaths.get(shape); + if (previous) { + throw new Error( + `Equivalent route files: '${previous}' and '${entry.filePath}' produce '${shape}'`, + ); + } + seenPaths.set(shape, entry.filePath); + } const seenVarNames = new Map(); for (const entry of entries) { const existing = seenVarNames.get(entry.varName); diff --git a/packages/adapter-vite/src/internal/ssg/ssg-helpers.ts b/packages/adapter-vite/src/internal/ssg/ssg-helpers.ts index 6a47e840a..a1b20b1db 100644 --- a/packages/adapter-vite/src/internal/ssg/ssg-helpers.ts +++ b/packages/adapter-vite/src/internal/ssg/ssg-helpers.ts @@ -5,7 +5,7 @@ * This module sits at the bottom of the dependency graph. */ -import { normalizeRoutePatternForURLPattern } from '@openelement/element/build-utils'; +import { normalizeRoutePatternForURLPattern } from '@openelement/app/router'; import { walkHtmlFileEntries } from '../html-files.ts'; import { NODE_BRIDGE_EMBEDDED_FUNCTIONS } from '../node-bridge.ts'; @@ -95,21 +95,6 @@ interface RequestTimeRoutePattern { path: string; } -/** - * Translate a request-time route pattern ('/item/:id', '/docs/:path{.+}') - * into a WHATWG URLPattern pathname (#856, ADR-0123). The framework dialect - * is already URLPattern-shaped except for the Hono-style `:name{regex}` - * catch-all emitted by the route scanner (#812), which rewrites to the - * URLPattern `:name(regex)` form. Used to generate the self-contained - * admission predicate inside dist/server/index.js (#556, narrowed by #1215): - * hosts get a dispatch predicate instead of re-implementing pattern matching - * against the raw ':param' strings in server-manifest.json. - * - * The implementation is shared with the app client router through - * @openelement/element/build-utils (#1103). - */ -export const routePatternToURLPatternPath = normalizeRoutePatternForURLPattern; - /** * Serialize the request-time admission patterns embedded in the generated * server entry (#1215). Declaration order is preserved and irrelevant: the @@ -118,7 +103,7 @@ export const routePatternToURLPatternPath = normalizeRoutePatternForURLPattern; function renderRequestTimeAdmissionPatterns(routes: RequestTimeRoutePattern[]): string { return routes .map((route) => { - const pattern = JSON.stringify(routePatternToURLPatternPath(route.path)); + const pattern = JSON.stringify(normalizeRoutePatternForURLPattern(route.path)); return ` new URLPattern({ pathname: ${pattern} }),`; }) .join('\n'); diff --git a/packages/adapter-vite/src/route-manifest.ts b/packages/adapter-vite/src/route-manifest.ts index bf36561c0..0c48ec20a 100644 --- a/packages/adapter-vite/src/route-manifest.ts +++ b/packages/adapter-vite/src/route-manifest.ts @@ -10,15 +10,15 @@ * ```ts * // Generated to `${outDir}/route-manifest.ts`; import from that generated * // module or alias it from your client entry. - * import { routeManifest } from './route-manifest.ts'; + * import { routeRecords } from './route-manifest.ts'; * import { defineApp } from '@openelement/app'; * * const app = defineApp({ * mode: 'spa', * routes: await Promise.all( - * Object.entries(routeManifest).map(async ([path, load]) => { + * routeRecords.map(async ({ load, ...record }) => { * const mod = await load(); - * return { path, tagName: mod.tagName }; + * return { ...record, tagName: mod.tagName }; * }), * ), * }); @@ -74,9 +74,11 @@ async function generateRouteManifest( // Route paths and file names are interpolated into generated code, so // they go through the shared JS-literal quoting (a `'` in a file name // must not break the manifest's syntax, #1039). - return ` ${quoteGeneratedJavaScriptValue(r.path)}: () => import(${ + return ` { id: ${quoteGeneratedJavaScriptValue(r.filePath)}, path: ${ + quoteGeneratedJavaScriptValue(r.path) + }, methods: ['GET', 'POST'], load: () => import(${ quoteGeneratedJavaScriptValue(importPath) - })`; + }) }`; }); if (entries.length === 0) { @@ -84,7 +86,7 @@ async function generateRouteManifest( count: 0, content: `// Auto-generated by @openelement/adapter-vite — do not edit. // No page routes found in "${routesDir}". -export const routeManifest = {} as const; +export const routeRecords = [] as const; `, }; } @@ -92,9 +94,9 @@ export const routeManifest = {} as const; return { count: entries.length, content: `// Auto-generated by @openelement/adapter-vite — do not edit. -export const routeManifest = { +export const routeRecords = [ ${entries.join(',\n')} -} as const; +] as const; `, }; } diff --git a/packages/app/__tests__/authoring.test.ts b/packages/app/__tests__/authoring.test.ts index ca354a2ba..2c8757516 100644 --- a/packages/app/__tests__/authoring.test.ts +++ b/packages/app/__tests__/authoring.test.ts @@ -104,7 +104,7 @@ Deno.test('definePage() attaches the descriptor to the compiled class and return const props = () => ({}); const error = () => ({}); const result = definePage(Page, { - route: { path: '/', id: 'home' }, + route: { id: 'home' }, head: { title: 'Home', description: 'Application API', @@ -120,7 +120,7 @@ Deno.test('definePage() attaches the descriptor to the compiled class and return const descriptor = (Page as unknown as { openElementPage: Record }) .openElementPage; assertEquals(descriptor.kind, 'page'); - assertEquals(descriptor.route, { path: '/', id: 'home' }); + assertEquals(descriptor.route, { id: 'home' }); assertEquals(descriptor.head, { title: 'Home', description: 'Application API', diff --git a/packages/app/__tests__/client-router.test.ts b/packages/app/__tests__/client-router.test.ts index c0910a916..2313a3d3f 100644 --- a/packages/app/__tests__/client-router.test.ts +++ b/packages/app/__tests__/client-router.test.ts @@ -13,10 +13,11 @@ import { const routes: RouteConfig[] = [{ path: '/items/:id', tagName: 'item-page' }]; -Deno.test('client router decodes path parameters and gives path values precedence', () => { +Deno.test('client router keeps decoded path parameters separate from query', () => { const match = matchRoute('/items/hello%20world', '?id=query&view=full', routes); assertEquals(match?.params.id, 'hello world'); - assertEquals(match?.params.view, 'full'); + assertEquals(match?.params.view, undefined); + assertEquals(match?.searchParams.get('view'), 'full'); }); Deno.test('client router decodes query components exactly once', () => { @@ -27,15 +28,15 @@ Deno.test('client router decodes query components exactly once', () => { ['?value=%2B', '+'], ] as const; for (const [search, expected] of cases) { - assertEquals(matchRoute('/items/id', search, routes)?.params.value, expected); + assertEquals(matchRoute('/items/id', search, routes)?.searchParams.get('value'), expected); } }); Deno.test('client router preserves malformed query escapes without aborting matching', () => { const match = matchRoute('/items/id', '?bad=%&also=%2&key%=value%', routes); - assertEquals(match?.params.bad, '%'); - assertEquals(match?.params.also, '%2'); - assertEquals(match?.params['key%'], 'value%'); + assertEquals(match?.searchParams.get('bad'), '%'); + assertEquals(match?.searchParams.get('also'), '%2'); + assertEquals(match?.searchParams.get('key%'), 'value%'); }); interface ExpectedRouteMatch { @@ -178,7 +179,7 @@ const semanticCases: Array<{ search: '?id=query&view=first&view=last&encoded=a%2Bb+two', expected: { route: 'item-page', - params: { id: 'hello world', view: 'last', encoded: 'a+b two' }, + params: { id: 'hello world' }, }, }, { @@ -187,7 +188,7 @@ const semanticCases: Array<{ search: '?bad=%&also=%2&key%=value%', expected: { route: 'item-page', - params: { bad: '%', also: '%2', 'key%': 'value%', id: 'id' }, + params: { id: 'id' }, }, }, { @@ -298,8 +299,7 @@ Deno.test('RouteTable rejects malformed URLPattern patterns consistently', () => Deno.test('RouteTable classifies methods, HEAD, base paths, and trailing-slash policy', () => { type MethodRoute = RouteConfig & { methods: readonly string[] }; const methodRoutes: MethodRoute[] = [ - { path: '/items/:id', tagName: 'item-get', methods: ['GET'] }, - { path: '/items/:id', tagName: 'item-post', methods: ['POST'] }, + { path: '/items/:id', tagName: 'item', methods: ['GET', 'POST'] }, ]; const tables = [ new RouteTable(methodRoutes, URLPatternPolyfillConstructor, { @@ -318,9 +318,9 @@ Deno.test('RouteTable classifies methods, HEAD, base paths, and trailing-slash p ]; const expected = [ - { kind: 'match', route: 'item-get', params: { id: 'a b' } }, - { kind: 'match', route: 'item-get', params: { id: 'a b' } }, - { kind: 'match', route: 'item-post', params: { id: 'a b' } }, + { kind: 'match', route: 'item', params: { id: 'a b' } }, + { kind: 'match', route: 'item', params: { id: 'a b' } }, + { kind: 'match', route: 'item', params: { id: 'a b' } }, { kind: 'method-not-allowed', allow: ['GET', 'HEAD', 'POST'] }, { kind: 'not-found' }, ]; @@ -363,7 +363,13 @@ Deno.test('client router dispose removes event listeners and double dispose is s const removed: EventListener[] = []; Object.defineProperty(globalThis, 'location', { configurable: true, - value: { protocol: 'https:', pathname: '/', search: '', hash: '' }, + value: { + protocol: 'https:', + href: 'https://router.test/', + pathname: '/', + search: '', + hash: '', + }, }); Object.defineProperty(globalThis, 'history', { configurable: true, @@ -402,7 +408,13 @@ Deno.test('client router guard redirect limit rejects redirect loops', async () const originalHistory = Object.getOwnPropertyDescriptor(globalThis, 'history'); Object.defineProperty(globalThis, 'location', { configurable: true, - value: { protocol: 'https:', pathname: '/', search: '', hash: '' }, + value: { + protocol: 'https:', + href: 'https://router.test/', + pathname: '/', + search: '', + hash: '', + }, }); Object.defineProperty(globalThis, 'history', { configurable: true, @@ -961,6 +973,9 @@ function installFakeHistoryStack(initialEntries: string[]) { configurable: true, value: { protocol: 'https:', + get href() { + return currentUrl().href; + }, get pathname() { return currentUrl().pathname; }, diff --git a/packages/app/__tests__/route-pattern.test.ts b/packages/app/__tests__/route-pattern.test.ts new file mode 100644 index 000000000..af3121f70 --- /dev/null +++ b/packages/app/__tests__/route-pattern.test.ts @@ -0,0 +1,14 @@ +import { assertEquals } from '@std/assert'; +import { normalizeRoutePatternForURLPattern } from '../src/internal/router/route-pattern.ts'; + +Deno.test('shared route normalizer preserves params and converts Hono catch-alls (#1103)', () => { + assertEquals(normalizeRoutePatternForURLPattern('/item/:id'), '/item/:id'); + assertEquals( + normalizeRoutePatternForURLPattern('/docs/:path{.+}'), + '/docs/:path(.+)', + ); + assertEquals( + normalizeRoutePatternForURLPattern('/org/:org/repo/:path{.*}'), + '/org/:org/repo/:path(.*)', + ); +}); diff --git a/packages/app/__tests__/route-resolution.test.ts b/packages/app/__tests__/route-resolution.test.ts new file mode 100644 index 000000000..c411a3641 --- /dev/null +++ b/packages/app/__tests__/route-resolution.test.ts @@ -0,0 +1,49 @@ +import { assertEquals } from '@std/assert'; +import { RouteTable } from '../src/internal/router/route-table.ts'; + +Deno.test('URL winner precedes method dispatch, including overlapping explicit records', () => { + const routes = [ + { path: '/products/new', methods: ['GET'] }, + { path: '/products/:id', methods: ['POST'] }, + ]; + const resolution = new RouteTable(routes).resolve('/products/new', '', 'POST'); + assertEquals(resolution.kind, 'method-not-allowed'); + if (resolution.kind === 'method-not-allowed') assertEquals(resolution.allow, ['GET', 'HEAD']); + assertEquals( + new RouteTable([...routes].reverse()).resolve('/products/new', '', 'POST').kind, + 'match', + ); +}); + +Deno.test('query never becomes a path capture', () => { + const match = new RouteTable([{ path: '/items/:id' }]).match( + '/items/a%252Fb', + '?id=query&view=&view=full', + ); + assertEquals(Object.entries(match?.params ?? {}), [['id', 'a%2Fb']]); +}); + +Deno.test('resolution snapshots and full URL component patterns preserve URLPattern semantics', () => { + const routes = [{ + id: 'secure', + path: '/items/:id', + pattern: { hostname: 'shop.example', protocol: 'https' }, + methods: ['get'], + }]; + const table = new RouteTable(routes); + routes[0].path = '/changed'; + routes[0].methods.push('POST'); + const result = table.resolve(new URL('https://shop.example/items/a%2Fb?q=&q=2')); + assertEquals(result.kind, 'match'); + if (result.kind === 'match') { + assertEquals(result.id, 'secure'); + assertEquals(result.params.id, 'a/b'); + assertEquals(result.searchParams.getAll('q'), ['', '2']); + } + assertEquals(table.resolve(new URL('https://other.example/items/a')).kind, 'not-found'); + assertEquals(table.resolve('/items/a', '', 'POST').kind, 'not-found'); + assertEquals( + new RouteTable([{ path: '//a' }]).match(new URL('https://shop.example//a'))?.route.path, + '//a', + ); +}); diff --git a/packages/app/__tests__/router-browser.test.ts b/packages/app/__tests__/router-browser.test.ts new file mode 100644 index 000000000..b3f77edbb --- /dev/null +++ b/packages/app/__tests__/router-browser.test.ts @@ -0,0 +1,137 @@ +import { assertEquals } from '@std/assert'; +import { chromium, firefox, webkit } from '@playwright/test'; +import { createServer } from 'vite'; +import { generateWorkspaceAliases } from '../../adapter-vite/src/workspace-alias.ts'; + +Deno.test({ + name: 'Navigation API: three-browser push/replace/traversal and stale guard regression', + sanitizeOps: false, + sanitizeResources: false, + async fn() { + const root = new URL('../../../', import.meta.url).pathname.replace(/\/$/, ''); + const source = + `import {createRouter} from '/@fs/${root}/packages/app/src/internal/router/client-router.ts'; + window.changes=[]; + window.router=createRouter({mode:'history', routes:[ + {path:'/',tagName:'home-page'}, {path:'/a',tagName:'a-page'}, {path:'/b',tagName:'b-page'}, + {path:'/slow',tagName:'slow-page',guard:()=>new Promise(r=>window.releaseGuard=r)}, + {path:'/download',tagName:'download-page'}, {path:'/redirect',tagName:'redirect-page',guard:async()=>'/b'}, {path:'/blocked',tagName:'blocked-page',guard:async()=>false} + ],onChange:()=>window.changes.push(window.router.currentPath)});`; + const server = await createServer({ + root, + configFile: false, + optimizeDeps: { noDiscovery: true, include: [] }, + logLevel: 'error', + resolve: { alias: generateWorkspaceAliases(root) }, + server: { host: '127.0.0.1', port: 0 }, + plugins: [{ + name: 'nav-proof', + configureServer(server) { + server.middlewares.use((req, res, next) => { + if (req.url === '/download') { + res.setHeader('content-type', 'text/plain'); + res.setHeader('content-disposition', 'attachment; filename="proof.txt"'); + res.end('download proof'); + return; + } + if (req.url === '/proof.js') { + res.setHeader('content-type', 'text/javascript'); + res.end(source); + return; + } + if (req.headers.accept?.includes('text/html')) { + res.setHeader('content-type', 'text/html'); + res.end( + 'a', + ); + return; + } + next(); + }); + }, + }], + }); + await server.listen(); + try { + const address = server.httpServer!.address() as { port: number }; + for (const type of [chromium, firefox, webkit]) { + const browser = await type.launch({ headless: true }); + try { + const page = await browser.newPage(); + page.setDefaultTimeout(10_000); + await page.goto(`http://127.0.0.1:${address.port}`); + await page.waitForFunction('window.router'); + await page.evaluate('window.router.navigate("/a")'); + assertEquals(await page.evaluate('window.router.currentPath'), '/a', type.name()); + await page.evaluate('window.router.replace("/b")'); + await page.goBack(); + await page.waitForFunction('window.router.currentPath === "/"'); + await page.goForward(); + await page.waitForFunction('window.router.currentPath === "/b"'); + await page.evaluate('window.pending = window.router.navigate("/slow"); void 0'); + await page.evaluate('window.router.navigate("/a")'); + await page.evaluate('window.releaseGuard("/b"); window.pending'); + assertEquals(await page.evaluate('window.router.currentPath'), '/a'); + await page.evaluate('window.router.navigate("/redirect")'); + assertEquals(await page.evaluate('window.router.currentPath'), '/b'); + // Read layout directly: WebKit's Playwright locator auto-wait treats + // Navigation API same-document traversals as pending document loads. + const link = await page.evaluate(() => + document.querySelector('#link')!.getBoundingClientRect().toJSON() + ); + await page.mouse.click(link.x + link.width / 2, link.y + link.height / 2); + await page.waitForFunction('window.router.currentPath === "/a"'); + await page.evaluate( + 'document.querySelector("#link").href="/blocked"; document.querySelector("#link").click()', + ); + await page.waitForFunction( + 'location.pathname === "/a" && window.router.currentPath === "/a"', + ); + await page.evaluate( + 'document.querySelector("#link").href="/slow"; document.querySelector("#link").click()', + ); + await page.waitForFunction('location.pathname === "/slow"'); + await page.evaluate('window.router.navigate("/b")'); + await page.evaluate('window.releaseGuard(false)'); + assertEquals(await page.evaluate('location.pathname'), '/b'); + assertEquals(await page.evaluate('window.router.currentPath'), '/b'); + await page.evaluate( + 'window.navEvents=[]; navigation.addEventListener("navigate",e=>window.navEvents.push({url:e.destination.url,can:e.canIntercept,download:e.downloadRequest,source:e.sourceElement?.outerHTML}))', + ); + const downloaded = page.waitForEvent('download').catch(async (error) => { + console.log( + type.name(), + await page.evaluate( + '({path:location.href,events:window.navEvents,current:window.router?.currentPath})', + ), + ); + throw error; + }); + await page.evaluate( + 'const a=document.querySelector("#link"); a.href="/download"; a.download="proof.txt"', + ); + await page.mouse.click(link.x + link.width / 2, link.y + link.height / 2); + assertEquals((await downloaded).suggestedFilename(), 'proof.txt'); + assertEquals(await page.evaluate('window.router.currentPath'), '/b'); + await page.route( + 'https://external.invalid/**', + (route) => + route.fulfill({ contentType: 'text/html', body: '

external document

' }), + ); + await page.evaluate( + 'const a=document.querySelector("#link"); a.removeAttribute("download"); a.href="https://external.invalid/a"; a.click()', + ); + await page.waitForURL('https://external.invalid/a'); + assertEquals(await page.evaluate('typeof window.router'), 'undefined'); + console.log( + `${type.name()} ${browser.version()}: navigation, abort, download, cross-origin PASS`, + ); + } finally { + await browser.close(); + } + } + } finally { + await server.close(); + } + }, +}); diff --git a/packages/app/__tests__/router-http.test.ts b/packages/app/__tests__/router-http.test.ts new file mode 100644 index 000000000..ccfd30292 --- /dev/null +++ b/packages/app/__tests__/router-http.test.ts @@ -0,0 +1,78 @@ +import { assertEquals, assertRejects, assertThrows } from '@std/assert'; +import { Hono } from 'hono'; +import { createRouteMiddleware } from '../src/router-http.ts'; + +Deno.test('Request chooses one URL before methods; Hono retains context, middleware and response', async () => { + const events: string[] = []; + const app = new Hono<{ Variables: { host: string } }>(); + app.use('*', async (c, next) => { + events.push('before'); + c.set('host', 'ok'); + await next(); + events.push('after'); + c.header('x-host', 'yes'); + }); + app.get('/host', (c) => c.text('host')); + app.all( + '*', + createRouteMiddleware([ + { id: 'new', path: '/products/new', handlers: { GET: (c) => c.text(c.get('host')) } }, + { + id: 'item', + path: '/products/:id', + handlers: { POST: (c) => c.json({ params: c.get('routeResolution').params }) }, + }, + { + path: '/explicit-head', + handlers: { + GET: (c) => c.text('get'), + HEAD: (c) => c.text('body', 202, { 'x-explicit': 'yes' }), + }, + }, + ]), + ); + let response = await app.request('/products/new', { method: 'POST' }); + assertEquals(response.status, 405); + assertEquals(response.headers.get('Allow'), 'GET, HEAD'); + assertEquals(response.headers.get('x-host'), 'yes'); + response = await app.request('/products/new', { method: 'HEAD' }); + assertEquals(response.status, 200); + assertEquals(await response.text(), ''); + response = await app.request('/explicit-head', { method: 'HEAD' }); + assertEquals(response.status, 202); + assertEquals(response.headers.get('x-explicit'), 'yes'); + assertEquals(await response.text(), ''); + response = await app.request('/products/a%252Fb?q=x', { method: 'POST' }); + assertEquals(await response.json(), { params: { id: 'a%2Fb' } }); + assertEquals(await (await app.request('/host')).text(), 'host'); + assertEquals((await app.request('/missing')).status, 404); + assertEquals((await app.request('/products/new', { method: 'OPTIONS' })).status, 405); + assertEquals(events, Array.from({ length: 7 }, () => ['before', 'after']).flat()); +}); + +Deno.test('HTTP records reject ambiguous duplicate methods and preserve thrown handler errors', async () => { + assertThrows( + () => + createRouteMiddleware([{ + path: '/', + handlers: { get: (c) => c.text('a'), GET: (c) => c.text('b') }, + }]), + TypeError, + ); + const app = new Hono<{ Variables: { host: string } }>(); + app.onError(() => { + throw new Error('host error boundary'); + }); + app.all( + '*', + createRouteMiddleware([{ + path: '/', + handlers: { + GET: () => { + throw new Error('handler'); + }, + }, + }]), + ); + await assertRejects(() => Promise.resolve(app.request('/')), Error, 'host error boundary'); +}); diff --git a/packages/app/__tests__/spa.test.ts b/packages/app/__tests__/spa.test.ts index aae8a145c..92dabfdef 100644 --- a/packages/app/__tests__/spa.test.ts +++ b/packages/app/__tests__/spa.test.ts @@ -111,7 +111,8 @@ Deno.test('defineApp mounts loader data and route context through the page props routes: [{ path: '/articles/:slug', tagName: 'article-page', - loader: ({ params }) => Promise.resolve({ title: `${params.slug}:${params.preview}` }), + loader: ({ params, searchParams }) => + Promise.resolve({ title: `${params.slug}:${searchParams.get('preview')}` }), }], }); try { @@ -121,13 +122,13 @@ Deno.test('defineApp mounts loader data and route context through the page props assertEquals(calls.normal.length, 1); const context = calls.normal[0]; assertEquals(context.data, { title: 'hello:yes' }); - assertEquals(context.params, { slug: 'hello', preview: 'yes' }); + assertEquals(context.params, { slug: 'hello' }); assertEquals(context.request?.url, 'https://example.test/articles/hello?preview=yes'); assertEquals(context.route, { path: '/articles/:slug' }); // The projected values landed on the host as pre-connect own properties. assertEquals(hosts.length, 1); assertEquals(hosts[0].slug, 'hello'); - assertEquals(hosts[0].preview, 'yes'); + assertEquals(hosts[0].preview, undefined); assertEquals(hosts[0].title, 'hello:yes'); } finally { app.dispose(); @@ -761,3 +762,59 @@ Deno.test('defineApp routes action notFound() to the page error projector (#731) restoreRegistry(); } }); + +for (const operation of ['loader', 'action'] as const) { + Deno.test(`SPA aborts superseded ${operation} and ignores its late redirect`, async () => { + const hosts: Record[] = []; + let submit: ((event: Event) => void) | undefined; + let release!: () => void; + let signal: AbortSignal | undefined; + const pending = new Promise((resolve) => release = resolve); + const root = { + innerHTML: '', + addEventListener(type: string, handler: (event: Event) => void) { + if (type === 'submit') submit = handler; + }, + removeEventListener() {}, + appendChild(host: Record) { + hosts.push(host); + return host; + }, + }; + const env = stubNavigableEnvironment(root, '/'); + const delayed = async (context: { signal: AbortSignal }) => { + signal = context.signal; + await pending; + redirect('/stale'); + }; + const app = defineApp({ + mode: 'spa', + routerMode: 'history', + routes: [ + { path: '/', tagName: 'home-page', [operation]: delayed }, + { path: '/new', tagName: 'new-page', loader: () => Promise.resolve({ page: 'new' }) }, + { path: '/stale', tagName: 'stale-page', loader: () => Promise.resolve({ page: 'stale' }) }, + ], + }); + try { + app.mount('#app'); + await new Promise((resolve) => setTimeout(resolve, 0)); + if (operation === 'action') { + const event = new Event('submit', { cancelable: true }); + Object.defineProperty(event, 'target', { value: { tagName: 'FORM' } }); + submit!(event); + } + assertEquals(signal?.aborted, false); + await app.router!.navigate('/new'); + assertEquals(signal?.aborted, true); + release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(env.pushed, ['/new']); + assertEquals(app.router!.currentPath, '/new'); + assertEquals(hosts.at(-1)?.page, 'new'); + } finally { + app.dispose(); + env.restore(); + } + }); +} diff --git a/packages/app/__tests__/url-pattern-list.bench.ts b/packages/app/__tests__/url-pattern-list.bench.ts new file mode 100644 index 000000000..2db298493 --- /dev/null +++ b/packages/app/__tests__/url-pattern-list.bench.ts @@ -0,0 +1,94 @@ +/** Bounded diagnostic, not a ranking: deno run -A this-file. */ +import { RouteTable, URLPatternPolyfillConstructor } from '../src/internal/router/route-table.ts'; +import { URLPatternList } from '../src/internal/router/url-pattern-list/index.ts'; + +async function main(): Promise { + const base = '0d826954cb96b3a9306119830defd6000a798c95'; + const source = await new Deno.Command('git', { + args: ['show', `${base}:packages/app/src/internal/router/route-table.ts`], + stdout: 'piped', + }).output(); + if (!source.success) throw new Error('Baseline source unavailable'); + const baselineCode = new TextDecoder().decode(source.stdout) + .replace( + '@openelement/element/build-utils', + new URL('../src/internal/router/route-pattern.ts', import.meta.url).href, + ) + .replace("'urlpattern-polyfill'", "'npm:urlpattern-polyfill@10.1.0'"); + const file = await Deno.makeTempFile({ suffix: '.ts' }); + await Deno.writeTextFile(file, baselineCode); + try { + const { RouteTable: Baseline } = await import(new URL(`file://${file}`).href); + console.log( + JSON.stringify({ + deno: Deno.version, + baseline: base, + memory: 'NOT MEASURED: no reliable per-index isolation', + samples: 5, + lookupsPerSample: 100, + }), + ); + const median = (values: number[]) => + values.sort((a, b) => a - b)[Math.floor(values.length / 2)]; + for (const count of [100, 1000, 5000]) { + for (const conservative of [false, true]) { + const paths = Array.from({ length: count }, (_, i) => `/shared/catalog/${i}/details`); + if (conservative) paths.unshift('/shared/:path*'); + const records = paths.map((path) => ({ path })); + const build: number[] = []; + let entries: Array = []; + let list!: URLPatternList; + for (let sample = 0; sample < 5; sample++) { + const start = performance.now(); + entries = paths.map((pathname, i) => + [new URLPatternPolyfillConstructor({ pathname }), i] as const + ); + list = new URLPatternList(entries); + build.push(performance.now() - start); + } + const oldBuild = performance.now(); + const old = new Baseline(records, URLPatternPolyfillConstructor); + const oldBuildMs = performance.now() - oldBuild; + const current = new RouteTable(records, URLPatternPolyfillConstructor); + const linear = (url: URL) => entries.find(([pattern]) => pattern.exec(url.href)); + for ( + const path of [`/shared/catalog/${count - 1}/details`, '/shared/catalog/missing/details'] + ) { + const url = new URL(path, 'https://localhost'); + const times: Record = {}; + for ( + const [name, match] of Object.entries({ + original: () => old.match(path), + linear: () => linear(url), + ownedList: () => list.match(url), + table: () => current.match(url), + }) + ) { + match(); + const samples = []; + for (let sample = 0; sample < 5; sample++) { + const start = performance.now(); + for (let i = 0; i < 100; i++) match(); + samples.push((performance.now() - start) / 100); + } + times[name] = median(samples); + } + console.log( + JSON.stringify({ + count, + conservative, + path, + buildListMedianMs: median(build), + originalBuildSingleMs: oldBuildMs, + matchMedianMs: times, + }), + ); + } + } + } + } finally { + await Deno.remove(file); + } +} + +if (import.meta.main) await main(); diff --git a/packages/app/__tests__/url-pattern-list.test.ts b/packages/app/__tests__/url-pattern-list.test.ts new file mode 100644 index 000000000..83139ff08 --- /dev/null +++ b/packages/app/__tests__/url-pattern-list.test.ts @@ -0,0 +1,190 @@ +import { assertEquals, assertStrictEquals, assertThrows } from '@std/assert'; +import { URLPatternList } from '../src/internal/router/url-pattern-list/index.ts'; +import { URLPatternPolyfillConstructor } from '../src/internal/router/route-table.ts'; + +const constructors = [ + ['polyfill', URLPatternPolyfillConstructor], + ...('URLPattern' in globalThis ? [['native', globalThis.URLPattern] as const] : []), +] as const; + +for (const [name, Pattern] of constructors) { + Deno.test(`URLPatternList ${name}: complete results and identity against ordered oracle`, () => { + const patterns = [ + '/', + '/static', + '/:id', + '/a/:first/:second', + '/assets/:path*', + '/x/:id(\\d+)', + '/a{/:id}?', + '/a/:id+', + '/a/:id*', + '/a/\\:literal', + '/東京', + '/x/%2F', + '/shared/prefix/miss', + '/shared/prefix/:id', + '/shared/prefix/hit', + '*', + '/:__proto__/:constructor', + '/a//b', + '/a/', + '/a/:x([a-z]+)', + ].map((pathname) => new Pattern({ pathname })); + patterns.push(new Pattern({ pathname: '/static', search: '', hash: '', port: '' })); + patterns.push( + new Pattern({ + protocol: 'https', + hostname: ':sub.example.com', + pathname: '/:id', + search: 'q=:q', + hash: ':hash', + }), + ); + const inputs = [ + '/', + '/static', + '/a', + '/a/b', + '/a/b/c', + '/a//b', + '/a/', + '/a/:literal', + '/x/123', + '/x/abc', + '/assets', + '/assets/a/b', + '/東京', + '/x/%2F', + '/x/%252F', + '/x/%', + '/shared/prefix/hit', + '/shared/prefix/no', + '/missing?q=&q=2#h', + 'https://sub.example.com/a?q=1#h', + ]; + // Every pair, both orders, including duplicate patterns with distinct values. + for (const first of patterns) { + for (const second of patterns) { + const entries = [[first, {}], [second, {}]] as const; + const list = new URLPatternList(entries); + for (const input of inputs) { + const url = new URL(input, 'https://example.com'); + const expected = entries.map(([pattern, value]) => ({ + result: pattern.exec(url.href), + value, + })).find((r) => r.result); + const actual = list.match(input, 'https://example.com'); + assertEquals( + actual?.result ?? null, + expected?.result ?? null, + `${name}: ${first.pathname}, ${second.pathname}, ${input}`, + ); + assertStrictEquals(actual?.value, expected?.value); + assertEquals(list.match(url)?.result ?? null, expected?.result ?? null); + } + } + } + }); + + Deno.test(`URLPatternList ${name}: ignoreCase and empty URL components`, () => { + const CasePattern = Pattern as unknown as new ( + init: URLPatternInit, + options: { ignoreCase: boolean }, + ) => URLPattern; + const entries = [ + [new CasePattern({ pathname: '/Case' }, { ignoreCase: true }), 1], + [new Pattern({ pathname: '/case', search: '', hash: '', port: '' }), 2], + ] as const; + const list = new URLPatternList(entries); + for ( + const input of [ + 'https://example.com/case', + 'https://example.com/CASE?q=1', + 'http://example.com:80/case#', + 'http://example.com:81/case', + ] + ) { + const expected = entries.find(([p]) => p.exec(new URL(input).href)); + assertEquals(list.match(input)?.value, expected?.[1]); + assertEquals(list.match(input)?.result, expected?.[0].exec(new URL(input).href)); + } + }); + + Deno.test(`URLPatternList ${name}: seeded literal/conservative permutations`, () => { + const seed = 1324; + let state = seed; + const next = () => (state = (Math.imul(state, 1664525) + 1013904223) >>> 0); + for (let iteration = 0; iteration < 150; iteration++) { + const entries = Array.from({ length: 20 }, (_, id) => { + const pathname = next() % 3 === 0 ? '/shared/:id' : `/shared/${next() % 30}`; + return [new Pattern({ pathname }), id] as const; + }); + const input = `https://example.com/shared/${next() % 35}`; + const mismatch = (candidate: typeof entries) => { + const actual = new URLPatternList(candidate).match(input); + const expected = candidate.map(([pattern, value]) => ({ + result: pattern.exec(input), + value, + })) + .find((entry) => entry.result); + return actual?.value !== expected?.value || + JSON.stringify(actual?.result) !== JSON.stringify(expected?.result); + }; + // Deletion shrinking keeps the original input/seed and reduces the route + // sequence to a 1-minimal reproducer without changing record identities. + let minimal = entries; + if (mismatch(entries)) { + for (let index = 0; index < minimal.length;) { + const candidate = minimal.filter((_, i) => i !== index); + if (mismatch(candidate)) { + minimal = candidate; + index = 0; + } else index++; + } + } + assertEquals( + mismatch(minimal), + false, + `seed=${seed} iteration=${iteration} input=${input} patterns=${ + JSON.stringify(minimal.map(([p, value]) => ({ pathname: p.pathname, value }))) + }`, + ); + } + }); +} + +Deno.test('URLPatternList invalid URL boundary is consistent for empty and populated lists', () => { + for ( + const entries of [[], [[new URLPatternPolyfillConstructor({ pathname: '*' }), 1] as const]] + ) { + const list = new URLPatternList(entries); + assertThrows(() => list.match('/relative'), TypeError); + assertThrows(() => list.match('http://['), TypeError); + assertEquals(list.match('https://example.com')?.value ?? null, entries.length ? 1 : null); + } +}); + +Deno.test('admitted route patterns have consistent native/polyfill observable results', () => { + for ( + const pathname of [ + '/', + '/:id', + '/a/:x*', + '/a/:x(\\d+)', + '/a{/:x}?', + '/東京', + '/:__proto__', + '/a//b', + ] + ) { + for (const path of ['/', '/a', '/a/123', '/a/b/c', '/a//b', '/東京', '/%2F', '/%E0%A4%A']) { + const input = new URL(path, 'https://example.com').href; + assertEquals( + new URLPatternPolyfillConstructor({ pathname }).exec(input), + new URLPattern({ pathname }).exec(input), + `${pathname} ${input}`, + ); + } + } +}); diff --git a/packages/app/deno.json b/packages/app/deno.json index 78dc98007..a786961f8 100644 --- a/packages/app/deno.json +++ b/packages/app/deno.json @@ -3,6 +3,8 @@ "version": "0.44.0-beta.2", "exports": { ".": "./src/index.ts", + "./router/http": "./src/router-http.ts", + "./router": "./src/router.ts", "./model": "./src/model.ts", "./spa": "./src/spa.ts", "./i18n": "./src/i18n.ts", diff --git a/packages/app/src/authoring.ts b/packages/app/src/authoring.ts index 39587a7dd..854f713e8 100644 --- a/packages/app/src/authoring.ts +++ b/packages/app/src/authoring.ts @@ -36,7 +36,6 @@ type PageRenderingMode = 'static' | 'dynamic'; export type PageMeta = Record; interface PageRouteIntent { - path?: string; id?: string; params?: readonly string[]; } @@ -348,6 +347,11 @@ export function definePage< 'their Part Program — there is no render() function field (v0.44).', ); } + if (descriptor.route && Object.hasOwn(descriptor.route, 'path')) { + throw new Error( + `${ERROR_PREFIX} definePage route.path is not supported; the route file owns its URL path.`, + ); + } if (descriptor.props !== undefined && typeof descriptor.props !== 'function') { throw new Error(`${ERROR_PREFIX} definePage() props must be a projector function.`); } diff --git a/packages/app/src/internal/router/client-router.ts b/packages/app/src/internal/router/client-router.ts index baedd0d8e..38c40a438 100644 --- a/packages/app/src/internal/router/client-router.ts +++ b/packages/app/src/internal/router/client-router.ts @@ -3,7 +3,7 @@ * * Supports history (pushState), hash, and auto-detection modes. * Alpha.9 authority: URLPattern owns pathname grammar and RouteTable owns - * declaration order, query merging, safe params, and static lookup. + * declaration order, separate query/captures, and HTTP policy. * * Alpha.9 removes client-local route grammars and compatibility matchers so * browser navigation and the other route consumers share one semantic owner. @@ -14,21 +14,22 @@ // frozen semantics — types clarified, runtime unchanged). import type { SpaActionContext, SpaLoaderContext } from '@openelement/element'; import { createLogger, ERROR_PREFIX } from '@openelement/element'; -import { RouteTable } from './route-table.ts'; +import { type RouteMatch, type RouteRecord, RouteTable } from './route-table.ts'; const log = createLogger('router'); export type RouterMode = 'history' | 'hash' | 'auto'; -export interface RouteConfig { - path: string; // e.g. '/products/:id' +export interface RouteConfig extends RouteRecord { /** Custom element tag to instantiate directly in SPA mode. */ tagName: string; /** Client-side loader — runs before component render. Receives matched route params. */ - loader?: (ctx: SpaLoaderContext) => Promise; + loader?: ( + ctx: SpaLoaderContext & { searchParams: URLSearchParams; signal: AbortSignal }, + ) => Promise; /** Client-side action — runs on form submit. Receives matched route params and form data. */ action?: ( - ctx: SpaActionContext, + ctx: SpaActionContext & { searchParams: URLSearchParams; signal: AbortSignal }, ) => Promise; guard?: () => Promise; } @@ -38,6 +39,8 @@ interface RouterOptions { routes: RouteConfig[]; /** Called after navigation or browser history/hash changes update the current match. */ onChange?: () => void | Promise; + /** Invalidate pending execution as soon as a newer navigation owns intent. */ + onPending?: () => void; } export interface RouterInstance { @@ -47,13 +50,14 @@ export interface RouterInstance { currentPath: string; currentRoute: RouteConfig | null; params: Record; + readonly searchParams: URLSearchParams; } const MAX_GUARD_REDIRECTS = 10; export type CompiledRouteMatcher = Pick< RouteTable, - 'match' | 'candidateCount' + 'match' | 'resolve' | 'candidateCount' >; // ─── Internal helpers ───────────────────────────────────────────── @@ -71,7 +75,7 @@ export function matchRoute( pathname: string, search: string, routes: RouteConfig[], -): { route: RouteConfig; params: Record } | null { +): RouteMatch | null { return matcherFor(routes).match(pathname, search); } @@ -100,6 +104,11 @@ export function createRouter(options: RouterOptions): RouterInstance { let currentPath = ''; let currentRoute: RouteConfig | null = null; let currentParams: Record = Object.create(null); + let currentSearchParams = new URLSearchParams(); + const checkedNavigation = Object.freeze({}); + const nativeNavigation = mode === 'history' && typeof navigation !== 'undefined' + ? navigation + : undefined; let disposed = false; /** Registered listeners keyed by event type, to support dispose. */ @@ -125,16 +134,20 @@ export function createRouter(options: RouterOptions): RouterInstance { return '#' + (path.startsWith('#') ? path.slice(1) : path); } - function rematch(): void { - const raw = readPath(); - const u = new URL(raw, 'http://x'); - const pathname = u.pathname; + function resolveTarget(url: URL): RouteMatch | null { + const resolution = routeMatcher.resolve(url); + return resolution.kind === 'match' ? resolution : null; + } + + function rematch(raw = readPath()): void { + const u = new URL(raw, location.href); const search = u.search; - const matched = routeMatcher.match(pathname, search); + const matched = resolveTarget(u); currentPath = raw; currentRoute = matched?.route ?? null; currentParams = matched?.params ?? Object.create(null); + currentSearchParams = matched?.searchParams ?? new URLSearchParams(search); } function notifyChange(): void { @@ -179,8 +192,13 @@ export function createRouter(options: RouterOptions): RouterInstance { } // Run guard if we have a matching target route - const u = new URL(path, 'http://x'); - const matched = routeMatcher.match(u.pathname, u.search); + const u = new URL(path, location.href); + if (mode === 'history' && u.origin !== new URL(location.href).origin) { + if (navOptions.replace) location.replace(u.href); + else location.assign(u.href); + return; + } + const matched = resolveTarget(u); if (matched?.route.guard) { const result = await matched.route.guard(); if (disposed) return; @@ -207,6 +225,13 @@ export function createRouter(options: RouterOptions): RouterInstance { if (disposed || (ticket !== undefined && ticket !== programmaticNavigationSeq)) return; const url = mode === 'hash' ? toHashUrl(path) : path; + if (nativeNavigation) { + await nativeNavigation.navigate(url, { + history: navOptions.replace ? 'replace' : 'push', + info: checkedNavigation, + }).finished; + return; + } if (navOptions.replace) { history.replaceState(null, '', url); } else { @@ -228,10 +253,12 @@ export function createRouter(options: RouterOptions): RouterInstance { let programmaticNavigationSeq = 0; function navigate(path: string): Promise { + options.onPending?.(); return commitNavigation(path, { replace: false }, ++programmaticNavigationSeq); } function replace(path: string): Promise { + options.onPending?.(); return commitNavigation(path, { replace: true }, ++programmaticNavigationSeq); } @@ -247,17 +274,19 @@ export function createRouter(options: RouterOptions): RouterInstance { // for what is effectively a single navigation. let lastLandedUrl: string | null = null; - async function commitBrowserNavigation(): Promise { + async function commitBrowserNavigation( + ticket = programmaticNavigationSeq, + landed = readPath(), + ): Promise { if (disposed) return; - const landed = readPath(); if (landed === lastLandedUrl) return; try { - const u = new URL(landed, 'http://x'); - const matched = routeMatcher.match(u.pathname, u.search); + const u = new URL(landed, location.href); + const matched = resolveTarget(u); if (matched?.route.guard) { const seqAtGuardStart = programmaticNavigationSeq; const result = await matched.route.guard(); - if (disposed) return; + if (disposed || ticket !== programmaticNavigationSeq) return; if (result === false) { // Blocked: restore the entry the user came from (see // restoreBlockedEntry for why this rewrites rather than pushes). @@ -279,13 +308,13 @@ export function createRouter(options: RouterOptions): RouterInstance { return; } } - if (disposed) return; - rematch(); + if (disposed || ticket !== programmaticNavigationSeq) return; + rematch(landed); notifyChange(); } finally { // Track the committed URL (restored on block, replaced on redirect) so // only bursts landing on the same URL are deduped, not genuine retries. - lastLandedUrl = readPath(); + if (ticket === programmaticNavigationSeq) lastLandedUrl = currentPath; } } @@ -295,15 +324,20 @@ export function createRouter(options: RouterOptions): RouterInstance { function onBrowserNavigation(): void { if (disposed) return; + options.onPending?.(); + const ticket = ++programmaticNavigationSeq; browserNavigationQueue = browserNavigationQueue - .then(commitBrowserNavigation) + .then(() => + ticket === programmaticNavigationSeq ? commitBrowserNavigation(ticket) : undefined + ) .catch((err) => { - if (disposed) return; + if (disposed || ticket !== programmaticNavigationSeq) return; // Intentional fail-open: a rejected guard or a router error must not // wedge the queue or leave the UI inconsistent with the address bar, // so we log and converge to the real URL instead of rethrowing. log.error('browser navigation failed:', err); rematch(); + lastLandedUrl = currentPath; notifyChange(); }); } @@ -318,11 +352,43 @@ export function createRouter(options: RouterOptions): RouterInstance { removeEventListener(type, handler); } listeners.length = 0; + nativeNavigation?.removeEventListener('navigate', onNativeNavigate); + } + + function onNativeNavigate(event: NavigateEvent): void { + const target = new URL(event.destination.url); + // Firefox can emit a follow-up navigate with downloadRequest=null for + // the same download anchor. Preserve the originating element's policy. + const downloadLink = event.sourceElement?.hasAttribute('download') ?? false; + if ( + !event.canIntercept || event.downloadRequest !== null || downloadLink || + target.origin !== location.origin || + !resolveTarget(target) + ) return; + options.onPending?.(); + const ticket = ++programmaticNavigationSeq; + event.signal.addEventListener('abort', () => { + if (ticket === programmaticNavigationSeq) programmaticNavigationSeq++; + }, { once: true }); + event.intercept({ + handler: async () => { + if (event.signal.aborted || ticket !== programmaticNavigationSeq) return; + if (event.info === checkedNavigation) { + lastLandedUrl = null; + rematch(target.pathname + target.search); + notifyChange(); + } else { + await commitBrowserNavigation(ticket, target.pathname + target.search); + } + }, + }); } // ─── Initialization ─────────────────────────────────────────── - if (mode === 'history') { + if (nativeNavigation) { + nativeNavigation.addEventListener('navigate', onNativeNavigate); + } else if (mode === 'history') { addCleanupListener('popstate', onBrowserNavigation); } else { addCleanupListener('hashchange', onBrowserNavigation); @@ -341,6 +407,9 @@ export function createRouter(options: RouterOptions): RouterInstance { get currentRoute(): RouteConfig | null { return currentRoute; }, + get searchParams(): URLSearchParams { + return currentSearchParams; + }, get params(): Record { return currentParams; }, diff --git a/packages/app/src/internal/router/route-pattern.ts b/packages/app/src/internal/router/route-pattern.ts new file mode 100644 index 000000000..96e4bcbc2 --- /dev/null +++ b/packages/app/src/internal/router/route-pattern.ts @@ -0,0 +1,11 @@ +/** Convert the framework's Hono-style route dialect to WHATWG URLPattern syntax. */ +export function normalizeRoutePatternForURLPattern(path: string): string { + return path + .split('/') + .map((segment) => { + const brace = segment.startsWith(':') ? segment.indexOf('{') : -1; + if (brace === -1 || !segment.endsWith('}')) return segment; + return `${segment.slice(0, brace)}(${segment.slice(brace + 1, -1)})`; + }) + .join('/'); +} diff --git a/packages/app/src/internal/router/route-table.ts b/packages/app/src/internal/router/route-table.ts index cc41dd8a8..6ac285c8b 100644 --- a/packages/app/src/internal/router/route-table.ts +++ b/packages/app/src/internal/router/route-table.ts @@ -1,23 +1,26 @@ -/** - * Thin internal RouteTable. URLPattern owns pathname grammar; this table owns - * records, declaration priority, static lookup, query merging, and safe params. - */ - -import { normalizeRoutePatternForURLPattern } from '@openelement/element/build-utils'; +/** Route identity, URL winner and HTTP policy; URLPatternList owns indexing. */ +import { normalizeRoutePatternForURLPattern } from './route-pattern.ts'; +import { URLPatternList } from './url-pattern-list/index.ts'; import { URLPattern as URLPatternPolyfill } from 'urlpattern-polyfill'; export interface RouteRecord { path: string; + id?: string; + /** Full URL component patterns for explicit Route Mode. */ + pattern?: URLPatternInit; methods?: readonly string[]; } export interface RouteMatch { route: T; + id: string; params: Record; + searchParams: URLSearchParams; + patternResult: URLPatternResult; } export type RouteResolution = - | ({ kind: 'match' } & RouteMatch) + | ({ kind: 'match'; method: string } & RouteMatch) | { kind: 'method-not-allowed'; allow: string[] } | { kind: 'not-found' }; @@ -26,35 +29,14 @@ export interface RouteTableOptions { trailingSlash?: 'strict' | 'ignore'; } -export interface URLPatternMatch { - pathname: { groups: Record }; -} - -export interface URLPatternLike { - exec(input: { protocol: string; hostname: string; pathname: string }): URLPatternMatch | null; -} - -export type URLPatternConstructor = new (init: { pathname: string }) => URLPatternLike; - -interface CompiledRecord { - index: number; - route: T; - pattern: URLPatternLike; - staticPath?: string; -} - -type ParamMap = Map; - -function runtimeURLPattern(): URLPatternConstructor { - return (globalThis.URLPattern ?? URLPatternPolyfill) as URLPatternConstructor; -} - -function isStaticPath(path: string): boolean { - return !/[:*?+(){}]/.test(path); -} +export type URLPatternConstructor = new (init: URLPatternInit) => URLPattern; +const runtimeURLPattern = (): URLPatternConstructor => + (globalThis.URLPattern ?? URLPatternPolyfill) as URLPatternConstructor; function staticPathKey(pathname: string): string { - return new URL(pathname, 'https://openelement.invalid').pathname; + const url = new URL('https://openelement.invalid'); + url.pathname = pathname; + return url.pathname; } function normalizeBasePath(basePath: string | undefined): string { @@ -77,10 +59,11 @@ function routePathname(pathname: string, options: RouteTableOptions): string | u } function methodsFor(route: RouteRecord): string[] { - const source = route.methods?.length ? route.methods : ['GET']; - const methods = [...new Set(source.map((method) => method.toUpperCase()))]; + const methods = [ + ...new Set((route.methods?.length ? route.methods : ['GET']).map((m) => m.toUpperCase())), + ]; if (methods.includes('GET') && !methods.includes('HEAD')) methods.push('HEAD'); - return methods; + return methods.sort(); } function decodeComponent(value: string, plusAsSpace = false): string { @@ -96,130 +79,84 @@ function isSafeParamName(name: string): boolean { return name !== '__proto__' && name !== 'prototype' && name !== 'constructor'; } -function setParam(target: ParamMap, name: string, value: string): void { - if (isSafeParamName(name)) target.set(name, value); -} - -function queryParams(search: string): ParamMap { - const result = new Map(); - const source = search.startsWith('?') ? search.slice(1) : search; - if (!source) return result; - for (const pair of source.split('&')) { - const separator = pair.indexOf('='); - const key = separator < 0 ? pair : pair.slice(0, separator); - const value = separator < 0 ? '' : pair.slice(separator + 1); - setParam(result, decodeComponent(key, true), decodeComponent(value, true)); - } - return result; -} - -function paramsRecord(...sources: ParamMap[]): Record { - const values = new Map(); - for (const source of sources) { - for (const [key, value] of source) values.set(key, value); - } - return new Proxy(Object.create(null), { - get: (_target, property) => typeof property === 'string' ? values.get(property) : undefined, - getOwnPropertyDescriptor: (_target, property) => - typeof property === 'string' && values.has(property) - ? { value: values.get(property), enumerable: true, configurable: true } - : undefined, - has: (_target, property) => typeof property === 'string' && values.has(property), - ownKeys: () => [...values.keys()], - }) as Record; -} - -function pathParams(pattern: URLPatternLike, pathname: string): ParamMap | null { - const match = pattern.exec({ protocol: 'https', hostname: 'localhost', pathname }); - if (!match) return null; - const params = new Map(); - for (const [name, value] of Object.entries(match.pathname.groups)) { - if (value === undefined || /^\d+$/.test(name)) continue; - setParam(params, name, decodeComponent(value)); - } - return params; -} - export class RouteTable { - readonly #static = new Map[]>(); - readonly #dynamic: CompiledRecord[] = []; + readonly #list: URLPatternList<{ route: T; id: string }>; + readonly routes: readonly T[]; + readonly options: RouteTableOptions; constructor( - readonly routes: readonly T[], + routes: readonly T[], Pattern: URLPatternConstructor = runtimeURLPattern(), - readonly options: RouteTableOptions = {}, + options: RouteTableOptions = {}, ) { - routes.forEach((route, index) => { - let pathname = normalizeRoutePatternForURLPattern(route.path); + this.options = Object.freeze({ ...options }); + this.routes = Object.freeze( + routes.map((route) => + Object.freeze({ + ...route, + ...(route.methods ? { methods: Object.freeze([...route.methods]) } : {}), + ...(route.pattern ? { pattern: Object.freeze({ ...route.pattern }) } : {}), + }) + ), + ); + const ids = new Set(); + this.#list = new URLPatternList(this.routes.map((route, index) => { + const id = route.id ?? String(index); + if (ids.has(id)) throw new TypeError(`Duplicate route identity: ${id}`); + ids.add(id); + let pathname = route.pattern?.pathname ?? normalizeRoutePatternForURLPattern(route.path); if (options.trailingSlash === 'ignore' && pathname.length > 1 && pathname.endsWith('/')) { pathname = pathname.slice(0, -1); } - const record: CompiledRecord = { - index, - route, - pattern: new Pattern({ pathname }), - ...(isStaticPath(pathname) ? { staticPath: staticPathKey(pathname) } : {}), - }; - if (record.staticPath === undefined) { - this.#dynamic.push(record); - } else { - const entries = this.#static.get(record.staticPath) ?? []; - entries.push(record); - this.#static.set(record.staticPath, entries); - } - }); + return [new Pattern({ ...route.pattern, pathname }), { route, id }] as const; + })); } - match(pathname: string, search = ''): RouteMatch | null { - const routedPath = routePathname(pathname, this.options); - if (routedPath === undefined) return null; - const staticWinner = this.#static.get(staticPathKey(routedPath))?.[0]; - const query = queryParams(search); - for (const record of this.#dynamic) { - if (staticWinner && record.index > staticWinner.index) break; - const path = pathParams(record.pattern, routedPath); - if (path) return { route: record.route, params: paramsRecord(query, path) }; - } - if (!staticWinner) return null; - return { route: staticWinner.route, params: paramsRecord(query) }; + #url(input: string | URL, search: string): URL | undefined { + const url = input instanceof URL ? new URL(input.href) : new URL(input, 'https://localhost'); + if (search) url.search = search; + const pathname = routePathname(url.pathname, this.options); + if (pathname === undefined) return undefined; + url.pathname = pathname; + return url; } - resolve(pathname: string, search = '', method = 'GET'): RouteResolution { - const routedPath = routePathname(pathname, this.options); - if (routedPath === undefined) return { kind: 'not-found' }; - const query = queryParams(search); - const candidates: Array<{ record: CompiledRecord; params: ParamMap }> = []; - for (const record of this.#static.get(staticPathKey(routedPath)) ?? []) { - candidates.push({ record, params: new Map() }); - } - for (const record of this.#dynamic) { - const params = pathParams(record.pattern, routedPath); - if (params) candidates.push({ record, params }); + match(input: string | URL, search = ''): RouteMatch | null { + const url = this.#url(input, search); + if (!url) return null; + const winner = this.#list.match(url); + if (!winner) return null; + const params: Record = Object.create(null); + for (const [name, value] of Object.entries(winner.result.pathname.groups)) { + if (value !== undefined && !/^\d+$/.test(name) && isSafeParamName(name)) { + params[name] = decodeComponent(value); + } } - candidates.sort((left, right) => left.record.index - right.record.index); - if (candidates.length === 0) return { kind: 'not-found' }; + return { + ...winner.value, + params, + searchParams: url.searchParams, + patternResult: winner.result, + }; + } + resolve(input: string | URL, search = '', method = 'GET'): RouteResolution { + const winner = this.match(input, search); + if (!winner) return { kind: 'not-found' }; const requested = method.toUpperCase(); - for (const candidate of candidates) { - if (methodsFor(candidate.record.route).includes(requested)) { - return { - kind: 'match', - route: candidate.record.route, - params: paramsRecord(query, candidate.params), - }; - } - } - const allow = [...new Set(candidates.flatMap(({ record }) => methodsFor(record.route)))].sort(); - return { kind: 'method-not-allowed', allow }; + const allow = methodsFor(winner.route); + if (!allow.includes(requested)) return { kind: 'method-not-allowed', allow }; + const explicit = winner.route.methods?.map((m) => m.toUpperCase()) ?? ['GET']; + return { + kind: 'match', + ...winner, + method: requested === 'HEAD' && !explicit.includes('HEAD') ? 'GET' : requested, + }; } - /** Number of records URLPattern/static lookup may inspect for this path. */ - candidateCount(pathname: string): number { - const routedPath = routePathname(pathname, this.options); - if (routedPath === undefined) return 0; - const staticWinner = this.#static.get(staticPathKey(routedPath))?.[0]; - if (!staticWinner) return this.#dynamic.length; - return 1 + this.#dynamic.filter((record) => record.index < staticWinner.index).length; + candidateCount(input: string | URL): number { + const url = this.#url(input, ''); + return url ? this.#list.candidateCount(url) : 0; } } diff --git a/packages/app/src/internal/router/url-pattern-list/LICENSE b/packages/app/src/internal/router/url-pattern-list/LICENSE new file mode 100644 index 000000000..cffe7798b --- /dev/null +++ b/packages/app/src/internal/router/url-pattern-list/LICENSE @@ -0,0 +1,7 @@ +Copyright 2025 Justin Fagnani + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/app/src/internal/router/url-pattern-list/PROVENANCE.md b/packages/app/src/internal/router/url-pattern-list/PROVENANCE.md new file mode 100644 index 000000000..5a950dc63 --- /dev/null +++ b/packages/app/src/internal/router/url-pattern-list/PROVENANCE.md @@ -0,0 +1,26 @@ +# Owned URLPatternList + +Source: [justinfagnani/url-pattern-list v0.5.0](https://github.com/justinfagnani/url-pattern-list/tree/4911e649cc11860c7da90c9d0d9b05626c5cbb83), +commit `4911e649cc11860c7da90c9d0d9b05626c5cbb83`. The commit's package.json +identifies version 0.5.0; its MIT license is preserved verbatim alongside this file. + +OE derives the sequence-bearing item, fixed prefix tree and value/result contract +from src/index.ts. The original component parser and wildcard/regex traversal are +removed: their pruning needs more proof than final exec supplies, and empty URL +components must not disappear from matching. No upstream release/visualizer tooling +or global API is copied. This private module is maintained by OpenElement. + +Only canonical pathname strings in a literal ASCII alphabet enter the fixed tree. +ASCII case folding is an over-approximation; exec still determines case semantics. +Every other legal pattern remains in the conservative collection. Both collections +are merged by original sequence before exec. Other URL components are never pruned. +Duplicate values/patterns keep their order. The table is an immutable snapshot. + +Inputs follow upstream's URL/string boundary: relative strings require a baseURL; +invalid URL input throws TypeError, including for an empty list. Both the index and +exec consume the same normalized URL. Pattern construction and captures belong to +the supplied native/polyfill constructor. No URLPatternInit match-input API is claimed. + +Tests: packages/app/**tests**/url-pattern-list.test.ts compares full results and value +identity with each constructor's linear oracle. This is bounded corpus evidence, +not a claim of complete URLPattern standard or runtime qualification. diff --git a/packages/app/src/internal/router/url-pattern-list/index.ts b/packages/app/src/internal/router/url-pattern-list/index.ts new file mode 100644 index 000000000..d5cf99a0b --- /dev/null +++ b/packages/app/src/internal/router/url-pattern-list/index.ts @@ -0,0 +1,101 @@ +/** + * Derived from url-pattern-list 0.5.0, Copyright 2025 Justin Fagnani (MIT). + * See LICENSE and PROVENANCE.md. URLPattern owns all matching and captures. + */ +export interface ListPattern { + readonly pathname: string; + exec(input: string): URLPatternResult | null; +} + +interface URLPatternListItem { + readonly sequence: number; + readonly pattern: ListPattern; + readonly value: T; +} + +export interface URLPatternListMatch { + result: URLPatternResult; + value: T; +} + +/** Fixed-prefix nodes only; unproven grammars never enter the tree. */ +class FixedPrefixTreeNode { + readonly children = new Map>(); + readonly patterns: URLPatternListItem[] = []; +} + +/** + * A deliberately small literal alphabet, not a URLPattern grammar parser. + * These characters have no pattern operators/escapes. Every other spelling + * remains conservative, including groups, regex, empty paths and Unicode. + * ASCII folding over-selects for case-sensitive patterns and admits ignoreCase + * patterns without relying on a non-standard URLPattern options getter. + */ +function literalPath(path: string): string | undefined { + if (!path.startsWith('/')) return undefined; + for (const char of path) { + if (!'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/_-.%~'.includes(char)) { + return undefined; + } + } + return path.toLowerCase(); +} + +/** Immutable ordered snapshot. Input is a URL or URL string (relative with base). */ +export class URLPatternList { + readonly #root = new FixedPrefixTreeNode(); + readonly #conservative: URLPatternListItem[] = []; + + constructor(patterns: Iterable) { + let sequence = 0; + for (const [pattern, value] of patterns) { + const item = { sequence: sequence++, pattern, value }; + const key = literalPath(pattern.pathname); + if (key === undefined) { + this.#conservative.push(item); + continue; + } + let node = this.#root; + for (const char of key) { + let child = node.children.get(char); + if (!child) node.children.set(char, child = new FixedPrefixTreeNode()); + node = child; + } + node.patterns.push(item); + } + } + + #fixed(url: URL): readonly URLPatternListItem[] { + let node: FixedPrefixTreeNode | undefined = this.#root; + for (const char of url.pathname.toLowerCase()) { + node = node.children.get(char); + if (!node) return []; + } + return node.patterns; + } + + match(input: string | URL, baseURL?: string): URLPatternListMatch | null { + // One URL normalization boundary for both pruning and exec; invalid input + // throws TypeError even for an empty list. Do not silently turn it into 404. + const url = new URL(String(input), baseURL); + const fullURL = url.href; + const fixed = this.#fixed(url); + let fast = 0; + let slow = 0; + while (fast < fixed.length || slow < this.#conservative.length) { + const a = fixed[fast]; + const b = this.#conservative[slow]; + const item = a && (!b || a.sequence < b.sequence) + ? fixed[fast++] + : this.#conservative[slow++]; + const result = item.pattern.exec(fullURL); + if (result) return { result, value: item.value }; + } + return null; + } + + /** Diagnostic upper bound; no matcher internals are exposed. */ + candidateCount(input: string | URL, baseURL?: string): number { + return this.#fixed(new URL(String(input), baseURL)).length + this.#conservative.length; + } +} diff --git a/packages/app/src/router-http.ts b/packages/app/src/router-http.ts new file mode 100644 index 000000000..693f05bcf --- /dev/null +++ b/packages/app/src/router-http.ts @@ -0,0 +1,58 @@ +/** Hono integration for Route Mode; the pure matching entry is ./router. */ +import type { Context, Handler, MiddlewareHandler } from 'hono'; +import { every } from 'hono/combine'; +import { type RouteRecord, RouteTable, type RouteTableOptions } from './router.ts'; + +export interface HttpRouteRecord extends Omit { + handlers: Readonly>; +} + +/** Mount after host middleware/routes; unmatched URLs continue to the host. */ +export function createRouteMiddleware( + records: readonly HttpRouteRecord[], + options: RouteTableOptions & { + methodNotAllowed?: (context: Context, allow: readonly string[]) => Response | Promise; + } = {}, +): MiddlewareHandler { + const routes = records.map(({ handlers, ...record }) => { + const methods: string[] = []; + const dispatch = new Map(); + for (const [name, handler] of Object.entries(handlers)) { + const method = name.toUpperCase(); + if (dispatch.has(method)) { + throw new TypeError(`Duplicate ${method} handler for ${record.id ?? record.path}`); + } + if (!/^[!#$%&'*+.^_`|~0-9A-Z-]+$/.test(method)) { + throw new TypeError(`Invalid HTTP method: ${name}`); + } + methods.push(method); + dispatch.set(method, typeof handler === 'function' ? [handler] : [...handler]); + if (!dispatch.get(method)?.length) { + throw new TypeError(`Empty ${method} handler for ${record.path}`); + } + } + if (!methods.length) throw new TypeError(`No handlers for ${record.path}`); + return { ...record, methods, dispatch }; + }); + const table = new RouteTable(routes, undefined, options); + return async (c, next) => { + const resolution = table.resolve(new URL(c.req.url), '', c.req.method); + if (resolution.kind === 'not-found') return next(); + if (resolution.kind === 'method-not-allowed') { + if (options.methodNotAllowed) return options.methodNotAllowed(c, resolution.allow); + return c.text('Method Not Allowed', 405, { Allow: resolution.allow.join(', ') }); + } + c.set('routeResolution', resolution); + const handlers = resolution.route.dispatch.get(resolution.method)!; + // Hono executes middleware and Response semantics; it never rematches a path. + await every(...handlers)(c, next); + if (c.req.method === 'HEAD') { + c.res = new Response(null, { + status: c.res.status, + statusText: c.res.statusText, + headers: c.res.headers, + }); + } + return c.res; + }; +} diff --git a/packages/app/src/router.ts b/packages/app/src/router.ts new file mode 100644 index 000000000..4a1d0b0c1 --- /dev/null +++ b/packages/app/src/router.ts @@ -0,0 +1,9 @@ +/** Pure Route Mode matching entry: no Element, host or build dependencies. */ +export { RouteTable } from './internal/router/route-table.ts'; +export type { + RouteMatch, + RouteRecord, + RouteResolution, + RouteTableOptions, +} from './internal/router/route-table.ts'; +export { normalizeRoutePatternForURLPattern } from './internal/router/route-pattern.ts'; diff --git a/packages/app/src/spa.ts b/packages/app/src/spa.ts index 4a58b3740..2e50f2ce9 100644 --- a/packages/app/src/spa.ts +++ b/packages/app/src/spa.ts @@ -102,6 +102,12 @@ export function defineApp(options: SpaAppOptions): SpaAppInstance { let rootEl: Element | null = null; let submitHandler: ((e: Event) => void) | null = null; let renderId = 0; + let execution = new AbortController(); + function cancelPending(): void { + execution.abort(); + execution = new AbortController(); + renderId++; + } let currentLoaderData: unknown; let currentLoaderError: unknown; let currentActionData: unknown; @@ -122,16 +128,24 @@ export function defineApp(options: SpaAppOptions): SpaAppInstance { * channel with the original error so the error projector can read its 404 * status/message — mirroring the server chain. */ - async function runLoader(): Promise<{ data: unknown; error?: unknown; redirected?: boolean }> { + async function runLoader( + ticket = renderId, + ): Promise<{ data: unknown; error?: unknown; redirected?: boolean }> { if (!router) return { data: undefined }; const route = router.currentRoute; if (!route?.loader) return { data: undefined }; try { - return { data: await route.loader({ params: router.params }) }; + return { + data: await route.loader({ + params: router.params, + searchParams: router.searchParams, + signal: execution.signal, + }), + }; } catch (err) { if (isOpenElementRedirect(err)) { // Skip navigation if the app was disposed while the loader awaited. - if (router) await router.navigate(err.location); + if (router && ticket === renderId) await router.navigate(err.location); return { data: undefined, redirected: true }; } if (isOpenElementNotFound(err)) { @@ -255,6 +269,8 @@ export function defineApp(options: SpaAppOptions): SpaAppInstance { const outcome = classifyActionResult( await route.action({ params: router.params, + searchParams: router.searchParams, + signal: execution.signal, formData: createFormData(form), }), ); @@ -265,7 +281,7 @@ export function defineApp(options: SpaAppOptions): SpaAppInstance { if (isOpenElementRedirect(err)) { // PRG: navigate to the redirect target; its own render cycle renders // the destination. Skip if the app was disposed while awaiting. - if (router) await router.navigate(err.location); + if (router && currentRender === renderId) await router.navigate(err.location); return; } if (isOpenElementNotFound(err)) { @@ -280,6 +296,7 @@ export function defineApp(options: SpaAppOptions): SpaAppInstance { actionData = normalizeActionFailure(err, development, log.error); } + if (currentRender !== renderId || !router || !rootEl) return; // Re-run loader for fresh data const { data: loaderData, error: loaderError, redirected } = await runLoader(); if (currentRender !== renderId || !router || !rootEl) return; @@ -311,6 +328,7 @@ export function defineApp(options: SpaAppOptions): SpaAppInstance { router = createRouter({ mode: options.routerMode ?? 'auto', routes: options.routes ?? [], + onPending: cancelPending, onChange: renderRoute, }); @@ -329,6 +347,7 @@ export function defineApp(options: SpaAppOptions): SpaAppInstance { } function dispose(): void { + execution.abort(); // Remove form submit listener renderId++; if (submitHandler && rootEl) { diff --git a/packages/element/__tests__/html-route-utils.test.ts b/packages/element/__tests__/html-route-utils.test.ts index d19d09fec..923f26ccc 100644 --- a/packages/element/__tests__/html-route-utils.test.ts +++ b/packages/element/__tests__/html-route-utils.test.ts @@ -1,17 +1,5 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; -import { insertBeforeBodyClose, normalizeRoutePatternForURLPattern } from '../src/build-utils.ts'; - -Deno.test('shared route normalizer preserves params and converts Hono catch-alls (#1103)', () => { - assertEquals(normalizeRoutePatternForURLPattern('/item/:id'), '/item/:id'); - assertEquals( - normalizeRoutePatternForURLPattern('/docs/:path{.+}'), - '/docs/:path(.+)', - ); - assertEquals( - normalizeRoutePatternForURLPattern('/org/:org/repo/:path{.*}'), - '/org/:org/repo/:path(.*)', - ); -}); +import { insertBeforeBodyClose } from '../src/build-utils.ts'; Deno.test('shared body injector handles tolerant close tags and missing body (#1103)', () => { const tag = ''; diff --git a/packages/element/src/build-utils.ts b/packages/element/src/build-utils.ts index 42692d9f7..62a4c0ca9 100644 --- a/packages/element/src/build-utils.ts +++ b/packages/element/src/build-utils.ts @@ -13,9 +13,6 @@ export { formatJson } from './public-build-runtime.ts'; export { normalizeSeparators, pathToTagName } from './public-build-runtime.ts'; export { SsrRenderError } from './public-build-runtime.ts'; export { transformIslandSource } from './public-build-runtime.ts'; -export { - insertBeforeBodyClose, - normalizeRoutePatternForURLPattern, -} from './public-build-runtime.ts'; +export { insertBeforeBodyClose } from './public-build-runtime.ts'; export type { OpenElementRequestHandler, RuntimeContext } from './public-build-runtime.ts'; export { composeFetchMiddleware, createRuntimeAdapter } from './public-build-runtime.ts'; diff --git a/packages/element/src/internal/core/html-route-utils.ts b/packages/element/src/internal/core/html-route-utils.ts index 0a6c40c17..679061eb0 100644 --- a/packages/element/src/internal/core/html-route-utils.ts +++ b/packages/element/src/internal/core/html-route-utils.ts @@ -1,15 +1,3 @@ -/** Convert the framework's Hono-style route dialect to WHATWG URLPattern syntax. */ -export function normalizeRoutePatternForURLPattern(path: string): string { - return path - .split('/') - .map((segment) => { - const brace = segment.startsWith(':') ? segment.indexOf('{') : -1; - if (brace === -1 || !segment.endsWith('}')) return segment; - return `${segment.slice(0, brace)}(${segment.slice(brace + 1, -1)})`; - }) - .join('/'); -} - /** Insert markup before a tolerant HTML body close (``, case-insensitive). */ export function insertBeforeBodyClose(html: string, content: string): string { const match = /<\/body\s*>/i.exec(html); diff --git a/packages/element/src/public-build-runtime.ts b/packages/element/src/public-build-runtime.ts index 39fb87062..e13481fa6 100644 --- a/packages/element/src/public-build-runtime.ts +++ b/packages/element/src/public-build-runtime.ts @@ -3,9 +3,6 @@ export { formatJson } from './internal/core/write-json.ts'; export { normalizeSeparators, pathToTagName } from './internal/core/path-utils.ts'; export { SsrRenderError } from './internal/core/errors.ts'; export { transformIslandSource } from './internal/core/island-transform.ts'; -export { - insertBeforeBodyClose, - normalizeRoutePatternForURLPattern, -} from './internal/core/html-route-utils.ts'; +export { insertBeforeBodyClose } from './internal/core/html-route-utils.ts'; export type { OpenElementRequestHandler, RuntimeContext } from './internal/core/runtime.ts'; export { composeFetchMiddleware, createRuntimeAdapter } from './internal/core/runtime.ts'; diff --git a/tools/autoflow/__tests__/release.test.ts b/tools/autoflow/__tests__/release.test.ts index f3b9854da..219880f11 100644 --- a/tools/autoflow/__tests__/release.test.ts +++ b/tools/autoflow/__tests__/release.test.ts @@ -25,6 +25,7 @@ import { type ReleaseEvidence, renderClosureSection, renderReleaseNote, + resolvePatchTargetVersion, resumeEvidenceFromPrior, verifyMainCiSuccessForHead, verifyPrepareRecord, @@ -358,7 +359,7 @@ Deno.test('advancePrepareReleaseStateText: prepare advances the planning anchors // check-release-truth pins the planning pair to ACTIVE/NEXT_EXECUTION_VERSION, // which the bump advances; prepare must move them with the bump. assertEquals(state.activeTarget, 'v0.44.0-beta.2'); - assertEquals(state.nextPlannedTrain, 'v0.44.0-beta.3'); + assertEquals(state.nextPlannedTrain, 'v0.44.0-beta.2.1'); // The published-line fields stay finalize-owned // (advancePublishedReleaseStateText): prepare leaves the prepare-window lag. assertEquals(state.sourceVersion, '0.44.0-beta.1'); @@ -1541,3 +1542,24 @@ Deno.test('foldStarterLockfileIntoBumpCommit: a clean lockfile is a no-op (#1083 }, ); }); + +Deno.test('Beta checkpoint prepare, public-stage planning and retry preserve the source/published window', () => { + const published = { + schemaVersion: 1, + sourceVersion: '0.44.0-beta.2', + publishedVersion: '0.44.0-beta.2', + latestLandedTrain: 'v0.44.0-beta.2', + activeTarget: 'v0.44.0-beta.2.1', + nextPlannedTrain: 'v0.44.0-beta.2.1', + maturity: 'beta', + }; + const prepared = advancePrepareReleaseStateText(JSON.stringify(published), '0.44.0-beta.2.1'); + assertEquals(advancePrepareReleaseStateText(prepared, '0.44.0-beta.2.1'), prepared); + assertEquals(JSON.parse(prepared).publishedVersion, published.publishedVersion); + assertEquals(JSON.parse(prepared).nextPlannedTrain, 'v0.44.0-beta.2.2'); + assertEquals(nextPrereleaseTag('0.44.0-beta.2.3'), 'v1.0.0-alpha.1'); + assertEquals( + resolvePatchTargetVersion('0.44.0-beta.2.1', { kind: 'patch-release', status: 'failed' }), + { targetVersion: '0.44.0-beta.2.1', resumed: true }, + ); +}); diff --git a/tools/autoflow/release.ts b/tools/autoflow/release.ts index 3f1f4a3ff..e9efcc510 100644 --- a/tools/autoflow/release.ts +++ b/tools/autoflow/release.ts @@ -1,5 +1,6 @@ import { AUTOFLOW3_POLICY_VERSION, isCI } from './policy.ts'; import { + assertPublicReleaseVersion, compareVersions as compareLineVersions, FIRST_TAGGED_VERSION, nextPatchVersion as nextLinePatchVersion, @@ -143,6 +144,7 @@ export function createReleaseEvidence( targetVersion: string, approvalId?: string, ): ReleaseEvidence { + assertPublicReleaseVersion(targetVersion); const now = new Date().toISOString(); return { id: `${kind}-${releaseTag(targetVersion)}-${now.replace(/[:.]/g, '-')}`, diff --git a/tools/autoflow/version-anchors.ts b/tools/autoflow/version-anchors.ts index 7e205ff49..923927151 100644 --- a/tools/autoflow/version-anchors.ts +++ b/tools/autoflow/version-anchors.ts @@ -16,7 +16,12 @@ import { PREVIOUS_PACKAGE_VERSION, PREVIOUS_PACKAGE_VERSION_TAG, } from '../project-constants.ts'; -import { prereleaseChannel, prereleaseParts } from '../lib/version.ts'; +import { + nextPatchVersion, + nextProductStageVersion, + prereleaseChannel, + prereleaseParts, +} from '../lib/version.ts'; export function releaseTag(version: string): string { return `v${version}`; @@ -107,7 +112,9 @@ export async function updatePrepareReleaseState(version: string): Promise export function nextPrereleaseTag(version: string): string { const parts = prereleaseParts(version); if (!parts) return releaseTag(version); - return `v${parts.base}-${parts.name}.${parts.num + 1}`; + return releaseTag( + version === '0.44.0-beta.2.3' ? nextProductStageVersion(version) : nextPatchVersion(version), + ); } /** diff --git a/tools/bump-version.test.ts b/tools/bump-version.test.ts index a0b8ed127..aac843676 100644 --- a/tools/bump-version.test.ts +++ b/tools/bump-version.test.ts @@ -13,6 +13,7 @@ Deno.test('parseVersion parses stable and prerelease versions', () => { minor: 41, patch: 0, prerelease: 'alpha', + identifiers: ['alpha', '7'], prereleaseNumber: 7, }); }); diff --git a/tools/bump-version.ts b/tools/bump-version.ts index 577cd1fc5..9a1a9a33c 100644 --- a/tools/bump-version.ts +++ b/tools/bump-version.ts @@ -30,7 +30,7 @@ interface PackageJson { [key: string]: unknown; } -import { type LineVersion, parseLineVersion } from './lib/version.ts'; +import { compareVersions, type LineVersion, parseLineVersion } from './lib/version.ts'; import { getArg } from './lib/process.ts'; // Canonical prerelease/version truth lives in tools/lib/version.ts (#1231 @@ -40,37 +40,17 @@ export type ParsedVersion = LineVersion; export const parseVersion = parseLineVersion; -const PRERELEASE_RANK: Record = { - alpha: 1, - beta: 2, - rc: 3, -}; - export function validateVersionStep(fromVersion: string, toVersion: string): void { - const from = parseVersion(fromVersion); - const to = parseVersion(toVersion); - - const fromBase = from.major * 1_000_000 + from.minor * 1_000 + from.patch; - const toBase = to.major * 1_000_000 + to.minor * 1_000 + to.patch; - if (toBase < fromBase) { + if (compareVersions(toVersion, fromVersion) < 0) { + const from = parseVersion(fromVersion); + const to = parseVersion(toVersion); + const sameBase = from.major === to.major && from.minor === to.minor && from.patch === to.patch; throw new Error( - `Version step regresses the release base: ${fromVersion} → ${toVersion}`, + sameBase + ? `Prerelease step regresses: ${fromVersion} → ${toVersion}` + : `Version step regresses the release base: ${fromVersion} → ${toVersion}`, ); } - - // Same-base prerelease steps must not move backwards (e.g. beta → alpha). - if (toBase === fromBase && from.prerelease && to.prerelease) { - const fromRank = PRERELEASE_RANK[from.prerelease] ?? 99; - const toRank = PRERELEASE_RANK[to.prerelease] ?? 99; - if ( - toRank < fromRank || - (toRank === fromRank && to.prereleaseNumber < from.prereleaseNumber) - ) { - throw new Error( - `Prerelease step regresses: ${fromVersion} → ${toVersion}`, - ); - } - } } function findPackageDenos(root: string): string[] { diff --git a/tools/consumer-packaged-element.ts b/tools/consumer-packaged-element.ts new file mode 100644 index 000000000..1fadf5817 --- /dev/null +++ b/tools/consumer-packaged-element.ts @@ -0,0 +1,167 @@ +/** Packed Element author -> Vite compile -> separate plain-HTML browser proof (#1338). */ +import { assert, assertEquals } from '@std/assert'; +import { join, resolve } from '@std/path'; +import { chromium, firefox, webkit } from '@playwright/test'; +import ts from 'typescript'; +import { PACKAGE_VERSION } from './project-constants.ts'; + +const root = resolve(import.meta.dirname!, '..'); +const author = await Deno.makeTempDir({ prefix: 'oe-element-author-' }); +const consumer = await Deno.makeTempDir({ prefix: 'oe-element-html-' }); +async function run(args: string[]): Promise { + const output = await new Deno.Command(args[0], { + args: args.slice(1), + cwd: author, + stdout: 'piped', + stderr: 'piped', + }).output(); + if (!output.success) { + throw new Error( + new TextDecoder().decode(output.stdout) + new TextDecoder().decode(output.stderr), + ); + } +} +try { + await Deno.writeTextFile( + join(author, 'package.json'), + JSON.stringify({ + name: 'oe-standalone-author-proof', + version: '1.0.0', + private: true, + type: 'module', + dependencies: { + '@openelement/element': + `file:${root}/packages/element/openelement-element-${PACKAGE_VERSION}.tgz`, + }, + devDependencies: { + '@openelement/adapter-vite': + `file:${root}/packages/adapter-vite/openelement-adapter-vite-${PACKAGE_VERSION}.tgz`, + vite: '8.0.16', + }, + }), + ); + await Deno.writeTextFile( + join(author, 'counter.tsx'), + `import {element,property,OpenElement} from '@openelement/element'; +@element('proof-counter') +export class Counter extends OpenElement { + @property({reflect:true}) count = 0; + increment() { this.count++; } + render() { return ; } +} +`, + ); + await Deno.writeTextFile( + join(author, 'register.js'), + `import {Counter} from './counter.tsx'; customElements.define('proof-counter',Counter);`, + ); + await Deno.writeTextFile( + join(author, 'vite.config.js'), + `import {element} from '@openelement/adapter-vite/element'; +export default {plugins:[element(), {name:'proof-module-boundary',generateBundle(){for(const id of this.getModuleIds()){if(/compiler|adapter-vite|node:/.test(id))this.error('Browser tooling leak: '+id)}}}],build:{sourcemap:true,lib:{entry:'register.js',formats:['es'],fileName:'counter'}}};`, + ); + await run([ + 'npm', + 'install', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--fetch-retries=1', + '--fetch-timeout=30000', + ]); + assert( + !await Deno.stat(join(author, 'node_modules/@openelement/app')).then(() => true, () => false), + 'Router must not be installed', + ); + await run(['node', 'node_modules/vite/bin/vite.js', 'build']); + // Follow local and external declaration edges from the browser entry, rather than + // rejecting separate supported tooling declarations elsewhere in the package. + const seen = new Set(); + const declarations = async (path: string): Promise => { + if (seen.has(path)) return; + seen.add(path); + const text = await Deno.readTextFile(path); + for (const { fileName } of ts.preProcessFile(text).importedFiles) { + assert( + !/compiler|adapter-vite|\bvite\b|^node:|workspace:/.test(fileName), + `Browser declaration leak: ${path} -> ${fileName}`, + ); + const resolved = ts.resolveModuleName(fileName, path, { + moduleResolution: ts.ModuleResolutionKind.Bundler, + module: ts.ModuleKind.ESNext, + }, { + fileExists: (name) => { + try { + return Deno.statSync(name).isFile; + } catch { + return false; + } + }, + readFile: (name) => { + try { + return Deno.readTextFileSync(name); + } catch { + return undefined; + } + }, + }).resolvedModule; + assert(resolved, `Unresolved browser declaration: ${path} -> ${fileName}`); + await declarations(resolved.resolvedFileName); + } + }; + await declarations(join(author, 'node_modules/@openelement/element/src/index.d.ts')); + const map = JSON.parse(await Deno.readTextFile(join(author, 'dist/counter.js.map'))); + assert( + map.sources.some((s: string) => s.endsWith('counter.tsx')), + 'authored source map must survive', + ); + const js = await Deno.readTextFile(join(author, 'dist/counter.js')); + assert( + ts.preProcessFile(js).importedFiles.every(({ fileName }) => + !/workspace:|@openelement\/adapter-vite|^node:/.test(fileName) + ), + 'compiled browser artifact boundary', + ); + await Deno.writeTextFile(join(consumer, 'counter.js'), js); + await Deno.writeTextFile( + join(consumer, 'index.html'), + '', + ); + const server = Deno.serve({ hostname: '127.0.0.1', port: 0, onListen() {} }, async (request) => { + const script = new URL(request.url).pathname === '/counter.js'; + return new Response( + await Deno.readTextFile(join(consumer, script ? 'counter.js' : 'index.html')), + { headers: { 'content-type': script ? 'text/javascript' : 'text/html' } }, + ); + }); + try { + for (const type of [chromium, firefox, webkit]) { + const browser = await type.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.goto(`http://127.0.0.1:${server.addr.port}`); + const button = page.locator('proof-counter button'); + await button.waitFor(); + assertEquals(await button.textContent(), 'Count: 0'); + assertEquals(await button.getAttribute('title'), '0'); + await button.click(); + await page.waitForFunction('document.querySelector("proof-counter").count === 1'); + assertEquals(await button.textContent(), 'Count: 1'); + assertEquals(await button.getAttribute('title'), '1'); + console.log( + `PASS ${type.name()} ${browser.version()}: packed Element registration, attribute and event update`, + ); + } finally { + await browser.close(); + } + } + } finally { + await server.shutdown(); + } + console.log( + `PASS: Router absent, browser module graph clean, ${seen.size} declaration modules checked, source map retained`, + ); +} finally { + await Deno.remove(author, { recursive: true }); + await Deno.remove(consumer, { recursive: true }); +} diff --git a/tools/lib/npm-release-verifier.ts b/tools/lib/npm-release-verifier.ts index e1dc6dd9a..cb4a869bd 100644 --- a/tools/lib/npm-release-verifier.ts +++ b/tools/lib/npm-release-verifier.ts @@ -1,7 +1,7 @@ import { type PrereleaseChannel, prereleaseChannel, - prereleaseParts, + previousPrereleaseVersion, tryParseLineVersion, } from './version.ts'; @@ -67,10 +67,7 @@ export function prereleaseTag(version: string): PrereleaseChannel | null { // #869-2.5: the version immediately before the target on the same line, so a // release can never skip a number (alpha.8-style hole). export function previousPrerelease(version: string): string | null { - const parts = prereleaseParts(version); - const channel = prereleaseChannel(version); - if (!parts || !channel || parts.num <= 1) return null; - return `${parts.base}-${channel}.${parts.num - 1}`; + return previousPrereleaseVersion(version); } async function verifyField( diff --git a/tools/lib/version.test.ts b/tools/lib/version.test.ts index 9b83a0f3f..7c4fe89c1 100644 --- a/tools/lib/version.test.ts +++ b/tools/lib/version.test.ts @@ -1,12 +1,18 @@ import { assert, assertEquals, assertThrows } from '@std/assert'; import { + assertPublicReleaseVersion, compareVersions, FIRST_TAGGED_VERSION, + formatLineVersion, + isInternalAlphaWorkspace, + nextCheckpointVersion, nextPatchVersion, + nextProductStageVersion, normalizeReleaseVersion, parseLineVersion, prereleaseChannel, prereleaseParts, + previousPrereleaseVersion, tryParseLineVersion, } from './version.ts'; @@ -22,12 +28,15 @@ Deno.test('parseLineVersion parses stable and prerelease line versions', () => { minor: 44, patch: 0, prerelease: 'beta', + identifiers: ['beta', '1'], prereleaseNumber: 1, }); }); Deno.test('parseLineVersion rejects non-line versions', () => { - for (const bad of ['1.2', 'v1.2.3', '1.2.3+build', '1.2.3-alpha', '1.2.3-alpha.1.x', '01.2.3']) { + for ( + const bad of ['1.2', 'v1.2.3', '1.2.3+build', '1.2.3-alpha..1', '1.2.3-alpha.01', '01.2.3'] + ) { assertThrows(() => parseLineVersion(bad), Error, 'Invalid semver', bad); assertEquals(tryParseLineVersion(bad), undefined, bad); } @@ -73,3 +82,46 @@ Deno.test('FIRST_TAGGED_VERSION is the immutable-tag policy boundary (#855)', () assertEquals(FIRST_TAGGED_VERSION, '0.41.0-alpha.14'); assert(tryParseLineVersion(FIRST_TAGGED_VERSION) !== undefined); }); + +Deno.test('Beta checkpoint SemVer precedence includes all identifiers', () => { + const chain = [ + '0.44.0-beta.2', + '0.44.0-beta.2.1', + '0.44.0-beta.2.9', + '0.44.0-beta.2.10', + '0.44.0-beta.3', + '0.44.0', + '1.0.0-alpha.1', + ]; + for (let i = 1; i < chain.length; i++) assertEquals(compareVersions(chain[i - 1], chain[i]), -1); + assertEquals(prereleaseChannel('0.44.0-beta.2.1'), 'beta'); + assertEquals(prereleaseChannel('1.0.0-alpha.1'), 'alpha'); +}); + +Deno.test('checkpoint and product stage succession are different operations', () => { + assertEquals(nextCheckpointVersion('0.44.0-beta.2'), '0.44.0-beta.2.1'); + assertEquals(nextCheckpointVersion('0.44.0-beta.2.1'), '0.44.0-beta.2.2'); + assertEquals(nextCheckpointVersion('0.44.0-beta.2.2'), '0.44.0-beta.2.3'); + assertThrows(() => nextPatchVersion('0.44.0-beta.2.3')); + assertEquals(nextProductStageVersion('0.44.0-beta.2.3'), '1.0.0-alpha.1'); + assertEquals(isInternalAlphaWorkspace('0.44.0-alpha.10'), true); + assertEquals(isInternalAlphaWorkspace('1.0.0-alpha.1'), false); + for ( + const version of [ + '1.2.3-alpha', + '1.2.3-alpha.1.x', + '0.44.0-beta.2.10', + '1.2.3-12345678901234567890', + ] + ) assertEquals(formatLineVersion(parseLineVersion(version)), version); + assertEquals(compareVersions('1.2.3-9', '1.2.3-10'), -1); + assertEquals(compareVersions('1.2.3-1', '1.2.3-a'), -1); +}); + +Deno.test('release predecessor retains checkpoint identifiers and historic workspace prohibition is scoped', () => { + assertEquals(previousPrereleaseVersion('0.44.0-beta.2.2'), '0.44.0-beta.2.1'); + assertEquals(previousPrereleaseVersion('0.44.0-beta.2.1'), '0.44.0-beta.2'); + assertEquals(previousPrereleaseVersion('1.0.0-alpha.1'), null); + assertThrows(() => assertPublicReleaseVersion('0.44.0-alpha.10')); + assertPublicReleaseVersion('1.0.0-alpha.1'); +}); diff --git a/tools/lib/version.ts b/tools/lib/version.ts index ac44fba4f..1d98a97bf 100644 --- a/tools/lib/version.ts +++ b/tools/lib/version.ts @@ -2,8 +2,8 @@ * Canonical prerelease/version truth (#1231 M16; umbrella #1155). * * This module is the ONE implementation of the release line-version contract - * `x.y.z` or `x.y.z-