diff --git a/www/app/dev-server.ts b/www/app/dev-server.ts deleted file mode 100644 index 66f7467fd..000000000 --- a/www/app/dev-server.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * openElement dev:fast — zero-bundler development server. - * - * Uses Deno.serve + Hono to serve pre-built SSR output directly. - * No Vite, no virtual modules. Cold start target < 1s. - * - * Architecture: - * Deno.serve (port 3000) - * ├── Hono app - * ├── /__health — health check - * ├── Static middleware — serve dist/client/* directly - * ├── SSR middleware — import dist/server/entry.js and call renderRoute() - * └── SPA fallback — serve dist/index.html for unrecognized paths - * - * Pre-requisite: `deno task build` must have been run to generate dist/. - */ - -import { Hono } from 'hono'; -import { cors } from 'hono/cors'; -import { serveStaticAssets } from './middleware/dev-static.ts'; - -const PORT = 3000; - -// Compute paths relative to the project root (www/). -// The dev:fast task runs from www/ with `--config ../deno.json`, -// so cwd is www/ and ./dist resolves correctly. -const PROJECT_ROOT = Deno.cwd(); -const DIST_ROOT = `${PROJECT_ROOT}/dist`; -const SSR_ENTRY_PATH = `${DIST_ROOT}/server/entry.js`; - -const app = new Hono(); - -// ── CORS (local development only) ─────────────────────────── -app.use( - '*', - cors({ - origin: (origin) => { - if (!origin) return null; - const host = new URL(origin).hostname; - return host === 'localhost' || host === '127.0.0.1' || host === '::1' ? origin : null; - }, - }), -); - -// ── Health check ──────────────────────────────────────────── -app.get('/__health', (c) => c.json({ ok: true, mode: 'dev:fast', port: PORT })); - -// ── Static assets (must come before SSR catch-all) ────────── -app.use('*', serveStaticAssets(`${DIST_ROOT}/client/`)); - -// ── SSR catch-all ─────────────────────────────────────────── -app.get('*', async (c) => { - try { - // Import the pre-built SSR bundle (produced by `deno task build`) - // Uses cache-busting query param so changes are picked up between builds. - // Must use file:// protocol for cross-platform path compatibility. - const cacheBuster = Date.now(); - const entryUrl = `file:///${SSR_ENTRY_PATH.replace(/\\/g, '/')}?t=${cacheBuster}`; - const mod = await import(entryUrl); - - if (typeof mod.renderRoute === 'function') { - const url = new URL(c.req.url); - const result = await mod.renderRoute(url.pathname, { - lang: url.pathname.startsWith('/zh') ? 'zh' : 'en', - }); - return c.html(result.html); - } - - // Fallback: no renderRoute export — serve index.html - const indexPath = `${DIST_ROOT}/index.html`; - try { - const indexHtml = await Deno.readTextFile(indexPath); - return c.html(indexHtml); - } catch { - return c.html( - '

openElement dev:fast

No build output found. Run deno task build first.

', - 503, - ); - } - } catch (err) { - console.error('[dev:fast] SSR error:', err); - const message = String(err instanceof Error ? err.stack || err.message : err) - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); - return c.html( - `

Dev Server Error

