diff --git a/CHANGELOG.md b/CHANGELOG.md index 12fa6ec37..f39ce1040 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,32 @@ Current truth lives in: Historical changelog details remain available through git history and release evidence. +## 0.42.0-alpha.1 + +- First alpha of the 0.42 line (ADR-0120): request-time rendering gains + semantics — `renderIntent.mode` was inert metadata before this release. +- `rendering: 'dynamic'` routes skip prerendering, dynamic expansion and + i18n locale prerendering; they are served per request by the generated + `dist/server/index.js` (nitro-mount over the same SSR bundle), with + `dist/server/server-manifest.json` recording the partition. +- Hard rule (ADR-0120): pages with actions cannot be prerendered — a route + module exporting an action without `mode: 'dynamic'` fails the build. +- BuildPlan evidence records `requestTimeRoutes`; pure-static projects emit + no new artifacts (byte-identical public output proven against the 0.41.2 + build: zero HTML/JS/CSS differences). +- Request-time e2e fixture under + `packages/adapter-vite/__fixtures__/request-time/` proves loader data + varies per request and islands hydrate identically to static pages + (Chromium, Firefox and WebKit, 12/12). +- Request-time HTML now receives the island client entry automatically — + the generated server entry injects the same script the static pipeline + injects post-build (found by the fixture; without it, islands on + request-time pages never hydrated). A latent `__headExtras is not + defined` codegen bug for projects without head extras is fixed too. +- Loader/action contract types (`Loader`/`LoaderContext`/`Action`/ + `ActionContext`) are unchanged — they already shipped; this alpha wires + the rendering-mode semantics around them. + ## 0.41.2 - Patch release: release-tooling self-repair (TP-0 of the 0.42.0 plan) — no diff --git a/README.md b/README.md index efe267781..040719cb1 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Elements are the durable application contract; JSX and Basic Element are the authoring layer; Declarative Shadow DOM is the default server representation; interactive regions upgrade selectively. -Published package line: `0.41.2` (`v0.41.2`) — the stable five-package +Published package line: `0.42.0-alpha.1` (`v0.42.0-alpha.1`) — the stable five-package release under ADR-0119's scoped interface freeze; the abandoned beta naming is not an active line. diff --git a/README.zh.md b/README.zh.md index de1e86d46..5f95ceb2a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -6,7 +6,7 @@ Elements 是可长期保存的应用组件模型;JSX 与 Basic Element 是作者层; Declarative Shadow DOM 是默认服务端表示;交互区域按需升级。 -已发布包线为 `0.41.2`(`v0.41.2`)——ADR-0119 范围化接口冻结下的 +已发布包线为 `0.42.0-alpha.1`(`v0.42.0-alpha.1`)——ADR-0119 范围化接口冻结下的 stable 五包版本;已放弃的 beta 命名不再是当前版本线。 ## 当前产品 diff --git a/deno.json b/deno.json index 0497d6279..d756d72d4 100644 --- a/deno.json +++ b/deno.json @@ -86,6 +86,9 @@ "test:e2e:firefox-smoke": "deno run -A npm:@playwright/test@1.59.1 test --config www/e2e/playwright.config.ts --project=firefox --grep \"DSD Layers|Layout Island Shell|Island Script Loading|Theme Toggle|Theme initialization|data-signal bindings|SSR/hydration mismatch degradation|router guards on browser history traversal|reflect: true static props\"", "test:e2e:webkit-smoke": "deno run -A npm:@playwright/test@1.59.1 test --config www/e2e/playwright.config.ts --project=webkit --grep \"DSD Layers|Layout Island Shell|Island Script Loading|Theme Toggle|Theme initialization|data-signal bindings|SSR/hydration mismatch degradation|router guards on browser history traversal|reflect: true static props\"", "test:e2e:browsers:install": "deno run -A npm:playwright@1.59.1 install chromium firefox webkit", + "fixture:request-time:build": "cd packages/adapter-vite/__fixtures__/request-time && deno run --config ../../../../deno.json --allow-read --allow-write --allow-net --allow-env --allow-ffi --allow-sys --allow-run ../../src/cli/build.ts", + "fixture:request-time:e2e": "deno run -A npm:@playwright/test@1.59.1 test --config packages/adapter-vite/__fixtures__/request-time/e2e/playwright.config.ts --project=chromium", + "fixture:request-time:e2e:browsers": "deno run -A npm:@playwright/test@1.59.1 test --config packages/adapter-vite/__fixtures__/request-time/e2e/playwright.config.ts", "nitro:proof:node": "deno run --allow-read --allow-write --allow-run --allow-env --allow-net tools/nitro-proof.ts node", "nitro:proof:workers": "deno run --allow-read --allow-write --allow-run --allow-env --allow-net tools/nitro-proof.ts workers", "test:coverage:check": "deno run --allow-read --allow-run --allow-write --allow-env --allow-net --allow-ffi --allow-sys tools/check-coverage.ts --threshold 73 --branch-threshold 82 --function-threshold 77", diff --git a/docs/current/VERSION_PLAN.md b/docs/current/VERSION_PLAN.md index d662e5aaa..2de49c621 100644 --- a/docs/current/VERSION_PLAN.md +++ b/docs/current/VERSION_PLAN.md @@ -1,7 +1,7 @@ # v0.42.0 — WC Application Loop release plan -> Current source package line: `v0.41.2`\ -> Current npm registry line: `v0.41.2`\ +> Current source package line: `v0.42.0-alpha.1`\ +> Current npm registry line: `v0.42.0-alpha.1`\ > Active release target: `v0.41.1`\ > Planning release target: `v0.42.0` (WC Application Loop)\ > Next release line: `v0.43.0` (Universal WC SSR)\ diff --git a/docs/governance/PROJECT_WORKFLOW.md b/docs/governance/PROJECT_WORKFLOW.md index f02d4aa38..eadcdd894 100644 --- a/docs/governance/PROJECT_WORKFLOW.md +++ b/docs/governance/PROJECT_WORKFLOW.md @@ -11,8 +11,8 @@ 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: published package line `v0.41.2`, completed -implementation anchor `v0.41.2`, and `0.42.0` WC Application Loop planning +Current execution anchor: published package line `v0.42.0-alpha.1`, completed +implementation anchor `v0.42.0-alpha.1`, and `0.42.0` WC Application Loop planning under ADR-0120 and `docs/current/VERSION_PLAN.md`. OpenElement is one Web Components-native, static-first application framework: Basic Element is an authoring mode, not a diff --git a/docs/roadmap/ROADMAP.md b/docs/roadmap/ROADMAP.md index 0f017e494..fcde5d848 100644 --- a/docs/roadmap/ROADMAP.md +++ b/docs/roadmap/ROADMAP.md @@ -4,8 +4,8 @@ Execution and release state follow the [`Project Workflow`](../governance/PROJECT_WORKFLOW.md). > Source of truth for forward product planning.\ -> Published package line: `v0.41.2`.\ -> Active execution target: `v0.41.2`.\ +> Published package line: `v0.42.0-alpha.1`.\ +> Active execution target: `v0.42.0-alpha.1`.\ > Current implementation state: five-package convergence is published; > alpha.17 closed the first audit remediation, alpha.18 completed the > second audit sweep (ADR-0117), and alpha.19 completed the third (ADR-0118).\ @@ -116,7 +116,7 @@ making the standard Custom Element contract span both layers. See the official ## Current release state -`0.41.2` is the published package line. npm beta.1 through beta.3 are +`0.42.0-alpha.1` is the published package line. npm beta.1 through beta.3 are immutable partial artifacts and remain withdrawn from the active release story. The planned beta name was cancelled so the version label honestly reflects that breaking architecture and interface changes are still allowed. diff --git a/docs/status/STATUS.md b/docs/status/STATUS.md index 982a0c314..86958c9fb 100644 --- a/docs/status/STATUS.md +++ b/docs/status/STATUS.md @@ -1,9 +1,9 @@ # OpenElement Status > Updated: 2026-07-27\ -> Repository package line: `v0.41.2`\ -> npm registry line: `v0.41.2`\ -> Active release target: `v0.41.2`\ +> Repository package line: `v0.42.0-alpha.1`\ +> npm registry line: `v0.42.0-alpha.1`\ +> Active release target: `v0.42.0-alpha.1`\ > Next release line: `v0.42.0`\ > Product graph: five packages\ > Current maturity stage: stable (0.41.x) diff --git a/packages/adapter-vite/__fixtures__/request-time/app/components/.gitkeep b/packages/adapter-vite/__fixtures__/request-time/app/components/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/adapter-vite/__fixtures__/request-time/app/islands/live-counter.tsx b/packages/adapter-vite/__fixtures__/request-time/app/islands/live-counter.tsx new file mode 100644 index 000000000..9dd916593 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/request-time/app/islands/live-counter.tsx @@ -0,0 +1,31 @@ +/** + * live-counter — minimal signal counter island. + * + * Hydrates on load; clicking the button increments the count. Used on the + * request-time /live page to prove hydration is identical to static pages. + */ +import { defineCustomElement, OpenElement, signal } from '@openelement/element'; +import { defineIslandConfig } from '@openelement/app'; + +export const tagName = 'live-counter'; +export const openElement = defineIslandConfig({ hydrate: 'load', ssr: true, dsd: true }); + +export default class LiveCounter extends OpenElement { + #count = signal(0); + + constructor() { + super(); + this.registerSignal('count', this.#count); + } + + override render() { + return ( +
+ + +
+ ); + } +} + +defineCustomElement(tagName, LiveCounter); diff --git a/packages/adapter-vite/__fixtures__/request-time/app/routes/form.tsx b/packages/adapter-vite/__fixtures__/request-time/app/routes/form.tsx new file mode 100644 index 000000000..69fc9abcf --- /dev/null +++ b/packages/adapter-vite/__fixtures__/request-time/app/routes/form.tsx @@ -0,0 +1,38 @@ +/** + * /form — request-time page with an action export. + * + * Exercises the ADR-0120 hard rule from its valid side: a route with an + * action must declare renderIntent mode 'dynamic', and then builds fine. + */ +import { definePage, useActionData } from '@openelement/app'; + +export const tagName = 'page-form'; + +interface FormActionData { + echoed: string; +} + +export function action(ctx: { formData: Record }): FormActionData { + return { echoed: String(ctx.formData?.message ?? '') }; +} + +const FormPage = definePage({ + renderIntent: { mode: 'dynamic' }, + head: { title: 'request-time fixture — form' }, + render() { + const actionData = useActionData() as FormActionData | undefined; + return ( +
+

request-time form

+
+ + +
+

echo={actionData?.echoed ?? ''}

+
+ ); + }, +}); + +customElements.define(tagName, FormPage); +export default FormPage; diff --git a/packages/adapter-vite/__fixtures__/request-time/app/routes/index.tsx b/packages/adapter-vite/__fixtures__/request-time/app/routes/index.tsx new file mode 100644 index 000000000..5af67558f --- /dev/null +++ b/packages/adapter-vite/__fixtures__/request-time/app/routes/index.tsx @@ -0,0 +1,19 @@ +/** Static home page (default renderIntent mode 'auto') — prerendered at build time. */ +import { definePage } from '@openelement/app'; + +export const tagName = 'page-home'; + +const HomePage = definePage({ + head: { title: 'request-time fixture — home' }, + render() { + return ( +
+

request-time fixture home

+

This page is prerendered; /live is rendered at request time.

+
+ ); + }, +}); + +customElements.define(tagName, HomePage); +export default HomePage; diff --git a/packages/adapter-vite/__fixtures__/request-time/app/routes/live.tsx b/packages/adapter-vite/__fixtures__/request-time/app/routes/live.tsx new file mode 100644 index 000000000..58aaf8b70 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/request-time/app/routes/live.tsx @@ -0,0 +1,45 @@ +/** + * /live — request-time rendered page (renderIntent mode 'dynamic'). + * + * The loader derives data from the incoming request (query param `x`) and a + * per-process counter, so two requests must never return identical HTML. + * A counter island verifies hydration behaves the same as on static pages. + */ +import { definePage } from '@openelement/app'; +import '../islands/live-counter.tsx'; + +export const tagName = 'page-live'; + +interface LiveData { + x: string; + nonce: number; +} + +let requestCounter = 0; + +export function loader(ctx: { request: Request }): LiveData { + const url = new URL(ctx.request.url); + requestCounter += 1; + return { + x: url.searchParams.get('x') ?? '', + nonce: requestCounter, + }; +} + +const LivePage = definePage({ + renderIntent: { mode: 'dynamic' }, + head: { title: 'request-time fixture — live' }, + render({ data }) { + return ( +
+

request-time live

+

x={data?.x ?? ''}

+

nonce={data?.nonce ?? 0}

+ +
+ ); + }, +}); + +customElements.define(tagName, LivePage); +export default LivePage; diff --git a/packages/adapter-vite/__fixtures__/request-time/e2e/live.spec.ts b/packages/adapter-vite/__fixtures__/request-time/e2e/live.spec.ts new file mode 100644 index 000000000..67fce3e52 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/request-time/e2e/live.spec.ts @@ -0,0 +1,61 @@ +/** + * Request-time rendering E2E (0.42.0-alpha.1). + * + * Proves that renderIntent: { mode: 'dynamic' } routes are rendered per + * request (not prerendered), that islands hydrate on request-time pages + * exactly like on static pages, and that static routes are untouched. + */ +import { expect, test } from '@playwright/test'; + +test.describe('request-time rendering', () => { + test('GET /live renders loader data per request', async ({ request }) => { + const first = await request.get('/live?x=42'); + expect(first.ok()).toBe(true); + const firstHtml = await first.text(); + expect(firstHtml).toContain('x=42'); + + const second = await request.get('/live?x=99'); + expect(second.ok()).toBe(true); + const secondHtml = await second.text(); + expect(secondHtml).toContain('x=99'); + expect(secondHtml).not.toContain('x=42'); + + // Per-request proof: the nonce increments between requests, so the two + // responses cannot both come from a prerendered file. + const firstNonce = /nonce=(\d+)/.exec(firstHtml)?.[1]; + const secondNonce = /nonce=(\d+)/.exec(secondHtml)?.[1]; + expect(firstNonce).toBeTruthy(); + expect(secondNonce).toBeTruthy(); + expect(Number(secondNonce)).toBeGreaterThan(Number(firstNonce)); + }); + + test('counter island hydrates on the request-time page', async ({ page }) => { + await page.goto('/live?x=42'); + const button = page.locator('live-counter #increment'); + const count = page.locator('live-counter #count'); + await expect(button).toBeVisible(); + await button.click(); + await expect(count).toHaveText('1'); + await button.click(); + await expect(count).toHaveText('2'); + }); + + test('GET / serves the prerendered static page', async ({ page, request }) => { + const response = await request.get('/'); + expect(response.ok()).toBe(true); + const html = await response.text(); + expect(html).toContain('request-time fixture home'); + + await page.goto('/'); + await expect(page.locator('#home-marker')).toHaveText('request-time fixture home'); + }); + + test('action route builds and responds to POST', async ({ request }) => { + const response = await request.post('/form', { + form: { message: 'hello-action' }, + }); + expect(response.ok()).toBe(true); + const html = await response.text(); + expect(html).toContain('echo=hello-action'); + }); +}); diff --git a/packages/adapter-vite/__fixtures__/request-time/e2e/playwright.config.ts b/packages/adapter-vite/__fixtures__/request-time/e2e/playwright.config.ts new file mode 100644 index 000000000..fb6a43133 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/request-time/e2e/playwright.config.ts @@ -0,0 +1,58 @@ +/** + * Playwright configuration for the request-time rendering fixture. + * + * Tests run against the built fixture app (packages/adapter-vite/__fixtures__/ + * request-time/dist), served by e2e/server.ts: static files from disk, + * renderIntent 'dynamic' routes delegated to dist/server/index.js. + * + * Prerequisites: + * deno task fixture:request-time:build + * + * Run: deno task fixture:request-time:e2e + */ +import { defineConfig } from '@playwright/test'; +import process from 'node:process'; + +const PORT = Number(process.env.REQUEST_TIME_E2E_PORT ?? 4180); +const baseURL = `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: '.', + testMatch: '*.spec.ts', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 1, + workers: 1, + reporter: 'list', + timeout: 60_000, + + use: { + baseURL, + trace: 'on-first-retry', + }, + + webServer: { + // `exec` prevents the shell Playwright launches from orphaning Deno when + // the suite finishes or is interrupted. + command: + `exec deno run --config ../../../../../deno.json -A server.ts --port ${PORT} --dir ../dist`, + url: baseURL, + reuseExistingServer: false, + timeout: 60_000, + }, + + projects: [ + { + name: 'chromium', + use: { browserName: 'chromium' }, + }, + { + name: 'firefox', + use: { browserName: 'firefox' }, + }, + { + name: 'webkit', + use: { browserName: 'webkit' }, + }, + ], +}); diff --git a/packages/adapter-vite/__fixtures__/request-time/e2e/server.ts b/packages/adapter-vite/__fixtures__/request-time/e2e/server.ts new file mode 100644 index 000000000..914b50fd0 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/request-time/e2e/server.ts @@ -0,0 +1,86 @@ +/** + * Request-time fixture server. + * + * Serves the built app: + * - static files from dist/ (exact file, /path -> /path/index.html, /path.html) + * - request-time routes (from dist/server/server-manifest.json) are delegated + * to the generated dist/server/index.js default export, which takes a + * nitro-like event ({ request }) and returns a Response. + * + * Usage: + * deno run -A server.ts --port 4180 --dir ../dist + */ + +import { extname, join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const args: Record = {}; +for (let i = 0; i < Deno.args.length; i += 2) { + if (Deno.args[i].startsWith('--')) args[Deno.args[i].slice(2)] = Deno.args[i + 1] ?? ''; +} + +const PORT = Number(args.port ?? '4180'); +const ROOT = resolve(Deno.cwd(), args.dir ?? '../dist'); + +const CONTENT_TYPES: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + '.txt': 'text/plain; charset=utf-8', + '.xml': 'application/xml; charset=utf-8', + '.woff2': 'font/woff2', +}; + +const manifest = JSON.parse(await Deno.readTextFile(join(ROOT, 'server/server-manifest.json'))); +const requestTimePaths = new Set( + (manifest.requestTimeRoutes as Array<{ path: string }>).map((r) => r.path), +); + +const serverEntry = await import(pathToFileURL(join(ROOT, 'server/index.js')).href); +const handleRequestTime = serverEntry.default as ( + event: { request: Request }, +) => Promise; + +async function serveStatic(pathname: string): Promise { + const candidates = [pathname]; + if (pathname.endsWith('/')) { + candidates.push(pathname + 'index.html'); + } else { + candidates.push(pathname + '/index.html', pathname + '.html'); + } + for (const candidate of candidates) { + const filePath = join(ROOT, candidate); + if (!resolve(filePath).startsWith(ROOT)) continue; + try { + const stat = await Deno.stat(filePath); + if (!stat.isFile) continue; + const body = await Deno.readFile(filePath); + return new Response(body, { + headers: { 'content-type': CONTENT_TYPES[extname(filePath)] ?? 'application/octet-stream' }, + }); + } catch { + // try next candidate + } + } + return null; +} + +Deno.serve({ port: PORT, hostname: '127.0.0.1' }, async (request) => { + const url = new URL(request.url); + if (requestTimePaths.has(url.pathname)) { + return await handleRequestTime({ request }); + } + const staticResponse = await serveStatic(url.pathname); + if (staticResponse) return staticResponse; + return new Response('Not Found', { status: 404 }); +}); + +console.log(`request-time fixture server -> http://127.0.0.1:${PORT} (root: ${ROOT})`); diff --git a/packages/adapter-vite/__fixtures__/request-time/vite.config.ts b/packages/adapter-vite/__fixtures__/request-time/vite.config.ts new file mode 100644 index 000000000..a5184b5b6 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/request-time/vite.config.ts @@ -0,0 +1,31 @@ +/** + * Minimal openElement app proving request-time rendering (0.42.0-alpha.1): + * routes with renderIntent: { mode: 'dynamic' } are excluded from prerendering + * and served per-request through dist/server/index.js. + * + * No client-script workaround: the generated request-time server entry injects + * the island client entry into request-time HTML itself (framework fix), so + * islands hydrate here exactly like on prerendered pages. + */ +import { openElement } from '@openelement/adapter-vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + base: '/', + esbuild: { + jsx: 'automatic', + jsxImportSource: '@openelement/element', + }, + plugins: [ + ...openElement({ + routesDir: 'app/routes', + islandsDir: 'app/islands', + componentsDir: 'app/components', + // No app shell: the fixture stays minimal and does not pull @openelement/ui. + appShell: false, + html: { + title: 'request-time fixture', + }, + }), + ], +}); diff --git a/packages/adapter-vite/__tests__/ssg-render.test.ts b/packages/adapter-vite/__tests__/ssg-render.test.ts index e5187efeb..4515c93c5 100644 --- a/packages/adapter-vite/__tests__/ssg-render.test.ts +++ b/packages/adapter-vite/__tests__/ssg-render.test.ts @@ -332,3 +332,164 @@ Deno.test('ssgRender - dynamic-route failure in warn mode skips the page and the ]); await Deno.remove(outDir, { recursive: true }).catch(() => {}); }); + +// ─── 0.42.0-alpha.1 (ADR-0120): request-time route partition ────────────── + +Deno.test('ssgRender - request-time routes skip prerender and emit server artifacts', async () => { + const outDir = './dist-test-ssg-render-request-time'; + await Deno.remove(outDir, { recursive: true }).catch(() => {}); + const app = new Hono(); + app.get('/', (c) => c.html('static home')); + app.get('/live', (c) => c.html('request time')); + const bundle = createMockBundle({ + default: app, + routeInfo: [ + { path: '/', tagName: 'index-page', isDynamic: false, paramNames: [] }, + { + path: '/live', + tagName: 'live-page', + isDynamic: false, + paramNames: [], + rendering: 'dynamic', + hasAction: true, + }, + ], + }); + + await ssgRender(bundle, { ...defaultOptions, outDir }); + + // The static route is prerendered; the request-time route is not. + assert(await pathExists(`${outDir}/index.html`), 'static route should be prerendered'); + assert( + !(await pathExists(`${outDir}/live/index.html`)) && !(await pathExists(`${outDir}/live.html`)), + 'request-time route must not be prerendered', + ); + + // Server artifacts land next to the SSR bundle. + const manifest = JSON.parse(await Deno.readTextFile(`${outDir}/server/server-manifest.json`)); + assertEquals(manifest, { + version: 1, + requestTimeRoutes: [{ path: '/live', paramNames: [], hasAction: true }], + }); + const serverEntry = await Deno.readTextFile(`${outDir}/server/index.js`); + assert(serverEntry.includes('createOpenElementNitroHandler')); + assert(serverEntry.includes("from './entry.js'")); + + await Deno.remove(outDir, { recursive: true }).catch(() => {}); +}); + +Deno.test('ssgRender - pages with actions cannot be prerendered (hard rule)', async () => { + const outDir = './dist-test-ssg-render-action-rule'; + await Deno.remove(outDir, { recursive: true }).catch(() => {}); + const bundle = createMockBundle({ + routeInfo: [ + { path: '/', tagName: 'index-page', isDynamic: false, paramNames: [] }, + { + path: '/form', + tagName: 'form-page', + isDynamic: false, + paramNames: [], + hasAction: true, + }, + ], + }); + + await assertRejects( + () => ssgRender(bundle, { ...defaultOptions, outDir }), + Error, + 'Pages with actions cannot be prerendered', + ); + await Deno.remove(outDir, { recursive: true }).catch(() => {}); +}); + +Deno.test('ssgRender - pure-static projects emit no server artifacts', async () => { + const outDir = './dist-test-ssg-render-pure-static'; + await Deno.remove(outDir, { recursive: true }).catch(() => {}); + const bundle = createMockBundle(); + + await ssgRender(bundle, { ...defaultOptions, outDir }); + + assert( + !(await pathExists(`${outDir}/server/server-manifest.json`)), + 'pure-static build must not emit a server manifest', + ); + assert( + !(await pathExists(`${outDir}/server/index.js`)), + 'pure-static build must not emit a server entry', + ); + await Deno.remove(outDir, { recursive: true }).catch(() => {}); +}); + +// ─── 0.42.0-alpha.1 (ADR-0120): generated request-time server entry ─────── + +Deno.test('request-time server entry serves the SSR bundle at request time', async () => { + const { renderRequestTimeServerModule } = await import( + '../src/internal/ssg/ssg-helpers.ts' + ); + const { join } = await import('node:path'); + const { pathToFileURL } = await import('node:url'); + + const dir = await Deno.makeTempDir(); + try { + // A minimal stand-in for the built SSR bundle: one request-time route + // whose output depends on the live request (unlike a prerendered page). + await Deno.writeTextFile( + join(dir, 'entry.js'), + `import { Hono } from 'hono'; +const app = new Hono(); +app.get('/live', (c) => c.html('

live ' + new URL(c.req.url).searchParams.get('x') + '

')); +export default app; +`, + ); + await Deno.writeTextFile(join(dir, 'index.js'), renderRequestTimeServerModule()); + await Deno.writeTextFile(join(dir, 'client-script.js'), `export const clientScriptSrc = '';\n`); + + const mod = await import(pathToFileURL(join(dir, 'index.js')).href) as { + default: (event: { request: Request }) => Promise; + }; + const response = await mod.default({ request: new Request('http://localhost/live?x=42') }); + assertEquals(response.status, 200); + const html = await response.text(); + assert(html.includes('live 42')); + assert(!html.includes('type="module"'), 'no client script when none was recorded'); + } finally { + await Deno.remove(dir, { recursive: true }).catch(() => {}); + } +}); + +Deno.test('request-time server entry injects the island client script into HTML responses', async () => { + const { renderRequestTimeServerModule } = await import( + '../src/internal/ssg/ssg-helpers.ts' + ); + const { join } = await import('node:path'); + const { pathToFileURL } = await import('node:url'); + + const dir = await Deno.makeTempDir(); + try { + await Deno.writeTextFile( + join(dir, 'entry.js'), + `import { Hono } from 'hono'; +const app = new Hono(); +app.get('/live', (c) => c.html('

live

')); +export default app; +`, + ); + await Deno.writeTextFile(join(dir, 'index.js'), renderRequestTimeServerModule()); + await Deno.writeTextFile( + join(dir, 'client-script.js'), + `export const clientScriptSrc = '/client/entry-abc123.js';\n`, + ); + + const mod = await import(pathToFileURL(join(dir, 'index.js')).href + '?with-script') as { + default: (event: { request: Request }) => Promise; + }; + const response = await mod.default({ request: new Request('http://localhost/live') }); + const html = await response.text(); + assert( + html.includes(''), + 'request-time HTML must carry the island client script like static pages', + ); + } finally { + await Deno.remove(dir, { recursive: true }).catch(() => {}); + } +}); diff --git a/packages/adapter-vite/deno.json b/packages/adapter-vite/deno.json index 3c72be6b8..d908f7568 100644 --- a/packages/adapter-vite/deno.json +++ b/packages/adapter-vite/deno.json @@ -1,6 +1,6 @@ { "name": "@openelement/adapter-vite", - "version": "0.41.2", + "version": "0.42.0-alpha.1", "exports": { ".": "./src/index.ts", "./nitro-mount": "./src/nitro-mount.ts", diff --git a/packages/adapter-vite/src/build-plan.ts b/packages/adapter-vite/src/build-plan.ts index 80cf7b34e..0f3658860 100644 --- a/packages/adapter-vite/src/build-plan.ts +++ b/packages/adapter-vite/src/build-plan.ts @@ -71,6 +71,25 @@ function files(root: string): string[] { return result; } +/** + * Request-time route evidence for the build manifest, derived from the + * emitted dist/server/server-manifest.json (0.42.0-alpha.1 / ADR-0120). + * Returns {} for pure-static builds so their evidence shape is unchanged. + */ +function readRequestTimeRouteEvidence(outputDir: string): { requestTimeRoutes?: string[] } { + try { + const manifest = JSON.parse( + readFileSync(join(outputDir, 'server', 'server-manifest.json'), 'utf8'), + ) as { requestTimeRoutes?: Array<{ path?: unknown }> }; + const paths = (manifest.requestTimeRoutes ?? []) + .map((route) => route.path) + .filter((path): path is string => typeof path === 'string'); + return paths.length > 0 ? { requestTimeRoutes: paths } : {}; + } catch { + return {}; + } +} + export function collectBuildArtifacts(plan: BuildPlan): BuildArtifacts { const root = plan.output.root ?? (typeof Deno !== 'undefined' ? Deno.cwd() : process.cwd()); const outputDir = join(root, plan.output.outDir ?? DEFAULT_OUT_DIR); @@ -97,6 +116,7 @@ export function collectBuildArtifacts(plan: BuildPlan): BuildArtifacts { isDynamic: (route.paramNames?.length ?? 0) > 0, })), islands: plan.islands, + ...readRequestTimeRouteEvidence(outputDir), }, clientAssets, warnings: [], diff --git a/packages/adapter-vite/src/build.ts b/packages/adapter-vite/src/build.ts index f019cd6d8..55e88e949 100644 --- a/packages/adapter-vite/src/build.ts +++ b/packages/adapter-vite/src/build.ts @@ -55,6 +55,29 @@ export async function readClientEntryFromManifest(manifestPath: string): Promise throw new Error(`Client manifest exists but no open-client-entry was found: ${manifestPath}`); } +/** + * Write the island client entry URL for the request-time server entry + * (0.42.0-alpha.1 / ADR-0120). dist/server/index.js imports this module to + * inject the same island client script into request-time HTML that the + * static pipeline injects post-build. No-op for pure-static builds (no + * request-time server entry was emitted). + */ +export async function writeRequestTimeClientScript( + ctx: OpenElementBuildContext, + scriptSrc: string, +): Promise { + const root = ctx.phase3.root || process.cwd(); + const outDir = ctx.phase3.outDir || DEFAULT_OUT_DIR; + const serverIndex = join(root, outDir, 'server', 'index.js'); + const { existsSync, writeFileSync } = await import('node:fs'); + if (!existsSync(serverIndex)) return; + writeFileSync( + join(root, outDir, 'server', 'client-script.js'), + `export const clientScriptSrc = ${JSON.stringify(scriptSrc)};\n`, + ); + log.info(`Request-time client script recorded: ${scriptSrc}`); +} + /** Vite plugin: writes build metadata to ctx, then runs Phase 2 + Phase 3 */ export function buildPlugin( options: FrameworkOptions & { allowHeadExtrasScripts?: boolean; ssg?: SsgBehaviorOptions } = {}, @@ -209,6 +232,7 @@ export function buildPlugin( const base = ctx.phase3.base || '/'; const scriptSrc = `${base}client/${clientEntry}`; await postProcessClientIslandBuild(ctx, scriptSrc); + await writeRequestTimeClientScript(ctx, scriptSrc); log.info(`Client script injected: ${scriptSrc}`); } } catch (error) { diff --git a/packages/adapter-vite/src/internal/protocol/ssg.ts b/packages/adapter-vite/src/internal/protocol/ssg.ts index c5eacbc92..89ed9ecaf 100644 --- a/packages/adapter-vite/src/internal/protocol/ssg.ts +++ b/packages/adapter-vite/src/internal/protocol/ssg.ts @@ -270,6 +270,8 @@ export interface RouteInfoEntry { revalidate?: number | false; /** Rendering mode declared via renderIntent.mode ("auto" when unset). */ rendering?: string; + /** True when the route module exports an action (request-time form POST). */ + hasAction?: boolean; params?: Record; } @@ -425,6 +427,11 @@ export interface BuildPageArtifact { export interface BuildManifestArtifact { routes: Array<{ kind: 'page' | 'api'; path: string; tagName?: string; isDynamic: boolean }>; islands: BuildIslandInput[]; + /** + * Paths served at request time (renderIntent mode 'dynamic', 0.42.0-alpha.1 + * / ADR-0120). Absent for pure-static builds. + */ + requestTimeRoutes?: string[]; } /** A single client asset emitted by the build pipeline. */ diff --git a/packages/adapter-vite/src/internal/ssg/entry-render-ssg.ts b/packages/adapter-vite/src/internal/ssg/entry-render-ssg.ts index ab7ed83de..f1edaf2fa 100644 --- a/packages/adapter-vite/src/internal/ssg/entry-render-ssg.ts +++ b/packages/adapter-vite/src/internal/ssg/entry-render-ssg.ts @@ -69,7 +69,7 @@ export function renderSsgSection(desc: EntryDescriptor): string { (r.paramNames || []).map(jsStringLiteral).join(', ') }], revalidate: ${ routeRevalidateExpr(r.varName) - }, rendering: (__pageDefinition(${r.varName}).renderIntent?.mode || "auto") },`, + }, rendering: (__pageDefinition(${r.varName}).renderIntent?.mode || "auto"), hasAction: (typeof ${r.varName}.action === "function") },`, ); } lines.push('];'); diff --git a/packages/adapter-vite/src/internal/ssg/entry-renderer.ts b/packages/adapter-vite/src/internal/ssg/entry-renderer.ts index 67ec88772..494cceff8 100644 --- a/packages/adapter-vite/src/internal/ssg/entry-renderer.ts +++ b/packages/adapter-vite/src/internal/ssg/entry-renderer.ts @@ -181,7 +181,11 @@ export function renderEntry(desc: EntryDescriptor): string { lines.push(''); // --- SSG: headExtras via define injection --- - if (desc.isSSG && desc.document.headExtras) { + // Always emitted in SSG mode: renderRouteHandler references __headExtras + // unconditionally, so a project without headExtras would otherwise render + // every static page into a 500 (latent until the request-time fixture hit + // it in 0.42.0-alpha.1). + if (desc.isSSG) { lines.push( '// SSG: headExtras injected via Vite define (ADR 0008 Phase A)', ); diff --git a/packages/adapter-vite/src/internal/ssg/ssg-helpers.ts b/packages/adapter-vite/src/internal/ssg/ssg-helpers.ts index 900282716..710cac464 100644 --- a/packages/adapter-vite/src/internal/ssg/ssg-helpers.ts +++ b/packages/adapter-vite/src/internal/ssg/ssg-helpers.ts @@ -104,3 +104,53 @@ export function buildIsrManifestEntries( } return entries; } + +// ─── Request-time server entry module (0.42.0-alpha.1, ADR-0120) ────────── + +/** + * Source of the generated `dist/server/index.js`. Emitted only when at + * least one route declares `renderIntent: { mode: 'dynamic' }`, so + * pure-static output trees stay byte-identical. The module mounts the + * prerendering SSR bundle (the same Hono app, with loaders/actions) on the + * public `nitro-mount` seam; Nitro Node/Workers builds bundle it as the + * server entry, and plain Node can run it directly with adapter-vite + * installed. + */ +export function renderRequestTimeServerModule(): string { + return `// Generated by openElement build — request-time server entry (0.42.0-alpha.1). +// Serves renderIntent: { mode: 'dynamic' } routes at request time through the +// same SSR bundle used for prerendering. Do not edit; regenerated per build. +import { createOpenElementNitroHandler } from '@openelement/adapter-vite/nitro-mount'; +import app from './entry.js'; +import { clientScriptSrc } from './client-script.js'; + +const openElementHandler = createOpenElementNitroHandler({ + handler: (request, context) => + app.fetch(request, context?.env || {}, context?.platform), +}); + +// Island hydration parity with static pages: the static pipeline injects the +// island client entry into prerendered HTML as a post-build step, which +// request-time rendering bypasses. Inject the same script at serve time. +function withClientScript(response) { + if (!clientScriptSrc) return response; + const type = response.headers.get('content-type') || ''; + if (!type.includes('text/html')) return Promise.resolve(response); + return response.text().then((html) => { + if (html.includes(clientScriptSrc)) { + return new Response(html, { status: response.status, headers: response.headers }); + } + const tag = ''; + const out = html.includes('') + ? html.replace('', ' ' + tag + '\\n') + : html + tag; + return new Response(out, { status: response.status, headers: response.headers }); + }); +} + +export default async function openElementRequestTimeServer(event) { + const result = await openElementHandler(event ?? {}); + return withClientScript(result.response); +} +`; +} diff --git a/packages/adapter-vite/src/internal/ssg/ssg-render.ts b/packages/adapter-vite/src/internal/ssg/ssg-render.ts index 5d85cd759..ba6ff4186 100644 --- a/packages/adapter-vite/src/internal/ssg/ssg-render.ts +++ b/packages/adapter-vite/src/internal/ssg/ssg-render.ts @@ -26,7 +26,11 @@ import type { } from '../protocol/ssg.ts'; import { createLogger } from '@openelement/element'; import { expandDynamicRoutes, expandI18nLocales } from './ssg-dynamic.ts'; -import { buildIsrManifestEntries, findHtmlFiles } from './ssg-helpers.ts'; +import { + buildIsrManifestEntries, + findHtmlFiles, + renderRequestTimeServerModule, +} from './ssg-helpers.ts'; import { formatJson } from '@openelement/element/build-utils'; import { DEFAULT_OUT_DIR } from './../paths.ts'; @@ -73,7 +77,25 @@ export async function ssgRender( ); } - const dynamicRoutes = routeInfo.filter((r) => r.isDynamic); + // ── Request-time route partition (0.42.0-alpha.1, ADR-0120) ── + // renderIntent.mode was inert metadata before this line: 'dynamic' routes + // are no longer prerendered — they are served at request time by the + // generated server entry and recorded in server-manifest.json. + const requestTimeRoutes = routeInfo.filter((r) => r.rendering === 'dynamic'); + const prerenderViolations = routeInfo.filter((r) => + r.hasAction === true && r.rendering !== 'dynamic' + ); + if (prerenderViolations.length > 0) { + throw new Error( + '[openElement] Pages with actions cannot be prerendered (ADR-0120): ' + + prerenderViolations.map((r) => r.path).join(', ') + + `. Set renderIntent: { mode: 'dynamic' } on ${ + prerenderViolations.length === 1 ? 'this route' : 'these routes' + }.`, + ); + } + + const dynamicRoutes = routeInfo.filter((r) => r.isDynamic && r.rendering !== 'dynamic'); log.info( `Routes: ${routeInfo.length} total` + (dynamicRoutes.length > 0 @@ -139,10 +161,54 @@ export async function ssgRender( }, }; - const result = await toSSG(recordingApp as never, fsModule, { dir: outputDir }); + // Request-time routes are excluded from prerendering: hono/ssg skips a + // route when the beforeRequestHook returns false. + const requestTimePaths = new Set(requestTimeRoutes.map((r) => r.path)); + const result = await toSSG(recordingApp as never, fsModule, { + dir: outputDir, + beforeRequestHook: (request: Request) => { + const path = new URL(request.url).pathname; + return requestTimePaths.has(path) ? false : request; + }, + }); if (!result.success) throw result.error; + // Emit the request-time server artifacts only when such routes exist, so a + // pure-static project's output tree is unchanged (freeze regression rule). + if (requestTimeRoutes.length > 0) { + const serverDir = join(outputDir, 'server'); + mkdirSync(serverDir, { recursive: true }); + const serverManifest = { + version: 1, + requestTimeRoutes: requestTimeRoutes.map((r) => ({ + path: r.path, + filePath: r.filePath, + paramNames: r.paramNames, + hasAction: r.hasAction === true, + })), + }; + writeFileSync( + join(serverDir, 'server-manifest.json'), + formatJson(serverManifest), + 'utf-8', + ); + writeFileSync(join(serverDir, 'index.js'), renderRequestTimeServerModule(), 'utf-8'); + // Placeholder: Phase 2 overwrites this with the real island client entry + // URL when the project has islands (build.ts writeRequestTimeClientScript). + writeFileSync( + join(serverDir, 'client-script.js'), + `export const clientScriptSrc = '';\n`, + 'utf-8', + ); + log.info( + `Request-time server -> ${join(serverDir, 'index.js')} ` + + `(${requestTimeRoutes.length} route(s): ${ + requestTimeRoutes.map((r) => r.path).join(', ') + })`, + ); + } + if (staticNon200.length > 0) { log.warn( `Static route non-200 results: ${staticNon200.length} page(s) dropped (not written):`, @@ -202,10 +268,12 @@ export async function ssgRender( log.info(`Static site generated -> ${outputDir}`); // ── i18n locale expansion (if ctx available) ──────────────── + // Request-time routes render per request in every locale; they are not + // prerendered per locale either. await expandI18nLocales( evidence, renderRoute, - routeInfo, + routeInfo.filter((r) => r.rendering !== 'dynamic'), getStaticPaths, options, root, diff --git a/packages/app/deno.json b/packages/app/deno.json index a7eb086b4..b1bd8f7c4 100644 --- a/packages/app/deno.json +++ b/packages/app/deno.json @@ -1,6 +1,6 @@ { "name": "@openelement/app", - "version": "0.41.2", + "version": "0.42.0-alpha.1", "exports": { ".": "./src/index.ts", "./hono": "./src/hono.ts", diff --git a/packages/create/deno.json b/packages/create/deno.json index e51eb1a16..e273e6cf9 100644 --- a/packages/create/deno.json +++ b/packages/create/deno.json @@ -1,6 +1,6 @@ { "name": "@openelement/create", - "version": "0.41.2", + "version": "0.42.0-alpha.1", "exports": "./src/cli.ts", "imports": {}, "tasks": { diff --git a/packages/create/src/version.ts b/packages/create/src/version.ts index e1e906867..1f24da96d 100644 --- a/packages/create/src/version.ts +++ b/packages/create/src/version.ts @@ -1,2 +1,2 @@ /** The published CLI version, embedded so packed npm installs are self-contained. */ -export const CREATE_VERSION = '0.41.2'; +export const CREATE_VERSION = '0.42.0-alpha.1'; diff --git a/packages/element/deno.json b/packages/element/deno.json index 22c538a20..184426970 100644 --- a/packages/element/deno.json +++ b/packages/element/deno.json @@ -1,6 +1,6 @@ { "name": "@openelement/element", - "version": "0.41.2", + "version": "0.42.0-alpha.1", "exports": { ".": "./src/index.ts", "./jsx-runtime": "./src/jsx-runtime.ts", diff --git a/packages/ui/deno.json b/packages/ui/deno.json index 55ca8537d..2337138c1 100644 --- a/packages/ui/deno.json +++ b/packages/ui/deno.json @@ -1,6 +1,6 @@ { "name": "@openelement/ui", - "version": "0.41.2", + "version": "0.42.0-alpha.1", "exports": { ".": "./src/index.ts", "./open-button": "./src/open-button.tsx", diff --git a/packages/ui/src/generated-manifest.json b/packages/ui/src/generated-manifest.json index b7ebf91be..2844ff23c 100644 --- a/packages/ui/src/generated-manifest.json +++ b/packages/ui/src/generated-manifest.json @@ -1,7 +1,7 @@ { "schemaVersion": "1.0.0", "packageName": "@openelement/ui", - "version": "0.41.2", + "version": "0.42.0-alpha.1", "description": "Open Props Web Component library for openElement", "author": "openElement", "license": "MIT", diff --git a/tools/autoflow/__tests__/policy.test.ts b/tools/autoflow/__tests__/policy.test.ts index 0fbdf0d60..3353ba633 100644 --- a/tools/autoflow/__tests__/policy.test.ts +++ b/tools/autoflow/__tests__/policy.test.ts @@ -10,6 +10,7 @@ import { releaseTag, resolvePatchTargetVersion, roadmapEntryTheme, + supersededThemeForBump, } from '../release.ts'; Deno.test('policy: patch docs fix can be automated', () => { @@ -245,6 +246,26 @@ Deno.test('release: previous release theme bump records the superseded theme ide assertEquals(bumpPreviousReleaseThemeText(constants, 'old theme'), undefined); }); +Deno.test('release: superseded theme is only recorded on a real version change', () => { + const text = + " {\n version: 'v0.42.0-alpha.1',\n theme: 'request-time rendering foundation',\n"; + // Real bump: v0.41.2 -> v0.42.0-alpha.1 records the old entry's theme. + const bumpedText = text.replace('v0.42.0-alpha.1', 'v0.41.2').replace( + 'request-time rendering foundation', + 'release tooling self-repair', + ); + assertEquals( + supersededThemeForBump(bumpedText, "version: 'v0.41.2'", "version: 'v0.42.0-alpha.1'"), + 'release tooling self-repair', + ); + // Idempotent resume (from === to): nothing is superseded — recording the + // new theme here made the 0.42.0-alpha.1 gate reject correct prose. + assertEquals( + supersededThemeForBump(text, "version: 'v0.42.0-alpha.1'", "version: 'v0.42.0-alpha.1'"), + undefined, + ); +}); + Deno.test('release: local plan includes publish, smoke, gates, and GitHub release when credentials are present', () => { // Simulate a local/manual environment that has the credentials required for // npm publish and GitHub release creation. Force CI off so the plan follows diff --git a/tools/autoflow/release.ts b/tools/autoflow/release.ts index cf4a0ff2f..27fa189ad 100644 --- a/tools/autoflow/release.ts +++ b/tools/autoflow/release.ts @@ -685,6 +685,24 @@ export function roadmapEntryTheme(text: string, versionTag: string): string | un return text.match(new RegExp(`version:\\s*'${escaped}',\\s*theme:\\s*'([^']+)'`, 'u'))?.[1]; } +/** + * The theme a roadmap version-anchor bump supersedes, if any. Only a real + * version change (from tag ≠ to tag) supersedes a theme: an idempotent + * re-run after the bump finds from === to, and re-recording the + * already-written new theme would make the line-prose gate reject correct + * prose (the 0.42.0-alpha.1 resume loop). + */ +export function supersededThemeForBump( + text: string, + from: string, + to: string, +): string | undefined { + const oldTag = from.match(/version: '([^']+)'/u)?.[1]; + const newTag = to.match(/version: '([^']+)'/u)?.[1]; + if (!oldTag || oldTag === newTag) return undefined; + return roadmapEntryTheme(text, oldTag); +} + /** * Record the superseded current-line theme into the project-constants * source text. Returns `undefined` when already recorded (idempotent). @@ -824,8 +842,7 @@ export async function updateCurrentVersionAnchors(version: string): Promise { // `0.41.0-alpha.1` (stale) is a prefix of a current prerelease like // `0.41.0-alpha.17`; the numeric boundary must keep the current line from - // failing. On a stable current line there is no such prefix — the current - // version simply never matches a stale claim either way. - if (PACKAGE_VERSION.includes('-')) { - assert(stalePackageVersionClaims().some((claim) => PACKAGE_VERSION.startsWith(claim))); - } else { - assert(!stalePackageVersionClaims().some((claim) => PACKAGE_VERSION.startsWith(claim))); - } + // failing. That prefix relation holds only for same-base bumps: a new-line + // prerelease whose previous line is a different-base stable (0.41.2 → + // 0.42.0-alpha.1) has no stale prefix at all, and a stable current line + // never has one either. + const hasStalePrefix = stalePackageVersionClaims().some((claim) => + PACKAGE_VERSION.startsWith(claim) + ); + const sameBasePrereleaseBump = PACKAGE_VERSION.includes('-') && + PREVIOUS_PACKAGE_VERSION.includes('-') && + PACKAGE_VERSION.split('-')[0] === PREVIOUS_PACKAGE_VERSION.split('-')[0]; + assertEquals(hasStalePrefix, sameBasePrereleaseBump); assertEquals(findStaleAnchorFailures(readerFrom(goodFiles())), []); }); diff --git a/tools/check-www-current-truth.ts b/tools/check-www-current-truth.ts index d2b226dbc..d7fbc99fb 100644 --- a/tools/check-www-current-truth.ts +++ b/tools/check-www-current-truth.ts @@ -2,7 +2,6 @@ import { exists, walk } from './lib/fs.ts'; import { PACKAGE_VERSION, PACKAGE_VERSION_TAG, - PREVIOUS_PACKAGE_VERSION, PREVIOUS_RELEASE_THEME, } from './project-constants.ts'; import { roadmapEntryTheme } from './autoflow/release.ts'; @@ -14,33 +13,21 @@ const sourceRoots = [ 'www/app/site-ui', ]; -const [versionBase, prerelease = ''] = PACKAGE_VERSION.split('-'); -// After a stable bump the current version carries no prerelease suffix, but -// the previous prerelease line is exactly what must stay retired: build the -// pattern from PREVIOUS_PACKAGE_VERSION in that case. -const [retiredBase, retiredLine = ''] = prerelease - ? [versionBase, prerelease] - : PREVIOUS_PACKAGE_VERSION.split('-'); -const prereleaseMatch = retiredLine.match(/^(alpha|beta|rc)\.(\d+)$/u); -// On a stable line where the previous version is also stable (e.g. 0.41.1 -// after 0.41.0), every prerelease of the same base version is retired. -const stableLineAlphaPattern = !prereleaseMatch - ? new RegExp(`(?:v?${versionBase.replaceAll('.', '\\.')}-alpha|\\balpha)\\.\\d+(?!\\d)`, 'iu') - : null; -// Retired prerelease claims are flagged with or without the `v` prefix and in -// the short `alpha.N` form; only numbers below the current line match, so the -// current version itself never trips the gate. -const earlierPrereleasePattern = prereleaseMatch && Number(prereleaseMatch[2]) > 0 - ? new RegExp( - `(?:v?${retiredBase.replaceAll('.', '\\.')}-${prereleaseMatch[1]}|\\b${ - prereleaseMatch[1] - })\\.(?:${ - Array.from({ length: Number(prereleaseMatch[2]) }, (_, index) => index).join('|') - })(?!\\d)`, - 'iu', - ) - : null; -const activeRetiredPattern = earlierPrereleasePattern ?? stableLineAlphaPattern; +// Retired prerelease forms: any full-form prerelease version that is not the +// current one (covers same-base earlier alphas and, after a new-line bump +// like 0.41.2 → 0.42.0-alpha.1, the entire retired previous alpha line), +// plus the short `alpha.N` form for any N other than the current alpha +// number. History surfaces (migration guide, CHANGELOG) are exempted by the +// caller. Beta/rc full forms retire too; the abandoned beta naming must +// never reappear as a current claim. +const escapedCurrentVersion = PACKAGE_VERSION.replaceAll('.', '\\.'); +const currentAlphaNumber = PACKAGE_VERSION.match(/-alpha\.(\d+)$/u)?.[1]; +const retiredFullForm = + `(?!v?${escapedCurrentVersion}(?!\\d))v?\\d+\\.\\d+\\.\\d+-(?:alpha|beta|rc)\\.\\d+(?!\\d)`; +const retiredShortForm = currentAlphaNumber + ? `\\balpha\\.(?!${currentAlphaNumber}(?!\\d))\\d+(?!\\d)` + : `\\balpha\\.\\d+(?!\\d)`; +const activeRetiredPattern = new RegExp(`(?:${retiredFullForm}|${retiredShortForm})`, 'iu'); const forbidden: Array<{ name: string; re: RegExp }> = [ { name: 'mojibake', re: /(?:鏂|鈫|鍗|杩|鏈)/ }, diff --git a/tools/project-constants.ts b/tools/project-constants.ts index d69f88d60..eb167d6b0 100644 --- a/tools/project-constants.ts +++ b/tools/project-constants.ts @@ -1,6 +1,6 @@ -export const PACKAGE_VERSION = '0.41.2'; +export const PACKAGE_VERSION = '0.42.0-alpha.1'; export const PACKAGE_VERSION_TAG = `v${PACKAGE_VERSION}`; -export const ACTIVE_EXECUTION_VERSION = 'v0.41.2'; +export const ACTIVE_EXECUTION_VERSION = 'v0.42.0-alpha.1'; export const RETAINED_PACKAGE_NAMES = Object.freeze([ '@openelement/adapter-vite', '@openelement/app', @@ -49,7 +49,7 @@ export const NITRO_COMPATIBILITY_DATE = '2026-06-12'; // single source of truth for the "from" side of version-anchor replacements // (see buildVersionAnchorReplacements in tools/autoflow/release.ts). It is // kept in sync automatically by updateProjectConstants() during a bump. -export const PREVIOUS_PACKAGE_VERSION = '0.41.1'; +export const PREVIOUS_PACKAGE_VERSION = '0.41.2'; export const PREVIOUS_PACKAGE_VERSION_TAG = `v${PREVIOUS_PACKAGE_VERSION}`; // The theme the www roadmap current-line timeline entry carried immediately @@ -60,7 +60,7 @@ export const PREVIOUS_PACKAGE_VERSION_TAG = `v${PREVIOUS_PACKAGE_VERSION}`; // sweep' describing alpha.19 under the v0.41.1 entry). The bump side // re-records this constant from the pre-bump entry; bootstrap value // documents the incident. -export const PREVIOUS_RELEASE_THEME = 'stable five-package line'; +export const PREVIOUS_RELEASE_THEME = 'release tooling self-repair'; /** * Version strings that must never reappear in the head anchor zone of the diff --git a/www/app/data/version.ts b/www/app/data/version.ts index 95a98f21b..1065672a7 100644 --- a/www/app/data/version.ts +++ b/www/app/data/version.ts @@ -1,2 +1,2 @@ // Current published package line. Release tooling owns the next bump. -export const OPENELEMENT_VERSION = 'v0.41.2'; +export const OPENELEMENT_VERSION = 'v0.42.0-alpha.1'; diff --git a/www/app/routes/guide/deployment.tsx b/www/app/routes/guide/deployment.tsx index 13c4eb377..ec38f7a15 100644 --- a/www/app/routes/guide/deployment.tsx +++ b/www/app/routes/guide/deployment.tsx @@ -24,6 +24,7 @@ const content: Record<'en' | 'zh', GuideContent> = { 'Deployment is built around generated static output and adapter-specific runtime boundaries.', outline: [ { id: 'static-output', label: 'Static output', level: 3 }, + { id: 'request-time-server', label: 'Request-time server', level: 3 }, { id: 'adapters', label: 'Adapters', level: 3 }, { id: 'verification', label: 'Verification', level: 3 }, ], @@ -35,6 +36,12 @@ const content: Record<'en' | 'zh', GuideContent> = { title: 'Static output', body: 'The docs site is generated through the SSG pipeline.', }, + { + id: 'request-time-server', + title: 'Request-time server', + body: + "When any route declares renderIntent: { mode: 'dynamic' }, the build also emits dist/server/index.js — a Nitro-mountable handler over the same SSR bundle — plus server-manifest.json listing the request-time routes. Pure-static builds emit neither (0.42 line, unfrozen).", + }, { id: 'adapters', title: 'Adapters', @@ -53,6 +60,7 @@ const content: Record<'en' | 'zh', GuideContent> = { lede: '部署围绕生成的静态输出与 adapter 各自的运行时边界展开。', outline: [ { id: 'static-output', label: '静态输出', level: 3 }, + { id: 'request-time-server', label: '请求时服务器', level: 3 }, { id: 'adapters', label: 'Adapters', level: 3 }, { id: 'verification', label: '验证', level: 3 }, ], @@ -64,6 +72,12 @@ const content: Record<'en' | 'zh', GuideContent> = { title: '静态输出', body: 'docs 站点通过 SSG 管线生成。', }, + { + id: 'request-time-server', + title: '请求时服务器', + body: + "当任何路由声明 renderIntent: { mode: 'dynamic' } 时,构建还会产出 dist/server/index.js——挂在同一个 SSR bundle 上、可由 Nitro 挂载的处理器——以及列出请求时路由的 server-manifest.json。纯静态构建两者都不产出(0.42 版本线,未冻结)。", + }, { id: 'adapters', title: 'Adapters', diff --git a/www/app/routes/guide/routing-and-data.tsx b/www/app/routes/guide/routing-and-data.tsx index eb2b569ec..6fa1375ce 100644 --- a/www/app/routes/guide/routing-and-data.tsx +++ b/www/app/routes/guide/routing-and-data.tsx @@ -25,6 +25,7 @@ const content: Record<'en' | 'zh', GuideContent> = { { id: 'file-routes', label: 'File routes', level: 3 }, { id: 'metadata', label: 'Metadata', level: 3 }, { id: 'data-boundary', label: 'Data boundary', level: 3 }, + { id: 'rendering-modes', label: 'Rendering modes', level: 3 }, ], previous: { href: '/guide/comparison', label: 'Comparison' }, next: { href: '/guide/mdx', label: 'MDX' }, @@ -44,6 +45,12 @@ const content: Record<'en' | 'zh', GuideContent> = { title: 'Data boundary', body: 'Keep data loading separate from presentation markup.', }, + { + id: 'rendering-modes', + title: 'Rendering modes', + body: + "renderIntent.mode selects where a page renders: 'auto' (default) and 'static' prerender at build; 'dynamic' skips prerendering and renders per request through the generated dist/server entry, running the route loader on every request. Pages that export an action must declare 'dynamic' — the build rejects prerendered action pages (0.42 line, unfrozen).", + }, ], }, zh: { @@ -54,6 +61,7 @@ const content: Record<'en' | 'zh', GuideContent> = { { id: 'file-routes', label: '文件路由', level: 3 }, { id: 'metadata', label: '元数据', level: 3 }, { id: 'data-boundary', label: '数据边界', level: 3 }, + { id: 'rendering-modes', label: '渲染模式', level: 3 }, ], previous: { href: '/guide/comparison', label: '对比' }, next: { href: '/guide/mdx', label: 'MDX' }, @@ -73,6 +81,12 @@ const content: Record<'en' | 'zh', GuideContent> = { title: '数据边界', body: '数据加载与展示标记保持分离。', }, + { + id: 'rendering-modes', + title: '渲染模式', + body: + "renderIntent.mode 决定页面在哪里渲染:'auto'(默认)与 'static' 在构建时预渲染;'dynamic' 跳过预渲染,通过生成的 dist/server 入口按请求渲染,每次请求都会运行路由 loader。导出 action 的页面必须声明 'dynamic'——构建会拒绝预渲染的 action 页面(0.42 版本线,未冻结)。", + }, ], }, }; diff --git a/www/app/routes/roadmap.tsx b/www/app/routes/roadmap.tsx index ef694e7a7..66c494a70 100644 --- a/www/app/routes/roadmap.tsx +++ b/www/app/routes/roadmap.tsx @@ -332,10 +332,10 @@ type TimelineEntry = { const entries: TimelineEntry[] = [ { - version: 'v0.41.2', - theme: 'release tooling self-repair', + version: 'v0.42.0-alpha.1', + theme: 'request-time rendering foundation', copy: - 'TP-0 of the 0.42.0 plan: patch-release resume re-derives its target from evidence, and a line-prose gate keeps release themes honest.', + "renderIntent mode 'dynamic' skips prerendering; a generated nitro-mount server serves those routes per request with loaders, and the build rejects prerendered action pages (ADR-0120).", state: 'stable', stamp: 'CURRENT', }, @@ -420,7 +420,7 @@ export class RoadmapPage extends OpenElement { {entries.map((phase) => { // The current-line stamp follows the bump-maintained anchor so a // release bump re-marks the timeline without manual edits. - const stamp = phase.version === 'v0.41.2' ? 'CURRENT' : phase.stamp; + const stamp = phase.version === 'v0.42.0-alpha.1' ? 'CURRENT' : phase.stamp; return (
diff --git a/www/e2e/visual-baselines.spec.ts-snapshots/en-dark-desktop-home-chromium-canonical.png b/www/e2e/visual-baselines.spec.ts-snapshots/en-dark-desktop-home-chromium-canonical.png index 781f21c2f..e447e6440 100644 Binary files a/www/e2e/visual-baselines.spec.ts-snapshots/en-dark-desktop-home-chromium-canonical.png and b/www/e2e/visual-baselines.spec.ts-snapshots/en-dark-desktop-home-chromium-canonical.png differ diff --git a/www/e2e/visual-baselines.spec.ts-snapshots/en-dark-mobile-apilist-chromium-canonical.png b/www/e2e/visual-baselines.spec.ts-snapshots/en-dark-mobile-apilist-chromium-canonical.png index f89445c81..2e540d903 100644 Binary files a/www/e2e/visual-baselines.spec.ts-snapshots/en-dark-mobile-apilist-chromium-canonical.png and b/www/e2e/visual-baselines.spec.ts-snapshots/en-dark-mobile-apilist-chromium-canonical.png differ diff --git a/www/e2e/visual-baselines.spec.ts-snapshots/en-light-desktop-home-chromium-canonical.png b/www/e2e/visual-baselines.spec.ts-snapshots/en-light-desktop-home-chromium-canonical.png index 14006e52c..82b7b02bd 100644 Binary files a/www/e2e/visual-baselines.spec.ts-snapshots/en-light-desktop-home-chromium-canonical.png and b/www/e2e/visual-baselines.spec.ts-snapshots/en-light-desktop-home-chromium-canonical.png differ diff --git a/www/e2e/visual-baselines.spec.ts-snapshots/en-light-mobile-apilist-chromium-canonical.png b/www/e2e/visual-baselines.spec.ts-snapshots/en-light-mobile-apilist-chromium-canonical.png index 5f4adcda9..ccfea0504 100644 Binary files a/www/e2e/visual-baselines.spec.ts-snapshots/en-light-mobile-apilist-chromium-canonical.png and b/www/e2e/visual-baselines.spec.ts-snapshots/en-light-mobile-apilist-chromium-canonical.png differ diff --git a/www/e2e/visual-baselines.spec.ts-snapshots/zh-dark-desktop-home-chromium-canonical.png b/www/e2e/visual-baselines.spec.ts-snapshots/zh-dark-desktop-home-chromium-canonical.png index 0781e5a5e..2a418c64a 100644 Binary files a/www/e2e/visual-baselines.spec.ts-snapshots/zh-dark-desktop-home-chromium-canonical.png and b/www/e2e/visual-baselines.spec.ts-snapshots/zh-dark-desktop-home-chromium-canonical.png differ diff --git a/www/e2e/visual-baselines.spec.ts-snapshots/zh-dark-mobile-apilist-chromium-canonical.png b/www/e2e/visual-baselines.spec.ts-snapshots/zh-dark-mobile-apilist-chromium-canonical.png index f89445c81..2e540d903 100644 Binary files a/www/e2e/visual-baselines.spec.ts-snapshots/zh-dark-mobile-apilist-chromium-canonical.png and b/www/e2e/visual-baselines.spec.ts-snapshots/zh-dark-mobile-apilist-chromium-canonical.png differ diff --git a/www/e2e/visual-baselines.spec.ts-snapshots/zh-light-desktop-home-chromium-canonical.png b/www/e2e/visual-baselines.spec.ts-snapshots/zh-light-desktop-home-chromium-canonical.png index bec463980..363c85ee1 100644 Binary files a/www/e2e/visual-baselines.spec.ts-snapshots/zh-light-desktop-home-chromium-canonical.png and b/www/e2e/visual-baselines.spec.ts-snapshots/zh-light-desktop-home-chromium-canonical.png differ diff --git a/www/e2e/visual-baselines.spec.ts-snapshots/zh-light-mobile-apilist-chromium-canonical.png b/www/e2e/visual-baselines.spec.ts-snapshots/zh-light-mobile-apilist-chromium-canonical.png index 5f4adcda9..ccfea0504 100644 Binary files a/www/e2e/visual-baselines.spec.ts-snapshots/zh-light-mobile-apilist-chromium-canonical.png and b/www/e2e/visual-baselines.spec.ts-snapshots/zh-light-mobile-apilist-chromium-canonical.png differ