diff --git a/deno.json b/deno.json index 9bc7800a..845e2d63 100644 --- a/deno.json +++ b/deno.json @@ -16,6 +16,7 @@ ], "imports": { "@std/fs/walk": "jsr:@std/fs@^1.0.0/walk", + "@std/assert": "jsr:@std/assert@^1.0.0", "hono": "npm:hono@^4.12", "@hono/vite-dev-server": "npm:@hono/vite-dev-server@^0.25.3", "vite": "npm:vite@8.0.10", @@ -26,7 +27,10 @@ "preact": "npm:preact@^10.28.0", "preact-render-to-string": "npm:preact-render-to-string@^6.5.0", "@preact/signals-core": "npm:@preact/signals-core@^1.12.1", - "flexsearch": "npm:flexsearch@^0.7" + "@preact/signals": "npm:@preact/signals@^2.9.0", + "flexsearch": "npm:flexsearch@^0.7", + "@shoelace-style/shoelace": "npm:@shoelace-style/shoelace@^2.19.0", + "@material/web/checkbox/checkbox.js": "npm:@material/web@^2.0.0/checkbox/checkbox.js" }, "vendor": true, "nodeModulesDir": "manual", @@ -53,6 +57,7 @@ "consumer:local": "deno run --allow-read --allow-write --allow-run --allow-env --allow-net tools/consumer-local.ts", "consumer:packaged": "deno run --allow-read --allow-write --allow-run --allow-env --allow-net tools/consumer-local.ts --packaged-import-map-check", "third-party-wc:smoke": "deno run --allow-read --allow-write --allow-run --allow-env --allow-net --allow-sys tools/third-party-wc-smoke.ts", + "desktop-reader:smoke": "cd examples/deno-desktop-reader && deno test -A --no-check app/__tests__/", "package-artifacts:check": "deno run --allow-read --allow-write --allow-run --allow-net --allow-env tools/check-package-artifacts.ts", "publish:jsr:core": "cd packages/core && deno publish", "pack": "deno task generate:ui-manifest && deno run --allow-read --allow-write --allow-run tools/publish-npm.ts pack", diff --git a/deno.lock b/deno.lock index fe55f557..dc159ee5 100644 --- a/deno.lock +++ b/deno.lock @@ -2009,12 +2009,16 @@ }, "workspace": { "dependencies": [ + "jsr:@std/assert@1", "jsr:@std/fs@1", "npm:@hono/vite-dev-server@~0.25.3", + "npm:@material/web@2", "npm:@mdx-js/mdx@^3.1.1", "npm:@mdx-js/rollup@^3.1.1", "npm:@playwright/test@1.59.1", "npm:@preact/signals-core@^1.12.1", + "npm:@preact/signals@^2.9.0", + "npm:@shoelace-style/shoelace@^2.19.0", "npm:flexsearch@0.7", "npm:hono@^4.12.0", "npm:preact-render-to-string@^6.5.0", diff --git a/docs/adr/0036-ocean-island-architecture.md b/docs/adr/0036-ocean-island-architecture.md index 80a44e62..718b3e78 100644 --- a/docs/adr/0036-ocean-island-architecture.md +++ b/docs/adr/0036-ocean-island-architecture.md @@ -17,10 +17,10 @@ LessJS components (`@openelement/ui`) are built on **Lit + DsdLitElement**: ```typescript // 当前:Lit 依赖 class LessButton extends DsdLitElement { - static override styles = [lessDesignTokens, css`...`]; // Lit CSSResult + static override styles = [lessDesignTokens, css`...`]; // Lit CSSResult override render() { - if (this._dsdHydrated) return nothing; // hack! - return html` + + ); + }, +}); diff --git a/examples/deno-desktop-reader/main.ts b/examples/deno-desktop-reader/main.ts new file mode 100644 index 00000000..e9256c6c --- /dev/null +++ b/examples/deno-desktop-reader/main.ts @@ -0,0 +1,226 @@ +/** + * openElement Desktop Reader — HTTP server. + * + * Serves the SPA client (built by Vite to dist/), API endpoints, and PDF files. + * All handler errors are caught to avoid breaking the desktop webview. + */ + +import { indexBook, search } from './app/search.ts'; + +// Cache paths +const HOME = Deno.env.get('HOME') ?? '.'; +const CACHE_DIR = `${HOME}/.open-reader`; +const BOOKS_DIR = `${CACHE_DIR}/books`; +const FIXTURES_DIR = new URL('./fixtures/books/', import.meta.url).pathname; +const BOOKS_JSON_URL = new URL('./fixtures/books.json', import.meta.url); +const DIST_DIR = new URL('./dist/', import.meta.url); + +let searchIndexReady = false; + +function readTextSafe(url: URL): string | null { + try { + return Deno.readTextFileSync(url); + } catch { + return null; + } +} + +function readFileSafe(url: URL): Uint8Array | null { + try { + return Deno.readFileSync(url); + } catch { + return null; + } +} + +function statSafe(path: string): boolean { + try { + Deno.statSync(path); + return true; + } catch { + return false; + } +} + +function html(body: string): Response { + return new Response(body, { headers: { 'content-type': 'text/html' } }); +} + +function json(data: unknown): Response { + return new Response(JSON.stringify(data), { + headers: { 'content-type': 'application/json' }, + }); +} + +function byteBody(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ); +} + +function pdf(bytes: Uint8Array): Response { + return new Response(byteBody(bytes), { + headers: { 'content-type': 'application/pdf' }, + }); +} + +function serveFile(bytes: Uint8Array, ext: string): Response { + const mime: Record = { + '.js': 'application/javascript', + '.css': 'text/css', + '.html': 'text/html', + '.json': 'application/json', + '.svg': 'image/svg+xml', + '.png': 'image/png', + }; + return new Response(byteBody(bytes), { + headers: { 'content-type': mime[ext] ?? 'application/octet-stream' }, + }); +} + +/** Find the main app JS bundle in dist/assets/ (pick largest reader-*.js). */ +function findAppScript(dir: URL): string | null { + try { + let best: string | null = null; + let bestSize = 0; + for (const entry of Deno.readDirSync(dir)) { + if (entry.isFile && entry.name.startsWith('reader-') && entry.name.endsWith('.js')) { + const info = Deno.statSync(new URL(`./${entry.name}`, dir)); + if (info.size > bestSize) { + bestSize = info.size; + best = entry.name; + } + } + } + return best; + } catch { /* dir may not exist */ } + return null; +} + +/** Find the main CSS bundle in dist/assets/. */ +function findAppCss(dir: URL): string | null { + try { + for (const entry of Deno.readDirSync(dir)) { + if (entry.isFile && entry.name.startsWith('style-') && entry.name.endsWith('.css')) { + return entry.name; + } + } + } catch { /* dir may not exist */ } + return null; +} + +function notFound(): Response { + return new Response('Not Found', { status: 404 }); +} + +function serverError(): Response { + return new Response('Internal Server Error', { + status: 500, + headers: { 'content-type': 'text/plain' }, + }); +} + +async function ensureSearchIndex(): Promise { + if (searchIndexReady) return; + searchIndexReady = true; + const raw = readTextSafe(BOOKS_JSON_URL); + if (!raw) return; + try { + const books = JSON.parse(raw); + for (const book of books) { + const path = `${FIXTURES_DIR}/${book.fileName}`; + if (!statSafe(path)) continue; + try { + await indexBook(path, book.id, CACHE_DIR); + } catch { /* skip */ } + } + } catch { /* skip */ } +} + +Deno.serve((req: Request) => { + try { + const url = new URL(req.url); + const pathname = url.pathname; + const ext = pathname.slice(pathname.lastIndexOf('.')); + + // API: books + if (pathname === '/api/books') { + const raw = readTextSafe(BOOKS_JSON_URL); + return raw ? json(JSON.parse(raw)) : json([]); + } + + // API: search + if (pathname === '/api/search') { + const q = url.searchParams.get('q'); + if (!q) return json([]); + ensureSearchIndex(); // fire-and-forget + try { + return json(search(q, CACHE_DIR)); + } catch { + return json([]); + } + } + + // PDF files + if (pathname.startsWith('/books/')) { + const name = pathname.slice('/books/'.length); + for (const dir of [BOOKS_DIR, FIXTURES_DIR]) { + const p = `${dir}/${name}`; + if (!statSafe(p)) continue; + try { + return pdf(Deno.readFileSync(p)); + } catch { /* try next */ } + } + return notFound(); + } + + // Static assets from dist/ (Vite build output) + if ( + pathname.startsWith('/assets/') || pathname.startsWith('/islands/') || + pathname.startsWith('/client/') || + pathname.startsWith('/app/') || pathname.startsWith('/fixtures/') + ) { + const file = readFileSafe(new URL(`.${pathname}`, DIST_DIR)); + if (!file) { + // Also try from project root for non-built assets (CSS, JSON) + const rootFile = readFileSafe(new URL(`.${pathname}`, import.meta.url)); + return rootFile ? serveFile(rootFile, ext) : notFound(); + } + return serveFile(file, ext); + } + + // SPA fallback: serve dist/index.html for all other routes + let indexHtml = readTextSafe(new URL('./index.html', DIST_DIR)); + if (indexHtml) { + // Add fallback text that disappears when SPA mounts (debug aid) + indexHtml = indexHtml.replace( + '
', + '

