@nuraly/lumenjs 0.2.0 → 0.5.0

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.
Files changed (35) hide show
  1. package/README.md +80 -7
  2. package/dist/build/build-markdown.d.ts +15 -0
  3. package/dist/build/build-markdown.js +90 -0
  4. package/dist/build/build-server.js +13 -2
  5. package/dist/build/build.js +34 -2
  6. package/dist/build/scan.js +4 -1
  7. package/dist/build/serve-loaders.js +12 -2
  8. package/dist/build/serve-ssr.js +12 -3
  9. package/dist/build/serve-static.js +2 -1
  10. package/dist/build/serve.js +1 -1
  11. package/dist/communication/server.js +1 -0
  12. package/dist/communication/signaling.d.ts +2 -0
  13. package/dist/communication/signaling.js +41 -0
  14. package/dist/dev-server/plugins/vite-plugin-llms.js +1 -0
  15. package/dist/dev-server/plugins/vite-plugin-loaders.d.ts +4 -3
  16. package/dist/dev-server/plugins/vite-plugin-loaders.js +20 -5
  17. package/dist/dev-server/ssr-render.js +15 -3
  18. package/dist/editor/ai/types.d.ts +1 -1
  19. package/dist/editor/ai/types.js +2 -2
  20. package/dist/llms/generate.d.ts +15 -1
  21. package/dist/llms/generate.js +54 -44
  22. package/dist/runtime/communication.d.ts +65 -36
  23. package/dist/runtime/communication.js +117 -57
  24. package/dist/runtime/router.js +3 -3
  25. package/dist/runtime/webrtc.d.ts +8 -1
  26. package/dist/runtime/webrtc.js +49 -15
  27. package/dist/shared/html-to-markdown.d.ts +6 -0
  28. package/dist/shared/html-to-markdown.js +73 -0
  29. package/dist/shared/utils.js +8 -0
  30. package/package.json +2 -2
  31. package/templates/blog/pages/index.ts +3 -3
  32. package/templates/blog/pages/posts/[slug].ts +17 -6
  33. package/templates/blog/pages/tag/[tag].ts +6 -6
  34. package/templates/dashboard/pages/index.ts +7 -7
  35. package/templates/default/pages/index.ts +3 -3
package/README.md CHANGED
@@ -7,6 +7,8 @@
7
7
 
8
8
  # LumenJS
9
9
 
10
+ > **Mirror**: This repository is a read-only mirror of the original private repository, automatically synced on push to main.
11
+
10
12
  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, i18n, and a visual editor — all powered by Vite.
11
13
 
12
14
  ## Getting Started
