@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.
@@ -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');
@@ -110,6 +112,17 @@ export async function createDevServer(options) {
110
112
  name: 'lumenjs-index-html',
111
113
  configureServer(server) {
112
114
  server.middlewares.use((req, res, next) => {
115
+ // Guard against malformed percent-encoded URLs that crash Vite's transformIndexHtml
116
+ if (req.url) {
117
+ try {
118
+ decodeURIComponent(req.url);
119
+ }
120
+ catch {
121
+ res.statusCode = 400;
122
+ res.end('Bad Request');
123
+ return;
124
+ }
125
+ }
113
126
  if (req.url && !req.url.startsWith('/@') && !req.url.startsWith('/node_modules') &&
114
127
  !req.url.startsWith('/api/') && !req.url.startsWith('/__nk_loader/') &&
115
128
  !req.url.startsWith('/__nk_i18n/') &&
@@ -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,20 +1,28 @@
1
1
  {
2
2
  "name": "@nuraly/lumenjs",
3
- "version": "0.1.1",
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": {
15
20
  "build": "tsc",
16
21
  "dev": "tsc --watch",
17
- "prepublishOnly": "tsc"
22
+ "prepublishOnly": "tsc",
23
+ "test": "vitest run",
24
+ "test:watch": "vitest",
25
+ "test:coverage": "vitest run --coverage"
18
26
  },
19
27
  "keywords": [
20
28
  "lit",
@@ -38,14 +46,18 @@
38
46
  "author": "labidiaymen <labidi@aymen.co>",
39
47
  "license": "MIT",
40
48
  "dependencies": {
41
- "vite": "^5.4.0",
42
- "lit": "^3.1.0",
43
49
  "@lit-labs/ssr": "^3.2.0",
44
- "glob": "^10.3.0"
50
+ "better-sqlite3": "^11.0.0",
51
+ "glob": "^10.3.0",
52
+ "lit": "^3.1.0",
53
+ "vite": "^5.4.0"
45
54
  },
46
55
  "devDependencies": {
56
+ "@types/better-sqlite3": "^7.6.0",
57
+ "@types/node": "^20.14.2",
58
+ "@vitest/coverage-v8": "^4.0.18",
47
59
  "typescript": "^5.4.5",
48
- "@types/node": "^20.14.2"
60
+ "vitest": "^4.0.18"
49
61
  },
50
62
  "engines": {
51
63
  "node": ">=18"
@@ -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
+ }