openElement Reader — loading...

', + ); + // Inject main app bundle script (adapter-vite's SPA shell only includes island entry) + const assetsDir = new URL('./assets/', DIST_DIR); + const appScript = findAppScript(assetsDir); + const cssScript = findAppCss(assetsDir); + if (appScript) { + // Replace client-entry.js placeholder with actual reader bundle + CSS + CDN + islands + indexHtml = indexHtml.replace( + '', + `${cssScript ? `` : ''} + + + +`, + ); + } + return html(indexHtml); + } + + // Fallback: try project-root index.html (for dev mode) + const rootIndex = readTextSafe(new URL('./index.html', import.meta.url)); + return rootIndex ? html(rootIndex) : serverError(); + } catch (err) { + console.error('[reader] Handler error:', err); + return serverError(); + } +}); diff --git a/examples/deno-desktop-reader/reader.tsx b/examples/deno-desktop-reader/reader.tsx new file mode 100644 index 00000000..ab135a66 --- /dev/null +++ b/examples/deno-desktop-reader/reader.tsx @@ -0,0 +1,59 @@ +/** @jsxImportSource @openelement/core */ +import { defineApp } from '@openelement/app/spa'; +import { setRouter } from './router.ts'; + +// Register @openelement/ui custom elements on page load +import '@openelement/ui'; + +// Import route modules for side-effect: customElements.define + exports loader/action/tagName +import BookshelfPage, { + loader as bookshelfLoader, + tagName as bookshelfTag, +} from './routes/index.tsx'; +import ReadingPage, { + action as readingAction, + loader as readingLoader, + tagName as readingTag, +} from './routes/books/[id].tsx'; +import NotesPage, { loader as notesLoader, tagName as notesTag } from './routes/notes.tsx'; +import SearchPage, { loader as searchLoader, tagName as searchTag } from './routes/search.tsx'; +import SettingsPage, { + loader as settingsLoader, + tagName as settingsTag, +} from './routes/settings.tsx'; +import WcInteropPage, { tagName as wcInteropTag } from './routes/wc-interop.tsx'; + +// Prevent tree-shaking: default exports trigger customElements.define side effects +void BookshelfPage; void ReadingPage; void NotesPage; +void SearchPage; void SettingsPage; void WcInteropPage; + +// ─── Route config ────────────────────────────────────────── + +const routes = [ + { path: '/', loader: bookshelfLoader, component: () => bookshelfTag, tagName: bookshelfTag }, + { path: '/books/:id', loader: readingLoader, action: readingAction, component: () => readingTag, tagName: readingTag }, + { path: '/notes', loader: notesLoader, component: () => notesTag, tagName: notesTag }, + { path: '/search', loader: searchLoader, component: () => searchTag, tagName: searchTag }, + { path: '/settings', loader: settingsLoader, component: () => settingsTag, tagName: settingsTag }, + { path: '/wc-interop', component: () => wcInteropTag, tagName: wcInteropTag }, +]; + +// ─── Boot ─────────────────────────────────────────────────── + +const app = defineApp({ mode: 'spa', routes }); +app.mount('#root'); +setRouter(app.router); + +// Keyboard shortcuts +document.addEventListener('keydown', (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement)?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + if ((e.metaKey || e.ctrlKey) && e.key === 'f') { + e.preventDefault(); + app.router?.navigate('/search'); + } + if ((e.metaKey || e.ctrlKey) && e.key === ',') { + e.preventDefault(); + app.router?.navigate('/settings'); + } +}); diff --git a/examples/deno-desktop-reader/router.ts b/examples/deno-desktop-reader/router.ts new file mode 100644 index 00000000..8b97eaa7 --- /dev/null +++ b/examples/deno-desktop-reader/router.ts @@ -0,0 +1,32 @@ +/** + * Shared router reference for SPA navigation. + * Set by reader.tsx after mount, used by route components. + */ +import type { RouterInstance } from '@openelement/router/client-router'; + +let _router: RouterInstance | null = null; + +export function setRouter(router: RouterInstance | null): void { + _router = router; +} + +export function getRouter(): RouterInstance | null { + return _router; +} + +export function navigate(path: string): void { + if (_router) { + void _router.navigate(path); + return; + } + + console.warn('[reader] navigate called before openElement router is mounted:', path); +} + +export function currentParams(): Record { + return _router?.params ?? {}; +} + +export function currentPath(): string { + return _router?.currentPath ?? location.pathname + location.search; +} diff --git a/examples/deno-desktop-reader/routes/books/[id].tsx b/examples/deno-desktop-reader/routes/books/[id].tsx new file mode 100644 index 00000000..f04e532f --- /dev/null +++ b/examples/deno-desktop-reader/routes/books/[id].tsx @@ -0,0 +1,164 @@ +/** @jsxImportSource @openelement/core */ +import { OpenElement } from "@openelement/element"; +import { signal } from "@openelement/signal"; +import type { ReaderBook } from "../../app/types.ts"; +import { navigate } from "../../router.ts"; +import { saveNote, saveProgress } from "../../app/storage.ts"; + +// ponytail: direct import of books JSON for the SPA client +import booksData from "../../fixtures/books.json" with { type: "json" }; + +export interface ReadingData { + book: ReaderBook | null; + page: number; + totalPages: number; +} + +export interface ReadingActionData { + saved?: boolean; + error?: string; +} + +function readPage(params: Record): number { + const pageParam = parseInt(params.page || "1", 10); + return isNaN(pageParam) || pageParam < 1 ? 1 : pageParam; +} + +export function loader( + ctx: { params: Record }, +): Promise { + const books = booksData as unknown as ReaderBook[]; + const book = books.find((b) => b.id === ctx.params.id) ?? null; + const page = readPage(ctx.params); + if (book) saveProgress(book.id, page); + return Promise.resolve({ + book, + page, + totalPages: book?.pageCount ?? 0, + }); +} + +export function action( + ctx: { params: Record; formData?: FormData }, +): Promise { + const books = booksData as unknown as ReaderBook[]; + const book = books.find((b) => b.id === ctx.params.id); + if (!book) return Promise.resolve({ error: "Book not found" }); + + const quote = (ctx.formData?.get("note-quote") as string ?? "").trim(); + const note = (ctx.formData?.get("note-text") as string ?? "").trim(); + if (!note) { + return Promise.resolve({ error: "Write a note before saving." }); + } + + const page = readPage(ctx.params); + saveNote({ + id: crypto.randomUUID(), + bookId: book.id, + pageNumber: page, + quote, + note, + createdAt: new Date().toISOString(), + }); + return Promise.resolve({ saved: true }); +} + +export const tagName = "reader-reading"; + +export default class ReadingPage extends OpenElement { + #showAddNoteForm = signal(false); + + override render() { + const data = (this as unknown) as ReadingPage & ReadingData; + const actionData: ReadingActionData | undefined = + (this as unknown as Record).actionData as + | ReadingActionData + | undefined; + const book = data.book; + const page = data.page; + const totalPages = data.totalPages; + + if (!book) { + return ( + + ); + } + + return ( +
+

{book.title}

+

by {book.author}

+ {actionData?.saved &&

Note saved.

} + {actionData?.error &&

{actionData.error}

} + + + + + + { + this.#showAddNoteForm.value = !this.#showAddNoteForm.value; + }} + > + + Add Note + + + {this.#showAddNoteForm.value && ( +
+ +