@nuraly/lumenjs 0.1.1 → 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>`;
@@ -105,6 +105,7 @@ Loaders run server-side on initial load (SSR) and are fetched via `/__nk_loader/
105
105
  | `query` | `Record<string, string>` | Query string parameters |
106
106
  | `url` | `string` | Request pathname |
107
107
  | `headers` | `Record<string, any>` | Request headers |
108
+ | `locale` | `string` | Current locale (when i18n is configured) |
108
109
 
109
110
  ### Redirects
110
111
 
@@ -116,13 +117,75 @@ export async function loader({ headers }) {
116
117
  }
117
118
  ```
118
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
+
119
183
  ## Nested Layouts
120
184
 
121
185
  Create `_layout.ts` in any directory to wrap all pages in that directory and its subdirectories.
122
186
 
123
187
  ```typescript
124
188
  // pages/_layout.ts
125
- @customElement('layout-root')
126
189
  export class RootLayout extends LitElement {
127
190
  render() {
128
191
  return html`
@@ -146,9 +209,9 @@ export async function loader({ headers }) {
146
209
  return { user };
147
210
  }
148
211
 
149
- @customElement('layout-dashboard')
150
212
  export class DashboardLayout extends LitElement {
151
- @property({ type: Object }) loaderData: any = {};
213
+ static properties = { loaderData: { type: Object } };
214
+ loaderData: any = {};
152
215
 
153
216
  render() {
154
217
  return html`
@@ -223,6 +286,80 @@ Pages with loaders are automatically server-rendered using `@lit-labs/ssr`:
223
286
 
224
287
  Pages without loaders render client-side only (SPA mode). If SSR fails, LumenJS falls back gracefully to client-side rendering.
225
288
 
289
+ ## Internationalization (i18n)
290
+
291
+ LumenJS has built-in i18n support with URL-prefix-based locale routing.
292
+
293
+ ### Setup
294
+
295
+ 1. Add i18n config to `lumenjs.config.ts`:
296
+
297
+ ```typescript
298
+ export default {
299
+ title: 'My App',
300
+ i18n: {
301
+ locales: ['en', 'fr'],
302
+ defaultLocale: 'en',
303
+ prefixDefault: false, // / instead of /en/
304
+ },
305
+ };
306
+ ```
307
+
308
+ 2. Create translation files in `locales/`:
309
+
310
+ ```
311
+ my-app/
312
+ ├── locales/
313
+ │ ├── en.json # { "home.title": "Welcome", "nav.docs": "Docs" }
314
+ │ └── fr.json # { "home.title": "Bienvenue", "nav.docs": "Documentation" }
315
+ ├── pages/
316
+ └── lumenjs.config.ts
317
+ ```
318
+
319
+ ### Usage
320
+
321
+ ```typescript
322
+ import { t, getLocale, setLocale } from '@lumenjs/i18n';
323
+
324
+ export class PageIndex extends LitElement {
325
+ render() {
326
+ return html`<h1>${t('home.title')}</h1>`;
327
+ }
328
+ }
329
+ ```
330
+
331
+ ### API
332
+
333
+ | Function | Description |
334
+ |---|---|
335
+ | `t(key)` | Returns the translated string for the key, or the key itself if not found |
336
+ | `getLocale()` | Returns the current locale string |
337
+ | `setLocale(locale)` | Switches locale — sets cookie, navigates to the localized URL |
338
+
339
+ ### Locale Resolution
340
+
341
+ Locale is resolved in this order:
342
+
343
+ 1. URL prefix: `/fr/about` → locale `fr`, pathname `/about`
344
+ 2. Cookie `nk-locale` (set on explicit locale switch)
345
+ 3. `Accept-Language` header (SSR)
346
+ 4. Config `defaultLocale`
347
+
348
+ ### URL Routing
349
+
350
+ With `prefixDefault: false`, the default locale uses clean URLs:
351
+
352
+ | URL | Locale | Page |
353
+ |---|---|---|
354
+ | `/about` | `en` (default) | `pages/about.ts` |
355
+ | `/fr/about` | `fr` | `pages/about.ts` |
356
+
357
+ Routes are locale-agnostic — you don't need separate pages per locale. The router strips the locale prefix before matching and prepends it during navigation.
358
+
359
+ ### SSR
360
+
361
+ Translations are server-rendered. The `<html lang="...">` attribute is set dynamically, and translations are inlined in the response for hydration without flash of untranslated content.
362
+
226
363
  ## Integrations
227
364
 
228
365
  ### Tailwind CSS
@@ -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() {