${message}
`, - 500, - ); - } -}); - -// ── Start ─────────────────────────────────────────────────── -Deno.serve({ port: PORT }, app.fetch); -console.info(`[dev:fast] Server running at http://localhost:${PORT}`); -console.info(`[dev:fast] Serving static files from ${DIST_ROOT}/client/`); -console.info(`[dev:fast] SSR bundle: ${SSR_ENTRY_PATH}`); diff --git a/www/app/middleware/dev-static.ts b/www/app/middleware/dev-static.ts deleted file mode 100644 index 20b151367..000000000 --- a/www/app/middleware/dev-static.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * openElement dev:fast — static file middleware. - * - * Serves static assets from dist/client/ during zero-bundler development. - * No Vite transform; files served as-is with correct content types. - */ - -import type { Context, Next } from 'hono'; - -/** File extensions recognized as static assets (served directly, not SSR). */ -const STATIC_EXTS = new Set([ - '.js', - '.mjs', - '.css', - '.svg', - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.woff2', - '.woff', - '.ttf', - '.eot', - '.json', - '.ico', - '.txt', - '.xml', - '.webmanifest', - '.map', - '.avif', -]); - -/** Content-Type mapping for common static file extensions. */ -const CONTENT_TYPES: Record = { - '.js': 'application/javascript; charset=utf-8', - '.mjs': 'application/javascript; charset=utf-8', - '.css': 'text/css; charset=utf-8', - '.svg': 'image/svg+xml', - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.woff2': 'font/woff2', - '.woff': 'font/woff', - '.ttf': 'font/ttf', - '.eot': 'application/vnd.ms-fontobject', - '.json': 'application/json; charset=utf-8', - '.ico': 'image/x-icon', - '.txt': 'text/plain; charset=utf-8', - '.xml': 'application/xml; charset=utf-8', - '.webmanifest': 'application/manifest+json', - '.map': 'application/json; charset=utf-8', - '.avif': 'image/avif', -}; - -/** - * Middleware that serves static files from dist/client/. - * - * Files matching STATIC_EXTS are served directly from disk with - * appropriate Content-Type headers and no-cache for development. - * Non-static requests and missing files pass through to the next handler. - */ -export function serveStaticAssets(root: string) { - return async (c: Context, next: Next): Promise => { - const url = new URL(c.req.url); - const pathname = url.pathname; - const ext = pathname.match(/\.[a-z0-9]+$/i)?.[0]?.toLowerCase(); - - if (ext && STATIC_EXTS.has(ext)) { - try { - // Normalize path to prevent directory traversal - const safePath = pathname.replace(/\.\./g, '').replace(/\/{2,}/g, '/'); - const filePath = `${root}${safePath}`; - const file = await Deno.readFile(filePath); - const contentType = CONTENT_TYPES[ext] || 'application/octet-stream'; - return new Response(file, { - headers: { - 'content-type': contentType, - 'cache-control': 'no-cache', - }, - }); - } catch { - // File not found — let the next handler try - return await next(); - } - } - - await next(); - }; -} diff --git a/www/app/site-ui/open-layout.tsx b/www/app/site-ui/open-layout.tsx index 712093bc4..40cb747fc 100644 --- a/www/app/site-ui/open-layout.tsx +++ b/www/app/site-ui/open-layout.tsx @@ -35,6 +35,7 @@ import { type Context, createContext, provideContext } from '@openelement/elemen import { escapeAttr, escapeHtml } from '@openelement/element'; import { createLogger } from '@openelement/element'; import { defineCustomElement } from '@openelement/element'; +import { normalizeLocalePath } from '@openelement/app/i18n'; import '@openelement/ui/open-theme-toggle'; export const tagName = 'open-layout'; @@ -61,27 +62,22 @@ function isSafeLayoutUrl(url: string): boolean { } } -/* --- Locale/path helpers for the site shell --- */ +/* --- Locale/path helpers: thin wrappers over @openelement/app/i18n --- */ const LOCALE_LABELS: Record = { en: '中文', zh: 'English' }; -function parsePathWithoutLocale(pathname: string, locales: string[]): string { - const segs = pathname.split('/').filter(Boolean); - if (segs.length > 0 && locales.includes(segs[0])) { - return '/' + segs.slice(1).join('/') || '/'; - } - return pathname || '/'; -} - -function detectLocale(pathname: string, locales: string[], defaultLocale: string): string { - const segs = pathname.split('/').filter(Boolean); - if (segs.length > 0 && locales.includes(segs[0])) return segs[0]; - return defaultLocale; -} - -function localizePath(path: string, locale: string, defaultLocale: string): string { +function localizePath( + path: string, + locale: string, + locales: string[], + defaultLocale: string, +): string { if (isSafeLayoutUrl(path) && /^https?:/i.test(path)) return path; - return locale === defaultLocale ? path : `/${locale}${path}`; + if (locale === defaultLocale) return path; + return normalizeLocalePath(`/${locale}${path === '/' ? '' : path}`, { + locales, + defaultLocale, + }).localizedPath; } function switchPath( @@ -91,7 +87,8 @@ function switchPath( defaultLocale: string, ): string { const other = locales.find((l) => l !== currentLocale) || currentLocale; - return localizePath(currentPath, other, defaultLocale); + const bare = normalizeLocalePath(currentPath, { locales, defaultLocale }).path; + return localizePath(bare, other, locales, defaultLocale); } function switchLabel(currentLocale: string): string { @@ -595,7 +592,10 @@ export class OpenLayout extends OpenElement { private get _currentLocale(): string { try { if (typeof globalThis.location !== 'undefined') { - return detectLocale(location.pathname, this._locales, this._defaultLocale); + return normalizeLocalePath(location.pathname, { + locales: this._locales, + defaultLocale: this._defaultLocale, + }).locale; } return this._defaultLocale; } catch { @@ -606,7 +606,10 @@ export class OpenLayout extends OpenElement { private get _currentPathWithoutLocale(): string { try { if (typeof globalThis.location !== 'undefined') { - return parsePathWithoutLocale(location.pathname, this._locales); + return normalizeLocalePath(location.pathname, { + locales: this._locales, + defaultLocale: this._defaultLocale, + }).path; } return this.getAttribute('current-path') || '/'; } catch { @@ -649,7 +652,7 @@ export class OpenLayout extends OpenElement { // _currentPathWithoutLocale, _currentLocale, _locales, _switchPath(), // _switchLabel(), _updateSwitch(), _localizePath() - // Site-local helpers keep the shell independent from application internals. + // Locale path math goes through @openelement/app/i18n (normalizeLocalePath). private _currentPath(): string { // SSR-safe: prefer attribute/prop set by renderDsd over URL detection @@ -660,7 +663,10 @@ export class OpenLayout extends OpenElement { if (attr && attr.length > 0) return attr; try { if (typeof globalThis.location !== 'undefined') { - return parsePathWithoutLocale(location.pathname, this._locales); + return normalizeLocalePath(location.pathname, { + locales: this._locales, + defaultLocale: this._defaultLocale, + }).path; } return this.getAttribute('current-path') || '/'; } catch { @@ -848,7 +854,7 @@ export class OpenLayout extends OpenElement { return { href: isExternal ? safeHref - : localizePath(safeHref, this._currentLocale, this._defaultLocale), + : localizePath(safeHref, this._currentLocale, this._locales, this._defaultLocale), isExternal, }; } @@ -867,7 +873,7 @@ export class OpenLayout extends OpenElement { const langHref = locales.length > 1 ? switchPath(currentPath, currentLocale, locales, defaultLocale) : ''; - const localePath = (path: string) => localizePath(path, currentLocale, defaultLocale); + const localePath = (path: string) => localizePath(path, currentLocale, locales, defaultLocale); return (