@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.
- package/README.md +80 -7
- package/dist/build/build-markdown.d.ts +15 -0
- package/dist/build/build-markdown.js +90 -0
- package/dist/build/build-server.js +13 -2
- package/dist/build/build.js +34 -2
- package/dist/build/scan.js +4 -1
- package/dist/build/serve-loaders.js +12 -2
- package/dist/build/serve-ssr.js +12 -3
- package/dist/build/serve-static.js +2 -1
- package/dist/build/serve.js +1 -1
- package/dist/communication/server.js +1 -0
- package/dist/communication/signaling.d.ts +2 -0
- package/dist/communication/signaling.js +41 -0
- package/dist/dev-server/plugins/vite-plugin-llms.js +1 -0
- package/dist/dev-server/plugins/vite-plugin-loaders.d.ts +4 -3
- package/dist/dev-server/plugins/vite-plugin-loaders.js +20 -5
- package/dist/dev-server/ssr-render.js +15 -3
- package/dist/editor/ai/types.d.ts +1 -1
- package/dist/editor/ai/types.js +2 -2
- package/dist/llms/generate.d.ts +15 -1
- package/dist/llms/generate.js +54 -44
- package/dist/runtime/communication.d.ts +65 -36
- package/dist/runtime/communication.js +117 -57
- package/dist/runtime/router.js +3 -3
- package/dist/runtime/webrtc.d.ts +8 -1
- package/dist/runtime/webrtc.js +49 -15
- package/dist/shared/html-to-markdown.d.ts +6 -0
- package/dist/shared/html-to-markdown.js +73 -0
- package/dist/shared/utils.js +8 -0
- package/package.json +2 -2
- package/templates/blog/pages/index.ts +3 -3
- package/templates/blog/pages/posts/[slug].ts +17 -6
- package/templates/blog/pages/tag/[tag].ts +6 -6
- package/templates/dashboard/pages/index.ts +7 -7
- 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 = {
|
|
81
|
-
|
|
84
|
+
static properties = { post: { type: Object } };
|
|
85
|
+
post: any = null;
|
|
82
86
|
|
|
83
87
|
render() {
|
|
84
|
-
return html`<h1>${this.
|
|
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 = {
|
|
114
|
-
|
|
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.
|
|
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
|
-
|
|
11
|
-
|
|
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) {
|
package/dist/build/build.js
CHANGED
|
@@ -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:
|
|
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}`);
|
package/dist/build/scan.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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();
|
package/dist/build/serve-ssr.js
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
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
|
};
|
package/dist/build/serve.js
CHANGED
|
@@ -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)
|
|
@@ -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 })
|
|
16
|
+
* @property({ type: Object }) item = null;
|
|
17
|
+
* @property({ type: Number }) timestamp = 0;
|
|
17
18
|
* render() {
|
|
18
|
-
* return html`<h1>${this.
|
|
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
|
|
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 })
|
|
19
|
+
* @property({ type: Object }) item = null;
|
|
20
|
+
* @property({ type: Number }) timestamp = 0;
|
|
20
21
|
* render() {
|
|
21
|
-
* return html`<h1>${this.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
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;
|
package/dist/editor/ai/types.js
CHANGED
|
@@ -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 →
|
|
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 →
|
|
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.
|