@nuraly/lumenjs 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # LumenJS
2
2
 
3
- A full-stack web framework for [Lit](https://lit.dev/) web components. File-based routing, server loaders, SSR with hydration, nested layouts, API routes, and a Vite-powered dev server.
3
+ A full-stack web framework for [Lit](https://lit.dev/) web components. File-based routing, server loaders, real-time subscriptions (SSE), SSR with hydration, nested layouts, API routes, and a Vite-powered dev server.
4
4
 
5
5
  ## Quick Start
6
6
 
@@ -49,9 +49,7 @@ Pages are Lit components in the `pages/` directory. The file path determines the
49
49
  ```typescript
50
50
  // pages/index.ts
51
51
  import { LitElement, html, css } from 'lit';
52
- import { customElement } from 'lit/decorators.js';
53
52
 
54
- @customElement('page-index')
55
53
  export class PageIndex extends LitElement {
56
54
  static styles = css`:host { display: block; }`;
57
55
 
@@ -61,6 +59,8 @@ export class PageIndex extends LitElement {
61
59
  }
62
60
  ```
63
61
 
62
+ The custom element tag name is derived automatically from the file path — no `@customElement` decorator needed.
63
+
64
64
  ### Routing
65
65
 
66
66
  | File | URL | Tag |
@@ -85,9 +85,9 @@ export async function loader({ params, headers, query, url }) {
85
85
  return { post };
86
86
  }
87
87
 
88
- @customElement('page-blog-slug')
89
88
  export class BlogPost extends LitElement {
90
- @property({ type: Object }) loaderData: any = {};
89
+ static properties = { loaderData: { type: Object } };
90
+ loaderData: any = {};
91
91
 
92
92
  render() {
93
93
  return html`<h1>${this.loaderData.post?.title}</h1>`;
@@ -117,13 +117,75 @@ export async function loader({ headers }) {
117
117
  }
118
118
  ```
119
119
 
120
+ ## Live Data (subscribe)
121
+
122
+ Export a `subscribe()` function from any page or layout to push real-time data to the client over Server-Sent Events (SSE).
123
+
124
+ ```typescript
125
+ // pages/dashboard.ts
126
+ export async function loader() {
127
+ return { orders: await db.orders.findAll() };
128
+ }
129
+
130
+ export function subscribe({ push }) {
131
+ const stream = db.orders.watch();
132
+ stream.on('change', (change) => push({ type: 'order-update', data: change }));
133
+ return () => stream.close();
134
+ }
135
+
136
+ export class PageDashboard extends LitElement {
137
+ static properties = { loaderData: { type: Object }, liveData: { type: Object } };
138
+ loaderData: any = {};
139
+ liveData: any = null;
140
+
141
+ render() {
142
+ return html`
143
+ <h1>Orders (${this.loaderData.orders?.length})</h1>
144
+ ${this.liveData ? html`<p>Update: ${this.liveData.type}</p>` : ''}
145
+ `;
146
+ }
147
+ }
148
+ ```
149
+
150
+ The `subscribe()` function is a persistent server-side process tied to the page lifecycle:
151
+
152
+ 1. User opens page → framework opens SSE connection to `/__nk_subscribe/<path>`
153
+ 2. Server calls `subscribe()` — function keeps running (DB watchers, intervals, etc.)
154
+ 3. Call `push(data)` whenever you want → delivered to client → updates `liveData` property
155
+ 4. User navigates away → connection closes → cleanup function runs
156
+
157
+ Like `loader()`, `subscribe()` is stripped from client bundles automatically.
158
+
159
+ ### Subscribe Context
160
+
161
+ | Property | Type | Description |
162
+ |---|---|---|
163
+ | `params` | `Record<string, string>` | Dynamic route parameters |
164
+ | `headers` | `Record<string, any>` | Request headers |
165
+ | `locale` | `string` | Current locale (when i18n is configured) |
166
+ | `push` | `(data: any) => void` | Send SSE event to client (JSON-serialized) |
167
+
168
+ Return a cleanup function that is called when the client disconnects.
169
+
170
+ ### Layout Subscribe
171
+
172
+ Layouts can also export `subscribe()` for global live data (notifications, presence, etc.):
173
+
174
+ ```typescript
175
+ // pages/_layout.ts
176
+ export function subscribe({ push }) {
177
+ const ws = new WebSocket('wss://notifications.example.com');
178
+ ws.on('message', (msg) => push(JSON.parse(msg)));
179
+ return () => ws.close();
180
+ }
181
+ ```
182
+
120
183
  ## Nested Layouts
121
184
 
122
185
  Create `_layout.ts` in any directory to wrap all pages in that directory and its subdirectories.
123
186
 
124
187
  ```typescript
125
188
  // pages/_layout.ts
126
- @customElement('layout-root')
127
189
  export class RootLayout extends LitElement {
128
190
  render() {
129
191
  return html`
@@ -147,9 +209,9 @@ export async function loader({ headers }) {
147
209
  return { user };
148
210
  }
149
211
 
150
- @customElement('layout-dashboard')
151
212
  export class DashboardLayout extends LitElement {
152
- @property({ type: Object }) loaderData: any = {};
213
+ static properties = { loaderData: { type: Object } };
214
+ loaderData: any = {};
153
215
 
154
216
  render() {
155
217
  return html`
@@ -259,7 +321,6 @@ my-app/
259
321
  ```typescript
260
322
  import { t, getLocale, setLocale } from '@lumenjs/i18n';
261
323
 
262
- @customElement('page-index')
263
324
  export class PageIndex extends LitElement {
264
325
  render() {
265
326
  return html`<h1>${t('home.title')}</h1>`;
@@ -65,12 +65,12 @@ export async function buildProject(options) {
65
65
  // Collect server entry points (pages with loaders + layouts with loaders + API routes)
66
66
  const serverEntries = {};
67
67
  for (const entry of pageEntries) {
68
- if (entry.hasLoader) {
68
+ if (entry.hasLoader || entry.hasSubscribe) {
69
69
  serverEntries[`pages/${entry.name}`] = entry.filePath;
70
70
  }
71
71
  }
72
72
  for (const entry of layoutEntries) {
73
- if (entry.hasLoader) {
73
+ if (entry.hasLoader || entry.hasSubscribe) {
74
74
  const entryName = entry.dir ? `layouts/${entry.dir}/_layout` : 'layouts/_layout';
75
75
  serverEntries[entryName] = entry.filePath;
76
76
  }
@@ -124,6 +124,7 @@ export async function buildProject(options) {
124
124
  'os', 'fs', 'path', 'url', 'util', 'crypto', 'http', 'https', 'net',
125
125
  'stream', 'zlib', 'events', 'buffer', 'querystring', 'child_process',
126
126
  'worker_threads', 'cluster', 'dns', 'tls', 'assert', 'constants',
127
+ 'better-sqlite3',
127
128
  ],
128
129
  },
129
130
  },
@@ -163,8 +164,9 @@ export async function buildProject(options) {
163
164
  const relPath = path.relative(pagesDir, e.filePath).replace(/\\/g, '/');
164
165
  return {
165
166
  path: e.routePath,
166
- module: e.hasLoader ? `pages/${e.name}.js` : '',
167
+ module: (e.hasLoader || e.hasSubscribe) ? `pages/${e.name}.js` : '',
167
168
  hasLoader: e.hasLoader,
169
+ hasSubscribe: e.hasSubscribe,
168
170
  tagName: filePathToTagName(relPath),
169
171
  ...(routeLayouts.length > 0 ? { layouts: routeLayouts } : {}),
170
172
  };
@@ -173,11 +175,13 @@ export async function buildProject(options) {
173
175
  path: `/api/${e.routePath}`,
174
176
  module: `api/${e.name}.js`,
175
177
  hasLoader: false,
178
+ hasSubscribe: false,
176
179
  })),
177
180
  layouts: layoutEntries.map(e => ({
178
181
  dir: e.dir,
179
- module: e.hasLoader ? (e.dir ? `layouts/${e.dir}/_layout.js` : 'layouts/_layout.js') : '',
182
+ module: (e.hasLoader || e.hasSubscribe) ? (e.dir ? `layouts/${e.dir}/_layout.js` : 'layouts/_layout.js') : '',
180
183
  hasLoader: e.hasLoader,
184
+ hasSubscribe: e.hasSubscribe,
181
185
  })),
182
186
  ...(i18nConfig ? { i18n: i18nConfig } : {}),
183
187
  };
@@ -3,11 +3,13 @@ export interface PageEntry {
3
3
  filePath: string;
4
4
  routePath: string;
5
5
  hasLoader: boolean;
6
+ hasSubscribe: boolean;
6
7
  }
7
8
  export interface LayoutEntry {
8
9
  dir: string;
9
10
  filePath: string;
10
11
  hasLoader: boolean;
12
+ hasSubscribe: boolean;
11
13
  }
12
14
  export interface ApiEntry {
13
15
  name: string;
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { fileHasLoader, filePathToRoute } from '../shared/utils.js';
3
+ import { fileHasLoader, fileHasSubscribe, filePathToRoute } from '../shared/utils.js';
4
4
  export function scanPages(pagesDir) {
5
5
  if (!fs.existsSync(pagesDir))
6
6
  return [];
@@ -54,7 +54,8 @@ function walkDir(baseDir, relativePath, entries, pagesDir) {
54
54
  const name = entryRelative.replace(/\.(ts|js)$/, '').replace(/\\/g, '/');
55
55
  const routePath = filePathToRoute(entryRelative);
56
56
  const hasLoader = fileHasLoader(filePath);
57
- entries.push({ name, filePath, routePath, hasLoader });
57
+ const hasSubscribe = fileHasSubscribe(filePath);
58
+ entries.push({ name, filePath, routePath, hasLoader, hasSubscribe });
58
59
  }
59
60
  }
60
61
  }
@@ -65,7 +66,7 @@ function walkForLayouts(baseDir, relativePath, entries) {
65
66
  if (entry.isFile() && /^_layout\.(ts|js)$/.test(entry.name)) {
66
67
  const filePath = path.join(fullDir, entry.name);
67
68
  const dir = relativePath.replace(/\\/g, '/');
68
- entries.push({ dir, filePath, hasLoader: fileHasLoader(filePath) });
69
+ entries.push({ dir, filePath, hasLoader: fileHasLoader(filePath), hasSubscribe: fileHasSubscribe(filePath) });
69
70
  }
70
71
  if (entry.isDirectory()) {
71
72
  walkForLayouts(baseDir, path.join(relativePath, entry.name), entries);
@@ -1,4 +1,6 @@
1
1
  import http from 'http';
2
2
  import type { BuildManifest } from '../shared/types.js';
3
3
  export declare function handleLayoutLoaderRequest(manifest: BuildManifest, serverDir: string, queryString: string | undefined, headers: http.IncomingHttpHeaders, res: http.ServerResponse): Promise<void>;
4
+ export declare function handleLayoutSubscribeRequest(manifest: BuildManifest, serverDir: string, queryString: string | undefined, headers: http.IncomingHttpHeaders, res: http.ServerResponse): Promise<void>;
5
+ export declare function handleSubscribeRequest(manifest: BuildManifest, serverDir: string, pagesDir: string, pathname: string, queryString: string | undefined, headers: http.IncomingHttpHeaders, res: http.ServerResponse): Promise<void>;
4
6
  export declare function handleLoaderRequest(manifest: BuildManifest, serverDir: string, pagesDir: string, pathname: string, queryString: string | undefined, headers: http.IncomingHttpHeaders, res: http.ServerResponse): Promise<void>;
@@ -56,6 +56,116 @@ export async function handleLayoutLoaderRequest(manifest, serverDir, queryString
56
56
  res.end(JSON.stringify({ error: message }));
57
57
  }
58
58
  }
59
+ export async function handleLayoutSubscribeRequest(manifest, serverDir, queryString, headers, res) {
60
+ const query = {};
61
+ if (queryString) {
62
+ for (const pair of queryString.split('&')) {
63
+ const [key, val] = pair.split('=');
64
+ query[decodeURIComponent(key)] = decodeURIComponent(val || '');
65
+ }
66
+ }
67
+ const dir = query.__dir || '';
68
+ const layout = (manifest.layouts || []).find(l => l.dir === dir);
69
+ if (!layout || !layout.hasSubscribe || !layout.module) {
70
+ res.writeHead(204);
71
+ res.end();
72
+ return;
73
+ }
74
+ const modulePath = path.join(serverDir, layout.module);
75
+ if (!fs.existsSync(modulePath)) {
76
+ res.writeHead(204);
77
+ res.end();
78
+ return;
79
+ }
80
+ try {
81
+ const mod = await import(modulePath);
82
+ if (!mod.subscribe || typeof mod.subscribe !== 'function') {
83
+ res.writeHead(204);
84
+ res.end();
85
+ return;
86
+ }
87
+ res.writeHead(200, {
88
+ 'Content-Type': 'text/event-stream',
89
+ 'Cache-Control': 'no-cache',
90
+ 'Connection': 'keep-alive',
91
+ });
92
+ const locale = query.__locale;
93
+ const push = (data) => {
94
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
95
+ };
96
+ const cleanup = mod.subscribe({ params: {}, push, headers, locale });
97
+ res.on('close', () => {
98
+ if (typeof cleanup === 'function')
99
+ cleanup();
100
+ });
101
+ }
102
+ catch (err) {
103
+ console.error(`[LumenJS] Layout subscribe error for dir=${dir}:`, err);
104
+ if (!res.headersSent) {
105
+ res.writeHead(500);
106
+ res.end();
107
+ }
108
+ }
109
+ }
110
+ export async function handleSubscribeRequest(manifest, serverDir, pagesDir, pathname, queryString, headers, res) {
111
+ const pagePath = pathname.replace('/__nk_subscribe', '') || '/';
112
+ const query = {};
113
+ if (queryString) {
114
+ for (const pair of queryString.split('&')) {
115
+ const [key, val] = pair.split('=');
116
+ query[decodeURIComponent(key)] = decodeURIComponent(val || '');
117
+ }
118
+ }
119
+ let params = {};
120
+ if (query.__params) {
121
+ try {
122
+ params = JSON.parse(query.__params);
123
+ }
124
+ catch { /* ignore */ }
125
+ delete query.__params;
126
+ }
127
+ const matched = matchRoute(manifest.routes.filter(r => r.hasSubscribe), pagePath);
128
+ if (!matched || !matched.route.module) {
129
+ res.writeHead(204);
130
+ res.end();
131
+ return;
132
+ }
133
+ const modulePath = path.join(serverDir, matched.route.module);
134
+ if (!fs.existsSync(modulePath)) {
135
+ res.writeHead(204);
136
+ res.end();
137
+ return;
138
+ }
139
+ try {
140
+ const mod = await import(modulePath);
141
+ if (!mod.subscribe || typeof mod.subscribe !== 'function') {
142
+ res.writeHead(204);
143
+ res.end();
144
+ return;
145
+ }
146
+ res.writeHead(200, {
147
+ 'Content-Type': 'text/event-stream',
148
+ 'Cache-Control': 'no-cache',
149
+ 'Connection': 'keep-alive',
150
+ });
151
+ const locale = query.__locale;
152
+ const push = (data) => {
153
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
154
+ };
155
+ const cleanup = mod.subscribe({ params: matched.params, push, headers, locale });
156
+ res.on('close', () => {
157
+ if (typeof cleanup === 'function')
158
+ cleanup();
159
+ });
160
+ }
161
+ catch (err) {
162
+ console.error(`[LumenJS] Subscribe error for ${pagePath}:`, err);
163
+ if (!res.headersSent) {
164
+ res.writeHead(500);
165
+ res.end();
166
+ }
167
+ }
168
+ }
59
169
  export async function handleLoaderRequest(manifest, serverDir, pagesDir, pathname, queryString, headers, res) {
60
170
  const pagePath = pathname.replace('/__nk_loader', '') || '/';
61
171
  // Parse query params
@@ -5,13 +5,15 @@ import { readProjectConfig } from '../dev-server/config.js';
5
5
  import { installDomShims } from '../shared/dom-shims.js';
6
6
  import { serveStaticFile, sendCompressed } from './serve-static.js';
7
7
  import { handleApiRoute } from './serve-api.js';
8
- import { handleLoaderRequest, handleLayoutLoaderRequest } from './serve-loaders.js';
8
+ import { handleLoaderRequest, handleLayoutLoaderRequest, handleSubscribeRequest, handleLayoutSubscribeRequest } from './serve-loaders.js';
9
9
  import { handlePageRoute } from './serve-ssr.js';
10
10
  import { renderErrorPage } from './error-page.js';
11
11
  import { handleI18nRequest } from './serve-i18n.js';
12
12
  import { resolveLocale } from '../dev-server/middleware/locale.js';
13
+ import { setProjectDir } from '../db/context.js';
13
14
  export async function serveProject(options) {
14
15
  const { projectDir } = options;
16
+ setProjectDir(projectDir);
15
17
  const port = options.port || 3000;
16
18
  const outDir = path.join(projectDir, '.lumenjs');
17
19
  const clientDir = path.join(outDir, 'client');
@@ -62,12 +64,22 @@ export async function serveProject(options) {
62
64
  handleI18nRequest(localesDir, manifest.i18n.locales, pathname, req, res);
63
65
  return;
64
66
  }
65
- // 4. Layout loader endpoint
67
+ // 4. Layout subscribe endpoint (SSE)
68
+ if (pathname === '/__nk_subscribe/__layout/' || pathname === '/__nk_subscribe/__layout') {
69
+ await handleLayoutSubscribeRequest(manifest, serverDir, queryString, req.headers, res);
70
+ return;
71
+ }
72
+ // 5. Subscribe endpoint (SSE)
73
+ if (pathname.startsWith('/__nk_subscribe/')) {
74
+ await handleSubscribeRequest(manifest, serverDir, pagesDir, pathname, queryString, req.headers, res);
75
+ return;
76
+ }
77
+ // 6. Layout loader endpoint
66
78
  if (pathname === '/__nk_loader/__layout/' || pathname === '/__nk_loader/__layout') {
67
79
  await handleLayoutLoaderRequest(manifest, serverDir, queryString, req.headers, res);
68
80
  return;
69
81
  }
70
- // 5. Loader endpoint for client-side navigation
82
+ // 7. Loader endpoint for client-side navigation
71
83
  if (pathname.startsWith('/__nk_loader/')) {
72
84
  await handleLoaderRequest(manifest, serverDir, pagesDir, pathname, queryString, req.headers, res);
73
85
  return;
@@ -0,0 +1,2 @@
1
+ export declare function setProjectDir(dir: string): void;
2
+ export declare function getProjectDir(): string;
@@ -0,0 +1,9 @@
1
+ let _projectDir = null;
2
+ export function setProjectDir(dir) {
3
+ _projectDir = dir;
4
+ }
5
+ export function getProjectDir() {
6
+ if (!_projectDir)
7
+ throw new Error('[LumenJS] Project directory not set');
8
+ return _projectDir;
9
+ }
@@ -0,0 +1,19 @@
1
+ import Database from 'better-sqlite3';
2
+ export declare class LumenDb {
3
+ private db;
4
+ constructor(db: Database.Database);
5
+ /** SELECT multiple rows */
6
+ all<T = any>(sql: string, ...params: any[]): T[];
7
+ /** SELECT single row */
8
+ get<T = any>(sql: string, ...params: any[]): T | undefined;
9
+ /** INSERT/UPDATE/DELETE */
10
+ run(sql: string, ...params: any[]): {
11
+ changes: number;
12
+ lastInsertRowid: number | bigint;
13
+ };
14
+ /** Multi-statement DDL */
15
+ exec(sql: string): void;
16
+ /** Access the underlying better-sqlite3 instance */
17
+ get raw(): Database.Database;
18
+ }
19
+ export declare function useDb(): LumenDb;
@@ -0,0 +1,79 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+ import Database from 'better-sqlite3';
4
+ import { getProjectDir } from './context.js';
5
+ import { readProjectConfig } from '../dev-server/config.js';
6
+ export class LumenDb {
7
+ constructor(db) {
8
+ this.db = db;
9
+ }
10
+ /** SELECT multiple rows */
11
+ all(sql, ...params) {
12
+ return this.db.prepare(sql).all(...params);
13
+ }
14
+ /** SELECT single row */
15
+ get(sql, ...params) {
16
+ return this.db.prepare(sql).get(...params);
17
+ }
18
+ /** INSERT/UPDATE/DELETE */
19
+ run(sql, ...params) {
20
+ const result = this.db.prepare(sql).run(...params);
21
+ return { changes: result.changes, lastInsertRowid: result.lastInsertRowid };
22
+ }
23
+ /** Multi-statement DDL */
24
+ exec(sql) {
25
+ this.db.exec(sql);
26
+ }
27
+ /** Access the underlying better-sqlite3 instance */
28
+ get raw() {
29
+ return this.db;
30
+ }
31
+ }
32
+ let _instance = null;
33
+ export function useDb() {
34
+ if (_instance)
35
+ return _instance;
36
+ const projectDir = getProjectDir();
37
+ const config = readProjectConfig(projectDir);
38
+ const dbRelPath = config.db?.path || 'data/db.sqlite';
39
+ const dbPath = path.resolve(projectDir, dbRelPath);
40
+ // Auto-create directory
41
+ const dbDir = path.dirname(dbPath);
42
+ if (!fs.existsSync(dbDir)) {
43
+ fs.mkdirSync(dbDir, { recursive: true });
44
+ }
45
+ const db = new Database(dbPath);
46
+ db.pragma('journal_mode = WAL');
47
+ db.pragma('foreign_keys = ON');
48
+ _instance = new LumenDb(db);
49
+ // Run pending migrations
50
+ runMigrations(db, projectDir);
51
+ return _instance;
52
+ }
53
+ function runMigrations(db, projectDir) {
54
+ const migrationsDir = path.join(projectDir, 'data', 'migrations');
55
+ if (!fs.existsSync(migrationsDir))
56
+ return;
57
+ // Ensure tracking table exists
58
+ db.exec(`CREATE TABLE IF NOT EXISTS _lumen_migrations (
59
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
60
+ name TEXT NOT NULL UNIQUE,
61
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
62
+ )`);
63
+ const applied = new Set(db.prepare('SELECT name FROM _lumen_migrations').all()
64
+ .map((row) => row.name));
65
+ const files = fs.readdirSync(migrationsDir)
66
+ .filter(f => f.endsWith('.sql'))
67
+ .sort();
68
+ for (const file of files) {
69
+ if (applied.has(file))
70
+ continue;
71
+ const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf-8');
72
+ const migrate = db.transaction(() => {
73
+ db.exec(sql);
74
+ db.prepare('INSERT INTO _lumen_migrations (name) VALUES (?)').run(file);
75
+ });
76
+ migrate();
77
+ console.log(`[LumenJS] Applied migration: ${file}`);
78
+ }
79
+ }
@@ -7,6 +7,9 @@ export interface ProjectConfig {
7
7
  title: string;
8
8
  integrations: string[];
9
9
  i18n?: I18nConfig;
10
+ db?: {
11
+ path?: string;
12
+ };
10
13
  }
11
14
  /**
12
15
  * Reads the project config from lumenjs.config.ts.
@@ -52,7 +52,21 @@ export function readProjectConfig(projectDir) {
52
52
  }
53
53
  catch { /* ignore */ }
54
54
  }
55
- return { title, integrations, ...(i18n ? { i18n } : {}) };
55
+ // Parse db config
56
+ let db;
57
+ if (fs.existsSync(configPath)) {
58
+ try {
59
+ const configContent = fs.readFileSync(configPath, 'utf-8');
60
+ const dbMatch = configContent.match(/db\s*:\s*\{([\s\S]*?)\}/);
61
+ if (dbMatch) {
62
+ const block = dbMatch[1];
63
+ const pathMatch = block.match(/path\s*:\s*['"]([^'"]+)['"]/);
64
+ db = pathMatch ? { path: pathMatch[1] } : {};
65
+ }
66
+ }
67
+ catch { /* ignore */ }
68
+ }
69
+ return { title, integrations, ...(i18n ? { i18n } : {}), ...(db ? { db } : {}) };
56
70
  }
57
71
  /**
58
72
  * Reads the project title from lumenjs.config.ts (or returns default).
@@ -12,7 +12,6 @@ import { Plugin } from 'vite';
12
12
  * return { item: data, timestamp: Date.now() };
13
13
  * }
14
14
  *
15
- * @customElement('page-item')
16
15
  * export class PageItem extends LitElement {
17
16
  * @property({ type: Object }) loaderData = {};
18
17
  * render() {
@@ -15,7 +15,6 @@ import { installDomShims } from '../../shared/dom-shims.js';
15
15
  * return { item: data, timestamp: Date.now() };
16
16
  * }
17
17
  *
18
- * @customElement('page-item')
19
18
  * export class PageItem extends LitElement {
20
19
  * @property({ type: Object }) loaderData = {};
21
20
  * render() {
@@ -31,6 +30,78 @@ export function lumenLoadersPlugin(pagesDir) {
31
30
  return {
32
31
  name: 'lumenjs-loaders',
33
32
  configureServer(server) {
33
+ // SSE subscribe middleware
34
+ server.middlewares.use(async (req, res, next) => {
35
+ if (!req.url?.startsWith('/__nk_subscribe/')) {
36
+ return next();
37
+ }
38
+ const [pathname, queryString] = req.url.split('?');
39
+ // Parse query params
40
+ const query = {};
41
+ if (queryString) {
42
+ for (const pair of queryString.split('&')) {
43
+ const [key, val] = pair.split('=');
44
+ query[decodeURIComponent(key)] = decodeURIComponent(val || '');
45
+ }
46
+ }
47
+ // Handle layout subscribe: /__nk_subscribe/__layout/?__dir=<dir>
48
+ if (pathname === '/__nk_subscribe/__layout/' || pathname === '/__nk_subscribe/__layout') {
49
+ const dir = query.__dir || '';
50
+ await handleLayoutSubscribe(server, pagesDir, dir, query, req, res);
51
+ return;
52
+ }
53
+ const pagePath = pathname.replace('/__nk_subscribe', '') || '/';
54
+ // Parse URL params
55
+ let params = {};
56
+ if (query.__params) {
57
+ try {
58
+ params = JSON.parse(query.__params);
59
+ }
60
+ catch { /* ignore */ }
61
+ delete query.__params;
62
+ }
63
+ const filePath = resolvePageFile(pagesDir, pagePath);
64
+ if (!filePath) {
65
+ res.statusCode = 404;
66
+ res.end();
67
+ return;
68
+ }
69
+ if (Object.keys(params).length === 0) {
70
+ Object.assign(params, extractRouteParams(pagesDir, pagePath, filePath));
71
+ }
72
+ try {
73
+ installDomShims();
74
+ const mod = await server.ssrLoadModule(filePath);
75
+ if (!mod.subscribe || typeof mod.subscribe !== 'function') {
76
+ res.statusCode = 204;
77
+ res.end();
78
+ return;
79
+ }
80
+ // Set SSE headers
81
+ res.writeHead(200, {
82
+ 'Content-Type': 'text/event-stream',
83
+ 'Cache-Control': 'no-cache',
84
+ 'Connection': 'keep-alive',
85
+ });
86
+ const locale = query.__locale;
87
+ const push = (data) => {
88
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
89
+ };
90
+ const cleanup = mod.subscribe({ params, push, headers: req.headers, locale });
91
+ res.on('close', () => {
92
+ if (typeof cleanup === 'function')
93
+ cleanup();
94
+ });
95
+ }
96
+ catch (err) {
97
+ console.error(`[LumenJS] Subscribe error for ${pagePath}:`, err);
98
+ if (!res.headersSent) {
99
+ res.statusCode = 500;
100
+ res.end();
101
+ }
102
+ }
103
+ });
104
+ // Loader middleware
34
105
  server.middlewares.use(async (req, res, next) => {
35
106
  if (!req.url?.startsWith('/__nk_loader/')) {
36
107
  return next();
@@ -126,45 +197,26 @@ export function lumenLoadersPlugin(pagesDir) {
126
197
  // Apply to page files and layout files within the pages directory
127
198
  if (!id.startsWith(pagesDir) || !id.endsWith('.ts'))
128
199
  return;
129
- if (!code.includes('export') || !code.includes('loader'))
130
- return;
131
- const hasLoader = /export\s+(async\s+)?function\s+loader\s*\(/.test(code);
132
- if (!hasLoader)
133
- return;
134
- // Find the loader function by tracking brace depth
135
- const match = code.match(/export\s+(async\s+)?function\s+loader\s*\(/);
136
- if (!match)
200
+ const hasLoader = code.includes('export') && code.includes('loader') && /export\s+(async\s+)?function\s+loader\s*\(/.test(code);
201
+ const hasSubscribe = code.includes('export') && code.includes('subscribe') && /export\s+(async\s+)?function\s+subscribe\s*\(/.test(code);
202
+ if (!hasLoader && !hasSubscribe)
137
203
  return;
138
- const startIdx = match.index;
139
- // Skip past the function signature's closing parenthesis (handles nested braces in type annotations)
140
- let parenDepth = 1;
141
- let sigIdx = startIdx + match[0].length;
142
- while (sigIdx < code.length && parenDepth > 0) {
143
- if (code[sigIdx] === '(')
144
- parenDepth++;
145
- else if (code[sigIdx] === ')')
146
- parenDepth--;
147
- sigIdx++;
204
+ let result = code;
205
+ // Strip loader function
206
+ if (hasLoader) {
207
+ result = stripServerFunction(result, 'loader');
148
208
  }
149
- // Find the opening brace of the function body (after the closing paren and optional return type)
150
- let braceStart = code.indexOf('{', sigIdx);
151
- if (braceStart === -1)
152
- return;
153
- let depth = 1;
154
- let i = braceStart + 1;
155
- while (i < code.length && depth > 0) {
156
- if (code[i] === '{')
157
- depth++;
158
- else if (code[i] === '}')
159
- depth--;
160
- i++;
209
+ // Strip subscribe function
210
+ if (hasSubscribe) {
211
+ result = stripServerFunction(result, 'subscribe');
212
+ }
213
+ if (hasLoader) {
214
+ result += '\nexport const __nk_has_loader = true;\n';
215
+ }
216
+ if (hasSubscribe) {
217
+ result += '\nexport const __nk_has_subscribe = true;\n';
161
218
  }
162
- // Replace the entire loader function
163
- const transformed = code.substring(0, startIdx)
164
- + '// loader() — runs server-side only'
165
- + code.substring(i);
166
- const withFlag = transformed + '\nexport const __nk_has_loader = true;\n';
167
- return { code: withFlag, map: null };
219
+ return { code: result, map: null };
168
220
  },
169
221
  };
170
222
  }
@@ -310,6 +362,90 @@ function findDynamicPage(baseDir, segments) {
310
362
  }
311
363
  return null;
312
364
  }
365
+ /**
366
+ * Strip a named server-side function (loader/subscribe) from client code using brace-depth tracking.
367
+ */
368
+ function stripServerFunction(code, fnName) {
369
+ const regex = new RegExp(`export\\s+(async\\s+)?function\\s+${fnName}\\s*\\(`);
370
+ const match = code.match(regex);
371
+ if (!match)
372
+ return code;
373
+ const startIdx = match.index;
374
+ let parenDepth = 1;
375
+ let sigIdx = startIdx + match[0].length;
376
+ while (sigIdx < code.length && parenDepth > 0) {
377
+ if (code[sigIdx] === '(')
378
+ parenDepth++;
379
+ else if (code[sigIdx] === ')')
380
+ parenDepth--;
381
+ sigIdx++;
382
+ }
383
+ let braceStart = code.indexOf('{', sigIdx);
384
+ if (braceStart === -1)
385
+ return code;
386
+ let depth = 1;
387
+ let i = braceStart + 1;
388
+ while (i < code.length && depth > 0) {
389
+ if (code[i] === '{')
390
+ depth++;
391
+ else if (code[i] === '}')
392
+ depth--;
393
+ i++;
394
+ }
395
+ return code.substring(0, startIdx)
396
+ + `// ${fnName}() — runs server-side only`
397
+ + code.substring(i);
398
+ }
399
+ /**
400
+ * Handle layout subscribe requests in dev mode.
401
+ * GET /__nk_subscribe/__layout/?__dir=<dir>
402
+ */
403
+ async function handleLayoutSubscribe(server, pagesDir, dir, query, req, res) {
404
+ const layoutDir = path.join(pagesDir, dir);
405
+ let layoutFile = null;
406
+ for (const ext of ['.ts', '.js']) {
407
+ const p = path.join(layoutDir, `_layout${ext}`);
408
+ if (fs.existsSync(p)) {
409
+ layoutFile = p;
410
+ break;
411
+ }
412
+ }
413
+ if (!layoutFile) {
414
+ res.statusCode = 204;
415
+ res.end();
416
+ return;
417
+ }
418
+ try {
419
+ installDomShims();
420
+ const mod = await server.ssrLoadModule(layoutFile);
421
+ if (!mod.subscribe || typeof mod.subscribe !== 'function') {
422
+ res.statusCode = 204;
423
+ res.end();
424
+ return;
425
+ }
426
+ res.writeHead(200, {
427
+ 'Content-Type': 'text/event-stream',
428
+ 'Cache-Control': 'no-cache',
429
+ 'Connection': 'keep-alive',
430
+ });
431
+ const locale = query.__locale;
432
+ const push = (data) => {
433
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
434
+ };
435
+ const cleanup = mod.subscribe({ params: {}, push, headers: req.headers, locale });
436
+ res.on('close', () => {
437
+ if (typeof cleanup === 'function')
438
+ cleanup();
439
+ });
440
+ }
441
+ catch (err) {
442
+ console.error(`[LumenJS] Layout subscribe error for dir=${dir}:`, err);
443
+ if (!res.headersSent) {
444
+ res.statusCode = 500;
445
+ res.end();
446
+ }
447
+ }
448
+ }
313
449
  /**
314
450
  * Extract dynamic route params by comparing URL segments against [param] file path segments.
315
451
  */
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { dirToLayoutTagName, fileHasLoader, filePathToRoute, filePathToTagName } from '../../shared/utils.js';
3
+ import { dirToLayoutTagName, fileHasLoader, fileHasSubscribe, filePathToRoute, filePathToTagName } from '../../shared/utils.js';
4
4
  const VIRTUAL_MODULE_ID = 'virtual:lumenjs-routes';
5
5
  const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
6
6
  /**
@@ -98,18 +98,20 @@ export function lumenRoutesPlugin(pagesDir) {
98
98
  const routeArray = routes
99
99
  .map(r => {
100
100
  const hasLoader = fileHasLoader(r.componentPath);
101
+ const hasSubscribe = fileHasSubscribe(r.componentPath);
101
102
  const componentPath = r.componentPath.replace(/\\/g, '/');
102
103
  const chain = getLayoutChain(r.componentPath, layouts);
103
104
  let layoutsStr = '';
104
105
  if (chain.length > 0) {
105
106
  const items = chain.map(l => {
106
107
  const lHasLoader = fileHasLoader(l.filePath);
108
+ const lHasSubscribe = fileHasSubscribe(l.filePath);
107
109
  const lPath = l.filePath.replace(/\\/g, '/');
108
- return `{ tagName: ${JSON.stringify(l.tagName)}, loaderPath: ${JSON.stringify(l.dir)}${lHasLoader ? ', hasLoader: true' : ''}, load: () => import('${lPath}') }`;
110
+ return `{ tagName: ${JSON.stringify(l.tagName)}, loaderPath: ${JSON.stringify(l.dir)}${lHasLoader ? ', hasLoader: true' : ''}${lHasSubscribe ? ', hasSubscribe: true' : ''}, load: () => import('${lPath}') }`;
109
111
  });
110
112
  layoutsStr = `, layouts: [${items.join(', ')}]`;
111
113
  }
112
- return ` { path: ${JSON.stringify(r.path)}, tagName: ${JSON.stringify(r.tagName)}${hasLoader ? ', hasLoader: true' : ''}, load: () => import('${componentPath}')${layoutsStr} }`;
114
+ return ` { path: ${JSON.stringify(r.path)}, tagName: ${JSON.stringify(r.tagName)}${hasLoader ? ', hasLoader: true' : ''}${hasSubscribe ? ', hasSubscribe: true' : ''}, load: () => import('${componentPath}')${layoutsStr} }`;
113
115
  })
114
116
  .join(',\n');
115
117
  return `export const routes = [\n${routeArray}\n];\n`;
@@ -17,6 +17,7 @@ import { sourceAnnotatorPlugin } from './plugins/vite-plugin-source-annotator.js
17
17
  import { virtualModulesPlugin } from './plugins/vite-plugin-virtual-modules.js';
18
18
  import { i18nPlugin, loadTranslationsFromDisk } from './plugins/vite-plugin-i18n.js';
19
19
  import { resolveLocale } from './middleware/locale.js';
20
+ import { setProjectDir } from '../db/context.js';
20
21
  // Re-export for backwards compatibility
21
22
  export { readProjectConfig, readProjectTitle, getLumenJSNodeModules, getLumenJSDirs } from './config.js';
22
23
  export { getNuralyUIAliases, resolveNuralyUIPaths } from './nuralyui-aliases.js';
@@ -82,6 +83,7 @@ export function getSharedViteConfig(projectDir, options) {
82
83
  }
83
84
  export async function createDevServer(options) {
84
85
  const { projectDir, port, editorMode = false, base = '/' } = options;
86
+ setProjectDir(projectDir);
85
87
  const pagesDir = path.join(projectDir, 'pages');
86
88
  const apiDir = path.join(projectDir, 'api');
87
89
  const publicDir = path.join(projectDir, 'public');
@@ -1,3 +1,5 @@
1
1
  export declare function fetchLoaderData(pathname: string, params: Record<string, string>): Promise<any>;
2
2
  export declare function fetchLayoutLoaderData(dir: string): Promise<any>;
3
+ export declare function connectSubscribe(pathname: string, params: Record<string, string>): EventSource;
4
+ export declare function connectLayoutSubscribe(dir: string): EventSource;
3
5
  export declare function render404(pathname: string): string;
@@ -33,6 +33,26 @@ export async function fetchLayoutLoaderData(dir) {
33
33
  return undefined;
34
34
  return data;
35
35
  }
36
+ export function connectSubscribe(pathname, params) {
37
+ const url = new URL(`/__nk_subscribe${pathname}`, location.origin);
38
+ if (Object.keys(params).length > 0) {
39
+ url.searchParams.set('__params', JSON.stringify(params));
40
+ }
41
+ const config = getI18nConfig();
42
+ if (config) {
43
+ url.searchParams.set('__locale', getLocale());
44
+ }
45
+ return new EventSource(url.toString());
46
+ }
47
+ export function connectLayoutSubscribe(dir) {
48
+ const url = new URL('/__nk_subscribe/__layout/', location.origin);
49
+ url.searchParams.set('__dir', dir);
50
+ const config = getI18nConfig();
51
+ if (config) {
52
+ url.searchParams.set('__locale', getLocale());
53
+ }
54
+ return new EventSource(url.toString());
55
+ }
36
56
  export function render404(pathname) {
37
57
  return `<div style="display:flex;align-items:center;justify-content:center;min-height:80vh;font-family:system-ui,-apple-system,sans-serif;padding:2rem">
38
58
  <div style="text-align:center;max-width:400px">
@@ -1,6 +1,7 @@
1
1
  export interface LayoutInfo {
2
2
  tagName: string;
3
3
  hasLoader?: boolean;
4
+ hasSubscribe?: boolean;
4
5
  load?: () => Promise<any>;
5
6
  loaderPath?: string;
6
7
  }
@@ -8,6 +9,7 @@ export interface Route {
8
9
  path: string;
9
10
  tagName: string;
10
11
  hasLoader?: boolean;
12
+ hasSubscribe?: boolean;
11
13
  load?: () => Promise<any>;
12
14
  layouts?: LayoutInfo[];
13
15
  pattern?: RegExp;
@@ -23,14 +25,17 @@ export declare class NkRouter {
23
25
  private outlet;
24
26
  private currentTag;
25
27
  private currentLayoutTags;
28
+ private subscriptions;
26
29
  params: Record<string, string>;
27
30
  constructor(routes: Route[], outlet: HTMLElement, hydrate?: boolean);
28
31
  private compilePattern;
32
+ private cleanupSubscriptions;
29
33
  navigate(pathname: string, pushState?: boolean): Promise<void>;
30
34
  private matchRoute;
31
35
  private renderRoute;
32
36
  private buildLayoutTree;
33
37
  private createPageElement;
38
+ private findPageElement;
34
39
  private handleLinkClick;
35
40
  /** Strip locale prefix from a path for internal route matching. */
36
41
  private stripLocale;
@@ -1,4 +1,4 @@
1
- import { fetchLoaderData, fetchLayoutLoaderData, render404 } from './router-data.js';
1
+ import { fetchLoaderData, fetchLayoutLoaderData, connectSubscribe, connectLayoutSubscribe, render404 } from './router-data.js';
2
2
  import { hydrateInitialRoute } from './router-hydration.js';
3
3
  import { getI18nConfig, getLocale, stripLocalePrefix, buildLocalePath } from './i18n.js';
4
4
  /**
@@ -12,6 +12,7 @@ export class NkRouter {
12
12
  this.outlet = null;
13
13
  this.currentTag = null;
14
14
  this.currentLayoutTags = [];
15
+ this.subscriptions = [];
15
16
  this.params = {};
16
17
  this.outlet = outlet;
17
18
  this.routes = routes.map(r => ({
@@ -43,7 +44,14 @@ export class NkRouter {
43
44
  });
44
45
  return { pattern: new RegExp(`^${pattern}$`), paramNames };
45
46
  }
47
+ cleanupSubscriptions() {
48
+ for (const es of this.subscriptions) {
49
+ es.close();
50
+ }
51
+ this.subscriptions = [];
52
+ }
46
53
  async navigate(pathname, pushState = true) {
54
+ this.cleanupSubscriptions();
47
55
  const match = this.matchRoute(pathname);
48
56
  if (!match) {
49
57
  if (this.outlet)
@@ -55,6 +63,7 @@ export class NkRouter {
55
63
  if (pushState) {
56
64
  const localePath = this.withLocale(pathname);
57
65
  history.pushState(null, '', localePath);
66
+ window.scrollTo(0, 0);
58
67
  }
59
68
  this.params = match.params;
60
69
  // Lazy-load the page component if not yet registered
@@ -96,6 +105,28 @@ export class NkRouter {
96
105
  }
97
106
  }
98
107
  this.renderRoute(match.route, loaderData, layouts, layoutDataList);
108
+ // Set up SSE subscriptions for page
109
+ if (match.route.hasSubscribe) {
110
+ const es = connectSubscribe(pathname, match.params);
111
+ es.onmessage = (e) => {
112
+ const pageEl = this.findPageElement(match.route.tagName);
113
+ if (pageEl)
114
+ pageEl.liveData = JSON.parse(e.data);
115
+ };
116
+ this.subscriptions.push(es);
117
+ }
118
+ // Set up SSE subscriptions for layouts
119
+ for (const layout of layouts) {
120
+ if (layout.hasSubscribe) {
121
+ const es = connectLayoutSubscribe(layout.loaderPath || '');
122
+ es.onmessage = (e) => {
123
+ const layoutEl = this.outlet?.querySelector(layout.tagName);
124
+ if (layoutEl)
125
+ layoutEl.liveData = JSON.parse(e.data);
126
+ };
127
+ this.subscriptions.push(es);
128
+ }
129
+ }
99
130
  }
100
131
  matchRoute(pathname) {
101
132
  for (const route of this.routes) {
@@ -194,6 +225,11 @@ export class NkRouter {
194
225
  }
195
226
  return el;
196
227
  }
228
+ findPageElement(tagName) {
229
+ if (!this.outlet)
230
+ return null;
231
+ return this.outlet.querySelector(tagName) ?? this.outlet.querySelector(`${tagName}:last-child`);
232
+ }
197
233
  handleLinkClick(event) {
198
234
  const path = event.composedPath();
199
235
  const anchor = path.find((el) => el instanceof HTMLElement && el.tagName === 'A');
@@ -2,11 +2,13 @@ export interface ManifestLayout {
2
2
  dir: string;
3
3
  module: string;
4
4
  hasLoader: boolean;
5
+ hasSubscribe: boolean;
5
6
  }
6
7
  export interface ManifestRoute {
7
8
  path: string;
8
9
  module: string;
9
10
  hasLoader: boolean;
11
+ hasSubscribe: boolean;
10
12
  tagName?: string;
11
13
  layouts?: string[];
12
14
  }
@@ -14,7 +14,7 @@ export declare function stripOuterLitMarkers(html: string): string;
14
14
  export declare function dirToLayoutTagName(dir: string): string;
15
15
  /**
16
16
  * Find the custom element tag name from a page module.
17
- * Pages use @customElement('page-xxx') which registers the element.
17
+ * Pages are auto-registered by the auto-define plugin based on file path.
18
18
  */
19
19
  export declare function findTagName(mod: Record<string, any>): string | null;
20
20
  /**
@@ -43,6 +43,10 @@ export declare function escapeHtml(text: string): string;
43
43
  * Check if a page/layout file exports a loader() function.
44
44
  */
45
45
  export declare function fileHasLoader(filePath: string): boolean;
46
+ /**
47
+ * Check if a page/layout file exports a subscribe() function.
48
+ */
49
+ export declare function fileHasSubscribe(filePath: string): boolean;
46
50
  /**
47
51
  * Convert a file path (relative to pages/) to a route path.
48
52
  */
@@ -29,7 +29,7 @@ export function dirToLayoutTagName(dir) {
29
29
  }
30
30
  /**
31
31
  * Find the custom element tag name from a page module.
32
- * Pages use @customElement('page-xxx') which registers the element.
32
+ * Pages are auto-registered by the auto-define plugin based on file path.
33
33
  */
34
34
  export function findTagName(mod) {
35
35
  for (const key of Object.keys(mod)) {
@@ -109,6 +109,18 @@ export function fileHasLoader(filePath) {
109
109
  return false;
110
110
  }
111
111
  }
112
+ /**
113
+ * Check if a page/layout file exports a subscribe() function.
114
+ */
115
+ export function fileHasSubscribe(filePath) {
116
+ try {
117
+ const content = fs.readFileSync(filePath, 'utf-8');
118
+ return /export\s+(async\s+)?function\s+subscribe\s*\(/.test(content);
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ }
112
124
  /**
113
125
  * Convert a file path (relative to pages/) to a route path.
114
126
  */
package/package.json CHANGED
@@ -1,14 +1,19 @@
1
1
  {
2
2
  "name": "@nuraly/lumenjs",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Full-stack Lit web component framework with file-based routing, server loaders, SSR, and API routes",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
7
7
  "bin": {
8
8
  "lumenjs": "dist/cli.js"
9
9
  },
10
+ "exports": {
11
+ ".": "./dist/cli.js",
12
+ "./db": "./dist/db/index.js"
13
+ },
10
14
  "files": [
11
15
  "dist",
16
+ "templates",
12
17
  "README.md"
13
18
  ],
14
19
  "scripts": {
@@ -42,11 +47,13 @@
42
47
  "license": "MIT",
43
48
  "dependencies": {
44
49
  "@lit-labs/ssr": "^3.2.0",
50
+ "better-sqlite3": "^11.0.0",
45
51
  "glob": "^10.3.0",
46
52
  "lit": "^3.1.0",
47
53
  "vite": "^5.4.0"
48
54
  },
49
55
  "devDependencies": {
56
+ "@types/better-sqlite3": "^7.6.0",
50
57
  "@types/node": "^20.14.2",
51
58
  "@vitest/coverage-v8": "^4.0.18",
52
59
  "typescript": "^5.4.5",
@@ -0,0 +1,20 @@
1
+ import { useDb } from '@nuraly/lumenjs/db';
2
+
3
+ export function GET() {
4
+ const db = useDb();
5
+ const posts = db.all('SELECT id, title, slug, content, date FROM posts ORDER BY date DESC');
6
+ return { posts };
7
+ }
8
+
9
+ export function POST(req: { body: any }) {
10
+ const { title, slug, content } = req.body;
11
+ if (!title || !slug || !content) {
12
+ throw { status: 400, message: 'title, slug, and content are required' };
13
+ }
14
+ const db = useDb();
15
+ const result = db.run(
16
+ 'INSERT INTO posts (title, slug, content) VALUES (?, ?, ?)',
17
+ title, slug, content
18
+ );
19
+ return { id: result.lastInsertRowid, title, slug, content };
20
+ }
@@ -0,0 +1,12 @@
1
+ CREATE TABLE IF NOT EXISTS posts (
2
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3
+ title TEXT NOT NULL,
4
+ slug TEXT NOT NULL UNIQUE,
5
+ content TEXT NOT NULL,
6
+ date TEXT NOT NULL DEFAULT (date('now'))
7
+ );
8
+
9
+ INSERT INTO posts (title, slug, content, date) VALUES
10
+ ('Hello World', 'hello-world', 'Welcome to your new LumenJS blog! This post was loaded from SQLite.', '2025-01-15'),
11
+ ('Getting Started with LumenJS', 'getting-started', 'LumenJS makes it easy to build full-stack web apps with Lit web components and file-based routing.', '2025-01-20'),
12
+ ('SQLite Persistence', 'sqlite-persistence', 'LumenJS includes built-in SQLite support via better-sqlite3. Just use useDb() in your loaders and API routes.', '2025-01-25');
@@ -0,0 +1,39 @@
1
+ import { LitElement, html, css } from 'lit';
2
+ import { customElement, property } from 'lit/decorators.js';
3
+ import { useDb } from '@nuraly/lumenjs/db';
4
+
5
+ export function loader() {
6
+ const db = useDb();
7
+ const posts = db.all('SELECT id, title, slug, content, date FROM posts ORDER BY date DESC');
8
+ return { posts };
9
+ }
10
+
11
+ @customElement('page-home')
12
+ export class PageHome extends LitElement {
13
+ @property({ type: Object }) data: any;
14
+
15
+ static styles = css`
16
+ :host { display: block; max-width: 720px; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif; }
17
+ h1 { font-size: 2rem; margin-bottom: 1.5rem; }
18
+ .post { margin-bottom: 1.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid #eee; }
19
+ .post h2 { margin: 0 0 0.25rem; }
20
+ .post a { color: #0066cc; text-decoration: none; }
21
+ .post a:hover { text-decoration: underline; }
22
+ .post .date { color: #666; font-size: 0.875rem; }
23
+ .post p { color: #333; margin: 0.5rem 0 0; }
24
+ `;
25
+
26
+ render() {
27
+ const posts = this.data?.posts || [];
28
+ return html`
29
+ <h1>Blog</h1>
30
+ ${posts.map((post: any) => html`
31
+ <div class="post">
32
+ <h2><a href="/posts/${post.slug}">${post.title}</a></h2>
33
+ <span class="date">${post.date}</span>
34
+ <p>${post.content}</p>
35
+ </div>
36
+ `)}
37
+ `;
38
+ }
39
+ }
@@ -0,0 +1,35 @@
1
+ import { LitElement, html, css } from 'lit';
2
+ import { customElement, property } from 'lit/decorators.js';
3
+ import { useDb } from '@nuraly/lumenjs/db';
4
+
5
+ export function loader({ params }: { params: { slug: string } }) {
6
+ const db = useDb();
7
+ const post = db.get('SELECT id, title, slug, content, date FROM posts WHERE slug = ?', params.slug);
8
+ return { post: post || null };
9
+ }
10
+
11
+ @customElement('page-post')
12
+ export class PagePost extends LitElement {
13
+ @property({ type: Object }) data: any;
14
+
15
+ static styles = css`
16
+ :host { display: block; max-width: 720px; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif; }
17
+ a { color: #0066cc; text-decoration: none; }
18
+ a:hover { text-decoration: underline; }
19
+ .date { color: #666; font-size: 0.875rem; }
20
+ .content { margin-top: 1rem; line-height: 1.6; color: #333; }
21
+ `;
22
+
23
+ render() {
24
+ const post = this.data?.post;
25
+ if (!post) {
26
+ return html`<p>Post not found. <a href="/">Back to blog</a></p>`;
27
+ }
28
+ return html`
29
+ <a href="/">&larr; Back to blog</a>
30
+ <h1>${post.title}</h1>
31
+ <span class="date">${post.date}</span>
32
+ <div class="content">${post.content}</div>
33
+ `;
34
+ }
35
+ }
@@ -0,0 +1,7 @@
1
+ import { useDb } from '@nuraly/lumenjs/db';
2
+
3
+ export function GET() {
4
+ const db = useDb();
5
+ const stats = db.all('SELECT id, label, value, unit, updated_at FROM stats ORDER BY id');
6
+ return { stats };
7
+ }
@@ -0,0 +1,13 @@
1
+ CREATE TABLE IF NOT EXISTS stats (
2
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3
+ label TEXT NOT NULL UNIQUE,
4
+ value REAL NOT NULL,
5
+ unit TEXT NOT NULL DEFAULT '',
6
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
7
+ );
8
+
9
+ INSERT INTO stats (label, value, unit) VALUES
10
+ ('Total Users', 1284, 'users'),
11
+ ('Revenue', 42500, 'USD'),
12
+ ('Active Sessions', 89, 'sessions'),
13
+ ('Uptime', 99.97, '%');
@@ -0,0 +1,41 @@
1
+ import { LitElement, html, css } from 'lit';
2
+ import { customElement, property } from 'lit/decorators.js';
3
+ import { useDb } from '@nuraly/lumenjs/db';
4
+
5
+ export function loader() {
6
+ const db = useDb();
7
+ const stats = db.all('SELECT id, label, value, unit, updated_at FROM stats ORDER BY id');
8
+ return { stats };
9
+ }
10
+
11
+ @customElement('page-dashboard')
12
+ export class PageDashboard extends LitElement {
13
+ @property({ type: Object }) data: any;
14
+
15
+ static styles = css`
16
+ :host { display: block; max-width: 960px; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif; }
17
+ h1 { font-size: 2rem; margin-bottom: 1.5rem; }
18
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
19
+ .card { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 1.5rem; }
20
+ .card .label { font-size: 0.875rem; color: #6b7280; margin-bottom: 0.25rem; }
21
+ .card .value { font-size: 1.75rem; font-weight: 600; color: #111827; }
22
+ .card .unit { font-size: 0.75rem; color: #9ca3af; margin-left: 0.25rem; }
23
+ `;
24
+
25
+ render() {
26
+ const stats = this.data?.stats || [];
27
+ return html`
28
+ <h1>Dashboard</h1>
29
+ <div class="grid">
30
+ ${stats.map((stat: any) => html`
31
+ <div class="card">
32
+ <div class="label">${stat.label}</div>
33
+ <div class="value">
34
+ ${stat.value}<span class="unit">${stat.unit}</span>
35
+ </div>
36
+ </div>
37
+ `)}
38
+ </div>
39
+ `;
40
+ }
41
+ }