@@ -68,6 +70,8 @@ export class PageIndex extends LitElement {
68
70
 
69
71
  Export a `loader()` to fetch data server-side. It runs on SSR and via `/__nk_loader/<path>` during client-side navigation. Automatically stripped from client bundles.
70
72
 
73
+ Declare each returned key as its own property — the framework spreads loader data onto the element automatically.
74
+
71
75
  ```typescript
72
76
  // pages/blog/[slug].ts
73
77
  export async function loader({ params }) {
@@ -77,15 +81,45 @@ export async function loader({ params }) {
77
81
  }
78
82
 
79
83
  export class BlogPost extends LitElement {
80
- static properties = { loaderData: { type: Object } };
81
- loaderData: any = {};
84
+ static properties = { post: { type: Object } };
85
+ post: any = null;
82
86
 
83
87
  render() {
84
- return html`<h1>${this.loaderData.post?.title}</h1>`;
88
+ return html`<h1>${this.post?.title}</h1>`;
85
89
  }
86
90
  }
87
91
  ```
88
92
 
93
+ ### Splitting large loaders
94
+
95
+ For folder routes (`pages/foo/index.ts`), you can move the loader into a co-located `_loader.ts` file. The framework discovers it automatically — no import or wrapper needed in the page file.
96
+
97
+ ```
98
+ pages/
99
+ └── dashboard/
100
+ ├── index.ts ← page component only
101
+ └── _loader.ts ← auto-discovered loader
102
+ ```
103
+
104
+ ```typescript
105
+ // pages/dashboard/_loader.ts
106
+ export async function loader({ user }) {
107
+ const stats = await db.getStats(user.id);
108
+ return { stats };
109
+ }
110
+ ```
111
+
112
+ ```typescript
113
+ // pages/dashboard/index.ts — no loader here at all
114
+ export class PageDashboard extends LitElement {
115
+ static properties = { stats: { type: Array } };
116
+ stats = [];
117
+ render() { ... }
118
+ }
119
+ ```
120
+
121
+ Both patterns work side by side — the inline loader always takes precedence. Only folder routes (`index.ts`) support `_loader.ts` discovery; flat pages (`about.ts`) keep the loader inline.
122
+
89
123
  ### Loader Context
90
124
 
91
125
  | Property | Type | Description |
@@ -110,16 +144,20 @@ export function subscribe({ push }) {
110
144
  }
111
145
 
112
146
  export class PageDashboard extends LitElement {
113
- static properties = { liveData: { type: Object } };
114
- liveData: any = null;
147
+ static properties = {
148
+ time: { type: String },
149
+ count: { type: Number },
150
+ };
151
+ time = '';
152
+ count = 0;
115
153
 
116
154
  render() {
117
- return html`<p>Server time: ${this.liveData?.time}</p>`;
155
+ return html`<p>Server time: ${this.time}</p>`;
118
156
  }
119
157
  }
120
158
  ```
121
159
 
122
- Return a cleanup function — it runs when the client disconnects.
160
+ Each key from `push()` is spread as an individual property on the component — same as loader data. Return a cleanup function — it runs when the client disconnects.
123
161
 
124
162
  ## Layouts
125
163
 
@@ -186,6 +224,41 @@ npx lumenjs add tailwind # Tailwind CSS via @tailwindcss/vite
186
224
  export default { integrations: ['nuralyui'] };
187
225
  ```
188
226
 
227
+ ## Visual Editor
228
+
229
+ Start the dev server with `--editor-mode` to edit pages visually in the browser:
230
+
231
+ ```bash
232
+ npx lumenjs dev --editor-mode
233
+ ```
234
+
235
+ Click elements to select them, edit properties and styles in the side panel, double-click text to edit inline, or ask the AI assistant to make changes for you. Everything saves directly to your source files.
236
+
237
+ ### AI Backend
238
+
239
+ The editor includes an AI assistant that can modify your components. It supports three backends:
240
+
241
+ **Claude Code** (recommended) — uses your Pro/Max subscription, no API key needed:
242
+
243
+ ```bash
244
+ npm install -g @anthropic-ai/claude-code
245
+ claude login
246
+ npm install @anthropic-ai/claude-agent-sdk
247
+ npx lumenjs dev --editor-mode
248
+ ```
249
+
250
+ **OpenCode** — coding agent server, configure it with DeepSeek or any LLM provider:
251
+
252
+ ```bash
253
+ npm install -g opencode
254
+ opencode serve # terminal 1
255
+ npx lumenjs dev --editor-mode # terminal 2
256
+ ```
257
+
258
+ Configure the connection: `OPENCODE_URL` (default `http://localhost:4096`) and `OPENCODE_SERVER_PASSWORD` if auth is required.
259
+
260
+ Set `AI_BACKEND` to force a specific backend (`claude-code` or `opencode`). Without it, the editor auto-detects: Claude Code (if CLI logged in) → OpenCode (fallback).
261
+
189
262
  ## CLI
190
263
 
191
264
  ```
@@ -0,0 +1,15 @@
1
+ import type { BuildManifest } from '../shared/types.js';
2
+ import type { PageEntry } from './scan.js';
3
+ export interface MarkdownOptions {
4
+ serverDir: string;
5
+ clientDir: string;
6
+ pagesDir: string;
7
+ pageEntries: PageEntry[];
8
+ manifest: BuildManifest;
9
+ }
10
+ /**
11
+ * Generate static .md files for each page by SSR-rendering the component
12
+ * and converting the HTML to markdown. Written to clientDir so the
13
+ * production static file server picks them up (e.g., /docs/routing.md).
14
+ */
15
+ export declare function generateMarkdownPages(opts: MarkdownOptions): Promise<void>;
@@ -0,0 +1,90 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+ import { pathToFileURL } from 'url';
4
+ import { stripOuterLitMarkers, patchLoaderDataSpread } from '../shared/utils.js';
5
+ import { installDomShims } from '../shared/dom-shims.js';
6
+ import { htmlToMarkdown } from '../shared/html-to-markdown.js';
7
+ /**
8
+ * Generate static .md files for each page by SSR-rendering the component
9
+ * and converting the HTML to markdown. Written to clientDir so the
10
+ * production static file server picks them up (e.g., /docs/routing.md).
11
+ */
12
+ export async function generateMarkdownPages(opts) {
13
+ const { serverDir, clientDir, pagesDir, pageEntries, manifest } = opts;
14
+ // Skip if no pages
15
+ if (pageEntries.length === 0)
16
+ return;
17
+ // Load SSR runtime
18
+ const ssrRuntimePath = pathToFileURL(path.join(serverDir, 'ssr-runtime.js')).href;
19
+ let ssrRuntime;
20
+ try {
21
+ ssrRuntime = await import(ssrRuntimePath);
22
+ }
23
+ catch {
24
+ // No SSR runtime — skip markdown generation
25
+ return;
26
+ }
27
+ const { render, html, unsafeStatic } = ssrRuntime;
28
+ installDomShims();
29
+ let count = 0;
30
+ for (const page of pageEntries) {
31
+ // Skip dynamic routes (e.g., /blog/:slug)
32
+ if (page.routePath.includes(':'))
33
+ continue;
34
+ const moduleName = `pages/${page.name.replace(/\[(\w+)\]/g, '_$1_')}.js`;
35
+ let modulePath = path.join(serverDir, moduleName);
36
+ if (!fs.existsSync(modulePath)) {
37
+ modulePath = path.join(serverDir, moduleName.replace(/\[/g, '_').replace(/\]/g, '_'));
38
+ }
39
+ if (!fs.existsSync(modulePath))
40
+ continue;
41
+ try {
42
+ const mod = await import(pathToFileURL(modulePath).href);
43
+ // Run loader if present
44
+ let loaderData = undefined;
45
+ if (mod.loader && typeof mod.loader === 'function') {
46
+ loaderData = await mod.loader({ params: {}, query: {}, url: page.routePath, headers: {} });
47
+ if (loaderData?.__nk_redirect)
48
+ continue;
49
+ }
50
+ // Get tag name from manifest
51
+ const route = manifest.routes.find(r => r.path === page.routePath);
52
+ const tagName = route?.tagName;
53
+ if (!tagName)
54
+ continue;
55
+ patchLoaderDataSpread(tagName);
56
+ const tag = unsafeStatic(tagName);
57
+ const template = loaderData !== undefined
58
+ ? html `<${tag} .loaderData=${loaderData}></${tag}>`
59
+ : html `<${tag}></${tag}>`;
60
+ let rendered = '';
61
+ for (const chunk of render(template)) {
62
+ rendered += typeof chunk === 'string' ? chunk : String(chunk);
63
+ }
64
+ rendered = stripOuterLitMarkers(rendered);
65
+ const markdown = htmlToMarkdown(rendered);
66
+ if (!markdown.trim())
67
+ continue;
68
+ // Write to clientDir so static serving picks it up
69
+ // /docs/routing → clientDir/docs/routing.md
70
+ const mdPath = page.routePath === '/'
71
+ ? path.join(clientDir, 'index.md')
72
+ : path.join(clientDir, page.routePath + '.md');
73
+ // Skip if user provided their own .md file (copied from public/ during client build)
74
+ if (fs.existsSync(mdPath))
75
+ continue;
76
+ const mdDir = path.dirname(mdPath);
77
+ if (!fs.existsSync(mdDir))
78
+ fs.mkdirSync(mdDir, { recursive: true });
79
+ fs.writeFileSync(mdPath, markdown);
80
+ count++;
81
+ }
82
+ catch (err) {
83
+ // Skip pages that fail to render
84
+ console.warn(`[LumenJS] Markdown generation skipped for ${page.routePath}: ${err?.message}`);
85
+ }
86
+ }
87
+ if (count > 0) {
88
+ console.log(`[LumenJS] Generated ${count} markdown page(s) for /llms.txt`);
89
+ }
90
+ }
@@ -6,9 +6,20 @@ export async function buildServer(opts) {
6
6
  console.log('[LumenJS] Building server bundle...');
7
7
  // Collect server entry points (pages with loaders + layouts with loaders + API routes)
8
8
  const serverEntries = {};
9
+ // Include all pages in server build (enables SSR for .md endpoints)
9
10
  for (const entry of pageEntries) {
10
- if (entry.hasLoader || entry.hasSubscribe || entry.prerender) {
11
- serverEntries[`pages/${entry.name}`] = entry.filePath;
11
+ serverEntries[`pages/${entry.name}`] = entry.filePath;
12
+ // Co-located _loader.ts for folder index pages
13
+ if (path.basename(entry.filePath).replace(/\.(ts|js)$/, '') === 'index') {
14
+ const dir = path.dirname(entry.filePath);
15
+ for (const ext of ['.ts', '.js']) {
16
+ const loaderFile = path.join(dir, `_loader${ext}`);
17
+ if (fs.existsSync(loaderFile)) {
18
+ const entryDir = path.dirname(entry.name);
19
+ serverEntries[`pages/${entryDir}/_loader`] = loaderFile;
20
+ break;
21
+ }
22
+ }
12
23
  }
13
24
  }
14
25
  for (const entry of layoutEntries) {
@@ -2,11 +2,13 @@ import path from 'path';
2
2
  import fs from 'fs';
3
3
  import { getSharedViteConfig } from '../dev-server/server.js';
4
4
  import { readProjectConfig } from '../dev-server/config.js';
5
- import { filePathToTagName } from '../shared/utils.js';
5
+ import { filePathToTagName, fileGetApiMethods } from '../shared/utils.js';
6
6
  import { scanPages, scanLayouts, scanApiRoutes, scanMiddleware, getLayoutDirsForPage } from './scan.js';
7
7
  import { buildClient } from './build-client.js';
8
8
  import { buildServer } from './build-server.js';
9
9
  import { prerenderPages } from './build-prerender.js';
10
+ import { generateMarkdownPages } from './build-markdown.js';
11
+ import { generateLlmsTxt } from '../llms/generate.js';
10
12
  export async function buildProject(options) {
11
13
  const { projectDir } = options;
12
14
  const outDir = options.outDir || path.join(projectDir, '.lumenjs');
@@ -79,7 +81,7 @@ export async function buildProject(options) {
79
81
  const relPath = path.relative(pagesDir, e.filePath).replace(/\\/g, '/');
80
82
  return {
81
83
  path: e.routePath,
82
- module: (e.hasLoader || e.hasSubscribe || e.hasSocket || e.prerender) ? `pages/${e.name.replace(/\[(\w+)\]/g, '_$1_')}.js` : '',
84
+ module: `pages/${e.name.replace(/\[(\w+)\]/g, '_$1_')}.js`,
83
85
  hasLoader: e.hasLoader,
84
86
  hasSubscribe: e.hasSubscribe,
85
87
  tagName: filePathToTagName(relPath),
@@ -114,6 +116,28 @@ export async function buildProject(options) {
114
116
  prefetch: prefetchStrategy,
115
117
  };
116
118
  fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
119
+ // --- Generate llms.txt ---
120
+ const publicLlms = path.join(publicDir, 'llms.txt');
121
+ if (!fs.existsSync(publicLlms)) {
122
+ const llmsPages = pageEntries.map(e => ({
123
+ path: e.routePath,
124
+ hasLoader: e.hasLoader,
125
+ hasSubscribe: e.hasSubscribe,
126
+ }));
127
+ const llmsApiRoutes = apiEntries.map(e => ({
128
+ path: e.routePath,
129
+ methods: fileGetApiMethods(e.filePath),
130
+ })).filter(r => r.methods.length > 0);
131
+ const llmsContent = generateLlmsTxt({
132
+ title,
133
+ pages: llmsPages,
134
+ apiRoutes: llmsApiRoutes,
135
+ integrations,
136
+ i18n: i18nConfig ? { locales: i18nConfig.locales, defaultLocale: i18nConfig.defaultLocale } : undefined,
137
+ });
138
+ fs.writeFileSync(path.join(clientDir, 'llms.txt'), llmsContent);
139
+ console.log('[LumenJS] Generated llms.txt');
140
+ }
117
141
  // --- Pre-render phase ---
118
142
  await prerenderPages({
119
143
  serverDir,
@@ -123,6 +147,14 @@ export async function buildProject(options) {
123
147
  layoutEntries,
124
148
  manifest,
125
149
  });
150
+ // --- Generate .md files for each page (llms.txt per-page support) ---
151
+ await generateMarkdownPages({
152
+ serverDir,
153
+ clientDir,
154
+ pagesDir,
155
+ pageEntries,
156
+ manifest,
157
+ });
126
158
  console.log('[LumenJS] Build complete.');
127
159
  console.log(` Output: ${outDir}`);
128
160
  console.log(` Client assets: ${clientDir}`);
@@ -14,8 +14,11 @@ function analyzePageFile(filePath) {
14
14
  return false;
15
15
  return true;
16
16
  };
17
+ const hasColocatedLoader = path.basename(filePath).replace(/\.(ts|js)$/, '') === 'index' &&
18
+ (fs.existsSync(path.join(path.dirname(filePath), '_loader.ts')) ||
19
+ fs.existsSync(path.join(path.dirname(filePath), '_loader.js')));
17
20
  return {
18
- hasLoader: hasExportBefore(/export\s+(async\s+)?function\s+loader\s*\(/),
21
+ hasLoader: hasExportBefore(/export\s+(async\s+)?function\s+loader\s*\(/) || hasColocatedLoader,
19
22
  hasSubscribe: hasExportBefore(/export\s+(async\s+)?function\s+subscribe\s*\(/),
20
23
  hasSocket: /export\s+(function|const)\s+socket[\s(=]/.test(content),
21
24
  hasAuth: hasExportBefore(/export\s+const\s+auth\s*=/),
@@ -207,14 +207,24 @@ export async function handleLoaderRequest(manifest, serverDir, pagesDir, pathnam
207
207
  }
208
208
  try {
209
209
  const mod = await import(modulePath);
210
- if (!mod.loader || typeof mod.loader !== 'function') {
210
+ let loaderFn = mod.loader && typeof mod.loader === 'function' ? mod.loader : null;
211
+ // Fallback: co-located _loader.js for folder index pages
212
+ if (!loaderFn && path.basename(modulePath, '.js') === 'index') {
213
+ const colocated = path.join(path.dirname(modulePath), '_loader.js');
214
+ if (fs.existsSync(colocated)) {
215
+ const loaderMod = await import(colocated);
216
+ if (loaderMod.loader && typeof loaderMod.loader === 'function')
217
+ loaderFn = loaderMod.loader;
218
+ }
219
+ }
220
+ if (!loaderFn) {
211
221
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
212
222
  res.end(JSON.stringify({ __nk_no_loader: true }));
213
223
  return;
214
224
  }
215
225
  const locale = query.__locale;
216
226
  delete query.__locale;
217
- const result = await mod.loader({ params: matched.params, query, url: pagePath, headers, locale, user: user ?? null });
227
+ const result = await loaderFn({ params: matched.params, query, url: pagePath, headers, locale, user: user ?? null });
218
228
  if (isRedirectResponse(result)) {
219
229
  res.writeHead(result.status || 302, { Location: result.location });
220
230
  res.end();
@@ -18,10 +18,19 @@ export async function handlePageRoute(manifest, serverDir, pagesDir, pathname, q
18
18
  if (fs.existsSync(modulePath)) {
19
19
  try {
20
20
  const mod = await import(modulePath);
21
- // Run loader
21
+ // Run loader (inline or co-located _loader.js)
22
22
  let loaderData = undefined;
23
- if (mod.loader && typeof mod.loader === 'function') {
24
- loaderData = await mod.loader({ params: matched.params, query: {}, url: pathname, headers: req.headers, user: req.nkAuth?.user ?? null });
23
+ let loaderFn = mod.loader && typeof mod.loader === 'function' ? mod.loader : null;
24
+ if (!loaderFn && path.basename(modulePath, '.js') === 'index') {
25
+ const colocated = path.join(path.dirname(modulePath), '_loader.js');
26
+ if (fs.existsSync(colocated)) {
27
+ const loaderMod = await import(colocated);
28
+ if (loaderMod.loader && typeof loaderMod.loader === 'function')
29
+ loaderFn = loaderMod.loader;
30
+ }
31
+ }
32
+ if (loaderFn) {
33
+ loaderData = await loaderFn({ params: matched.params, query: {}, url: pathname, headers: req.headers, user: req.nkAuth?.user ?? null });
25
34
  if (isRedirectResponse(loaderData)) {
26
35
  res.writeHead(loaderData.status || 302, { Location: loaderData.location });
27
36
  res.end();
@@ -4,6 +4,8 @@ import { createGzip } from 'zlib';
4
4
  import { pipeline } from 'stream';
5
5
  export const MIME_TYPES = {
6
6
  '.html': 'text/html; charset=utf-8',
7
+ '.md': 'text/markdown; charset=utf-8',
8
+ '.txt': 'text/plain; charset=utf-8',
7
9
  '.js': 'application/javascript; charset=utf-8',
8
10
  '.mjs': 'application/javascript; charset=utf-8',
9
11
  '.css': 'text/css; charset=utf-8',
@@ -21,7 +23,6 @@ export const MIME_TYPES = {
21
23
  '.eot': 'application/vnd.ms-fontobject',
22
24
  '.otf': 'font/otf',
23
25
  '.map': 'application/json',
24
- '.txt': 'text/plain; charset=utf-8',
25
26
  '.xml': 'application/xml',
26
27
  '.webmanifest': 'application/manifest+json',
27
28
  };
@@ -332,7 +332,7 @@ export async function serveProject(options) {
332
332
  fs.createReadStream(filePath).pipe(res);
333
333
  return;
334
334
  }
335
- // 3. Static assets — try to serve from client dir
335
+ // 3. Static assets — try to serve from client dir (includes pre-generated .md files)
336
336
  if (pathname.includes('.')) {
337
337
  const served = serveStaticFile(clientDir, pathname, req, res);
338
338
  if (served)
@@ -70,6 +70,7 @@ export function createCommunicationHandler(options = {}) {
70
70
  }
71
71
  },
72
72
  broadcastAll: ctx.room.broadcastAll,
73
+ db: options.db,
73
74
  };
74
75
  // Broadcast online status if this is the user's first socket
75
76
  if (isFirstSocket) {
@@ -8,6 +8,8 @@ export interface SignalingContext {
8
8
  emitToSocket: (socketId: string, data: any) => void;
9
9
  /** Broadcast to all sockets in a room */
10
10
  broadcastAll: (room: string, data: any) => void;
11
+ /** Optional database for persisting call logs */
12
+ db?: any;
11
13
  }
12
14
  export declare function handleCallInitiate(ctx: SignalingContext, data: {
13
15
  conversationId: string;
@@ -130,6 +130,47 @@ export function handleCallHangup(ctx, data) {
130
130
  data: { callId: data.callId, state: 'ended', endReason: data.reason },
131
131
  });
132
132
  }
133
+ // Persist call log as a message in the conversation
134
+ if (ctx.db && call.conversationId) {
135
+ try {
136
+ const callStatus = data.reason === 'rejected' ? 'declined'
137
+ : data.reason === 'missed' ? 'missed'
138
+ : 'completed';
139
+ const attachment = JSON.stringify({
140
+ callType: call.type || 'audio',
141
+ callStatus,
142
+ duration: data.duration || null,
143
+ });
144
+ const msgId = `call-${data.callId}`;
145
+ const isPg = !!ctx.db.isPg;
146
+ if (isPg) {
147
+ ctx.db.run(`INSERT INTO messages (id, conversation_id, sender_id, content, type, attachment, created_at)
148
+ VALUES ($1, $2, $3, $4, $5, $6, NOW()) ON CONFLICT (id) DO NOTHING`, msgId, call.conversationId, call.callerId, '', 'call', attachment);
149
+ ctx.db.run(`UPDATE conversations SET updated_at = NOW() WHERE id = $1`, call.conversationId);
150
+ }
151
+ else {
152
+ ctx.db.run(`INSERT OR IGNORE INTO messages (id, conversation_id, sender_id, content, type, attachment, created_at)
153
+ VALUES (?, ?, ?, '', 'call', ?, datetime('now'))`, msgId, call.conversationId, call.callerId, attachment);
154
+ ctx.db.run(`UPDATE conversations SET updated_at = datetime('now') WHERE id = ?`, call.conversationId);
155
+ }
156
+ // Broadcast call message to all participants
157
+ const callMsg = {
158
+ id: msgId,
159
+ conversationId: call.conversationId,
160
+ senderId: call.callerId,
161
+ content: '',
162
+ type: 'call',
163
+ attachment: { callType: call.type || 'audio', callStatus, duration: data.duration || null },
164
+ createdAt: new Date().toISOString(),
165
+ };
166
+ for (const uid of allUsers) {
167
+ emitToUser(ctx, uid, { event: 'message:new', data: callMsg });
168
+ }
169
+ // Also emit to the caller
170
+ emitToUser(ctx, ctx.userId, { event: 'message:new', data: callMsg });
171
+ }
172
+ catch { }
173
+ }
133
174
  ctx.store.removeCall(data.callId);
134
175
  }
135
176
  }
@@ -75,6 +75,7 @@ export function lumenLlmsPlugin(projectDir) {
75
75
  integrations: config.integrations,
76
76
  i18n: config.i18n ? { locales: config.i18n.locales, defaultLocale: config.i18n.defaultLocale } : undefined,
77
77
  db: config.db,
78
+ baseUrl: '',
78
79
  });
79
80
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
80
81
  res.setHeader('Cache-Control', 'no-store');
@@ -13,15 +13,16 @@ import { Plugin } from 'vite';
13
13
  * }
14
14
  *
15
15
  * export class PageItem extends LitElement {
16
- * @property({ type: Object }) loaderData = {};
16
+ * @property({ type: Object }) item = null;
17
+ * @property({ type: Number }) timestamp = 0;
17
18
  * render() {
18
- * return html`<h1>${this.loaderData.item?.name}</h1>`;
19
+ * return html`<h1>${this.item?.name}</h1>`;
19
20
  * }
20
21
  * }
21
22
  *
22
23
  * The loader runs server-side via /__nk_loader/<page-path>
23
24
  * Layout loaders run via /__nk_loader/__layout/?__dir=<dir>
24
- * The router auto-fetches and passes the result as `loaderData` property.
25
+ * The router auto-fetches and spreads each key as an individual property on the element.
25
26
  */
26
27
  export declare function lumenLoadersPlugin(pagesDir: string): Plugin;
27
28
  /**
@@ -16,15 +16,16 @@ import { installDomShims } from '../../shared/dom-shims.js';
16
16
  * }
17
17
  *
18
18
  * export class PageItem extends LitElement {
19
- * @property({ type: Object }) loaderData = {};
19
+ * @property({ type: Object }) item = null;
20
+ * @property({ type: Number }) timestamp = 0;
20
21
  * render() {
21
- * return html`<h1>${this.loaderData.item?.name}</h1>`;
22
+ * return html`<h1>${this.item?.name}</h1>`;
22
23
  * }
23
24
  * }
24
25
  *
25
26
  * The loader runs server-side via /__nk_loader/<page-path>
26
27
  * Layout loaders run via /__nk_loader/__layout/?__dir=<dir>
27
- * The router auto-fetches and passes the result as `loaderData` property.
28
+ * The router auto-fetches and spreads each key as an individual property on the element.
28
29
  */
29
30
  export function lumenLoadersPlugin(pagesDir) {
30
31
  return {
@@ -158,7 +159,21 @@ export function lumenLoadersPlugin(pagesDir) {
158
159
  // Provide minimal DOM shims for SSR so Lit class definitions don't crash
159
160
  installDomShims();
160
161
  const mod = await server.ssrLoadModule(filePath);
161
- if (!mod.loader || typeof mod.loader !== 'function') {
162
+ let loaderFn = mod.loader && typeof mod.loader === 'function' ? mod.loader : null;
163
+ // Fallback: co-located _loader.ts for folder index pages
164
+ if (!loaderFn && path.basename(filePath).replace(/\.(ts|js)$/, '') === 'index') {
165
+ for (const ext of ['.ts', '.js']) {
166
+ const colocated = path.join(path.dirname(filePath), `_loader${ext}`);
167
+ if (fs.existsSync(colocated)) {
168
+ const loaderMod = await server.ssrLoadModule(colocated);
169
+ if (loaderMod.loader && typeof loaderMod.loader === 'function') {
170
+ loaderFn = loaderMod.loader;
171
+ }
172
+ break;
173
+ }
174
+ }
175
+ }
176
+ if (!loaderFn) {
162
177
  // No loader — return empty data
163
178
  res.statusCode = 200;
164
179
  res.setHeader('Content-Type', 'application/json');
@@ -204,7 +219,7 @@ export function lumenLoadersPlugin(pagesDir) {
204
219
  }
205
220
  catch { }
206
221
  }
207
- const result = await mod.loader({ params, query, url: pagePath, headers: req.headers, locale, user });
222
+ const result = await loaderFn({ params, query, url: pagePath, headers: req.headers, locale, user });
208
223
  if (isRedirectResponse(result)) {
209
224
  res.statusCode = result.status || 302;
210
225
  res.setHeader('Location', result.location);
@@ -44,10 +44,22 @@ export async function ssrRenderPage(server, pagesDir, pathname, headers, locale,
44
44
  const mod = await server.ssrLoadModule(pageModuleUrl);
45
45
  if (registry)
46
46
  registry.__nk_bypass_get = false;
47
- // Run loader if present
47
+ // Run loader if present (inline or co-located _loader.ts)
48
48
  let loaderData = undefined;
49
- if (mod.loader && typeof mod.loader === 'function') {
50
- loaderData = await mod.loader({ params, query: {}, url: pathname, headers: headers || {}, locale, user: user ?? null });
49
+ let loaderFn = mod.loader && typeof mod.loader === 'function' ? mod.loader : null;
50
+ if (!loaderFn && path.basename(filePath).replace(/\.(ts|js)$/, '') === 'index') {
51
+ for (const ext of ['.ts', '.js']) {
52
+ const colocated = path.join(path.dirname(filePath), `_loader${ext}`);
53
+ if (fs.existsSync(colocated)) {
54
+ const loaderMod = await server.ssrLoadModule('/' + path.relative(path.resolve(pagesDir, '..'), colocated).replace(/\\/g, '/'));
55
+ if (loaderMod.loader && typeof loaderMod.loader === 'function')
56
+ loaderFn = loaderMod.loader;
57
+ break;
58
+ }
59
+ }
60
+ }
61
+ if (loaderFn) {
62
+ loaderData = await loaderFn({ params, query: {}, url: pathname, headers: headers || {}, locale, user: user ?? null });
51
63
  if (loaderData && typeof loaderData === 'object' && loaderData.__nk_redirect) {
52
64
  return { html: '', loaderData: null, redirect: { location: loaderData.location, status: loaderData.status || 302 } };
53
65
  }
@@ -26,5 +26,5 @@ export interface AiStatusResult {
26
26
  configured: boolean;
27
27
  backend: 'claude-code' | 'opencode';
28
28
  }
29
- export declare const SYSTEM_PROMPT = "You are an AI coding assistant working inside a LumenJS project.\n\nLumenJS is a full-stack Lit web component framework with file-based routing, server loaders, SSR, and API routes.\n\nKey conventions:\n- Pages live in `pages/` directory \u2014 file path maps to URL route\n- Components are Lit web components (LitElement) auto-registered by file path\n- Layouts: `_layout.ts` in any directory for nested layouts (use <slot>)\n- API routes: `api/` directory with named exports (GET, POST, PUT, DELETE)\n- Server loaders: `export async function loader()` for server-side data fetching\n- Styles: use Tailwind CSS classes or Lit's `static styles` with css template tag\n- Config: `lumenjs.config.ts` at project root\n\nAuto-registration:\n- Pages and layouts are auto-registered by file path \u2014 do NOT add @customElement decorators.\n `pages/about.ts` \u2192 `<page-about>`, `pages/blog/_layout.ts` \u2192 `<layout-blog>`\n\nServer loaders:\n- `export async function loader({ params, query, url, headers, locale })` at file top level.\n- Return a data object \u2192 available as `this.loaderData` on the page element.\n\nSubscribe (SSE):\n- `export async function subscribe({ params, headers, locale, push })` for real-time data.\n- Call `push(data)` to send events \u2192 available as `this.liveData` on the page element.\n\nMiddleware:\n- `_middleware.ts` in any directory applies to that route subtree.\n\nDynamic routes:\n- `[slug]` for dynamic params, `[...rest]` for catch-all.\n\nProperties:\n- Use `@property()` for public reactive props, `@state()` for internal state (from `lit/decorators.js`).\n\nExample \u2014 adding a new page (`pages/contact.ts`):\n```\nimport { LitElement, html, css } from 'lit';\nimport { property } from 'lit/decorators.js';\n\nexport default class extends LitElement {\n static styles = css\\`/* styles here */\\`;\n render() { return html\\`<h1>Contact</h1>\\`; }\n}\n```\n\nIMPORTANT \u2014 Styling rules:\n- When asked to change a style (color, font, spacing, etc.), find and UPDATE the EXISTING CSS rule in `static styles = css\\`...\\``. Do NOT add a new class or duplicate rule.\n- Never add inline `style=\"...\"` attributes on HTML template elements. Always modify the CSS rule in `static styles`.\n- Example: to change the h1 color, find the `h1 { ... }` rule in `static styles` and update its `color` property. Do not create a new class.\n- If no CSS rule exists for the element, add one to the existing `static styles` block \u2014 do not add a separate `<style>` tag.\n\nIMPORTANT \u2014 i18n / translation rules (when the project uses i18n):\n- Text content in templates uses `t('key')` from `@lumenjs/i18n` \u2014 NEVER replace a `t()` call with hardcoded text.\n- To change displayed text, edit the translation value in `locales/<locale>.json` \u2014 do NOT modify the template.\n- Example: to change the subtitle, update `\"home.subtitle\"` in `locales/en.json` (and other locale files like `locales/fr.json`).\n- To add new text, add a key to ALL locale JSON files and use `t('new.key')` in the template.\n- The dev server watches locale files and updates the page automatically via HMR.\n\nYou have full access to the filesystem and can run shell commands.\nWhen a task requires a new npm package, install it with `npm install <package>`.\nAfter npm install, the dev server will automatically restart to load the new dependency.\nVite's HMR will pick up file changes automatically \u2014 no manual restart needed.\n\nIMPORTANT \u2014 Be fast and direct:\n- Make changes immediately \u2014 do not explain what you will do before doing it.\n- Read the file, make the edit, done. Minimize tool calls.\n- For simple CSS/text changes, edit directly without reading first if you have the source context.\n- Keep responses under 2 sentences. The user sees the diff, not your explanation.\n";
29
+ export declare const SYSTEM_PROMPT = "You are an AI coding assistant working inside a LumenJS project.\n\nLumenJS is a full-stack Lit web component framework with file-based routing, server loaders, SSR, and API routes.\n\nKey conventions:\n- Pages live in `pages/` directory \u2014 file path maps to URL route\n- Components are Lit web components (LitElement) auto-registered by file path\n- Layouts: `_layout.ts` in any directory for nested layouts (use <slot>)\n- API routes: `api/` directory with named exports (GET, POST, PUT, DELETE)\n- Server loaders: `export async function loader()` for server-side data fetching\n- Styles: use Tailwind CSS classes or Lit's `static styles` with css template tag\n- Config: `lumenjs.config.ts` at project root\n\nAuto-registration:\n- Pages and layouts are auto-registered by file path \u2014 do NOT add @customElement decorators.\n `pages/about.ts` \u2192 `<page-about>`, `pages/blog/_layout.ts` \u2192 `<layout-blog>`\n\nServer loaders:\n- `export async function loader({ params, query, url, headers, locale })` at file top level.\n- Return a data object \u2192 each key is spread as an individual property on the page element (e.g., return `{ posts }` \u2192 access as `this.posts`).\n\nSubscribe (SSE):\n- `export async function subscribe({ params, headers, locale, push })` for real-time data.\n- Call `push(data)` to send events \u2192 each key is spread as an individual property on the page element (same as loader data).\n\nMiddleware:\n- `_middleware.ts` in any directory applies to that route subtree.\n\nDynamic routes:\n- `[slug]` for dynamic params, `[...rest]` for catch-all.\n\nProperties:\n- Use `@property()` for public reactive props, `@state()` for internal state (from `lit/decorators.js`).\n\nExample \u2014 adding a new page (`pages/contact.ts`):\n```\nimport { LitElement, html, css } from 'lit';\nimport { property } from 'lit/decorators.js';\n\nexport default class extends LitElement {\n static styles = css\\`/* styles here */\\`;\n render() { return html\\`<h1>Contact</h1>\\`; }\n}\n```\n\nIMPORTANT \u2014 Styling rules:\n- When asked to change a style (color, font, spacing, etc.), find and UPDATE the EXISTING CSS rule in `static styles = css\\`...\\``. Do NOT add a new class or duplicate rule.\n- Never add inline `style=\"...\"` attributes on HTML template elements. Always modify the CSS rule in `static styles`.\n- Example: to change the h1 color, find the `h1 { ... }` rule in `static styles` and update its `color` property. Do not create a new class.\n- If no CSS rule exists for the element, add one to the existing `static styles` block \u2014 do not add a separate `<style>` tag.\n\nIMPORTANT \u2014 i18n / translation rules (when the project uses i18n):\n- Text content in templates uses `t('key')` from `@lumenjs/i18n` \u2014 NEVER replace a `t()` call with hardcoded text.\n- To change displayed text, edit the translation value in `locales/<locale>.json` \u2014 do NOT modify the template.\n- Example: to change the subtitle, update `\"home.subtitle\"` in `locales/en.json` (and other locale files like `locales/fr.json`).\n- To add new text, add a key to ALL locale JSON files and use `t('new.key')` in the template.\n- The dev server watches locale files and updates the page automatically via HMR.\n\nYou have full access to the filesystem and can run shell commands.\nWhen a task requires a new npm package, install it with `npm install <package>`.\nAfter npm install, the dev server will automatically restart to load the new dependency.\nVite's HMR will pick up file changes automatically \u2014 no manual restart needed.\n\nIMPORTANT \u2014 Be fast and direct:\n- Make changes immediately \u2014 do not explain what you will do before doing it.\n- Read the file, make the edit, done. Minimize tool calls.\n- For simple CSS/text changes, edit directly without reading first if you have the source context.\n- Keep responses under 2 sentences. The user sees the diff, not your explanation.\n";
30
30
  export declare function buildPrompt(options: AiChatOptions): string;
@@ -20,11 +20,11 @@ Auto-registration:
20
20
 
21
21
  Server loaders:
22
22
  - \`export async function loader({ params, query, url, headers, locale })\` at file top level.
23
- - Return a data object → available as \`this.loaderData\` on the page element.
23
+ - Return a data object → each key is spread as an individual property on the page element (e.g., return \`{ posts }\` → access as \`this.posts\`).
24
24
 
25
25
  Subscribe (SSE):
26
26
  - \`export async function subscribe({ params, headers, locale, push })\` for real-time data.
27
- - Call \`push(data)\` to send events → available as \`this.liveData\` on the page element.
27
+ - Call \`push(data)\` to send events → each key is spread as an individual property on the page element (same as loader data).
28
28
 
29
29
  Middleware:
30
30
  - \`_middleware.ts\` in any directory applies to that route subtree.