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 (