@nuraly/lumenjs 0.2.0 → 0.3.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 +48 -7
- package/dist/build/build-markdown.d.ts +15 -0
- package/dist/build/build-markdown.js +90 -0
- package/dist/build/build-server.js +2 -3
- package/dist/build/build.js +34 -2
- package/dist/build/serve-static.js +2 -1
- package/dist/build/serve.js +1 -1
- 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 +4 -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/shared/html-to-markdown.d.ts +6 -0
- package/dist/shared/html-to-markdown.js +73 -0
- package/package.json +1 -1
- 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
|
@@ -68,6 +68,8 @@ export class PageIndex extends LitElement {
|
|
|
68
68
|
|
|
69
69
|
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
70
|
|
|
71
|
+
Declare each returned key as its own property — the framework spreads loader data onto the element automatically.
|
|
72
|
+
|
|
71
73
|
```typescript
|
|
72
74
|
// pages/blog/[slug].ts
|
|
73
75
|
export async function loader({ params }) {
|
|
@@ -77,11 +79,11 @@ export async function loader({ params }) {
|
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
export class BlogPost extends LitElement {
|
|
80
|
-
static properties = {
|
|
81
|
-
|
|
82
|
+
static properties = { post: { type: Object } };
|
|
83
|
+
post: any = null;
|
|
82
84
|
|
|
83
85
|
render() {
|
|
84
|
-
return html`<h1>${this.
|
|
86
|
+
return html`<h1>${this.post?.title}</h1>`;
|
|
85
87
|
}
|
|
86
88
|
}
|
|
87
89
|
```
|
|
@@ -110,16 +112,20 @@ export function subscribe({ push }) {
|
|
|
110
112
|
}
|
|
111
113
|
|
|
112
114
|
export class PageDashboard extends LitElement {
|
|
113
|
-
static properties = {
|
|
114
|
-
|
|
115
|
+
static properties = {
|
|
116
|
+
time: { type: String },
|
|
117
|
+
count: { type: Number },
|
|
118
|
+
};
|
|
119
|
+
time = '';
|
|
120
|
+
count = 0;
|
|
115
121
|
|
|
116
122
|
render() {
|
|
117
|
-
return html`<p>Server time: ${this.
|
|
123
|
+
return html`<p>Server time: ${this.time}</p>`;
|
|
118
124
|
}
|
|
119
125
|
}
|
|
120
126
|
```
|
|
121
127
|
|
|
122
|
-
Return a cleanup function — it runs when the client disconnects.
|
|
128
|
+
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
129
|
|
|
124
130
|
## Layouts
|
|
125
131
|
|
|
@@ -186,6 +192,41 @@ npx lumenjs add tailwind # Tailwind CSS via @tailwindcss/vite
|
|
|
186
192
|
export default { integrations: ['nuralyui'] };
|
|
187
193
|
```
|
|
188
194
|
|
|
195
|
+
## Visual Editor
|
|
196
|
+
|
|
197
|
+
Start the dev server with `--editor-mode` to edit pages visually in the browser:
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
npx lumenjs dev --editor-mode
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
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.
|
|
204
|
+
|
|
205
|
+
### AI Backend
|
|
206
|
+
|
|
207
|
+
The editor includes an AI assistant that can modify your components. It supports three backends:
|
|
208
|
+
|
|
209
|
+
**Claude Code** (recommended) — uses your Pro/Max subscription, no API key needed:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
npm install -g @anthropic-ai/claude-code
|
|
213
|
+
claude login
|
|
214
|
+
npm install @anthropic-ai/claude-agent-sdk
|
|
215
|
+
npx lumenjs dev --editor-mode
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
**OpenCode** — coding agent server, configure it with DeepSeek or any LLM provider:
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
npm install -g opencode
|
|
222
|
+
opencode serve # terminal 1
|
|
223
|
+
npx lumenjs dev --editor-mode # terminal 2
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Configure the connection: `OPENCODE_URL` (default `http://localhost:4096`) and `OPENCODE_SERVER_PASSWORD` if auth is required.
|
|
227
|
+
|
|
228
|
+
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).
|
|
229
|
+
|
|
189
230
|
## CLI
|
|
190
231
|
|
|
191
232
|
```
|
|
@@ -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,10 +6,9 @@ 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
|
-
serverEntries[`pages/${entry.name}`] = entry.filePath;
|
|
12
|
-
}
|
|
11
|
+
serverEntries[`pages/${entry.name}`] = entry.filePath;
|
|
13
12
|
}
|
|
14
13
|
for (const entry of layoutEntries) {
|
|
15
14
|
if (entry.hasLoader || entry.hasSubscribe) {
|
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}`);
|
|
@@ -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)
|
|
@@ -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 {
|
|
@@ -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.
|
package/dist/llms/generate.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface LlmsApiRoute {
|
|
|
14
14
|
}
|
|
15
15
|
export interface LlmsTxtInput {
|
|
16
16
|
title: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
baseUrl?: string;
|
|
17
19
|
pages: LlmsPage[];
|
|
18
20
|
apiRoutes: LlmsApiRoute[];
|
|
19
21
|
integrations: string[];
|
|
@@ -26,9 +28,21 @@ export interface LlmsTxtInput {
|
|
|
26
28
|
};
|
|
27
29
|
}
|
|
28
30
|
/**
|
|
29
|
-
* Generate the llms.txt content
|
|
31
|
+
* Generate the llms.txt content following the llmstxt.org spec.
|
|
32
|
+
*
|
|
33
|
+
* Structure: H1 title, blockquote summary, H2 sections with
|
|
34
|
+
* markdown links to each page (linking to .md versions).
|
|
30
35
|
*/
|
|
31
36
|
export declare function generateLlmsTxt(input: LlmsTxtInput): string;
|
|
37
|
+
/**
|
|
38
|
+
* Generate llms-full.txt — all page content inlined as one markdown document.
|
|
39
|
+
*/
|
|
40
|
+
export declare function generateLlmsFullTxt(input: LlmsTxtInput & {
|
|
41
|
+
pageContents: {
|
|
42
|
+
path: string;
|
|
43
|
+
markdown: string;
|
|
44
|
+
}[];
|
|
45
|
+
}): string;
|
|
32
46
|
/**
|
|
33
47
|
* Try to resolve dynamic route entries by finding a parent/sibling index page
|
|
34
48
|
* whose loader returns an array, then calling the dynamic page's loader for each item.
|
package/dist/llms/generate.js
CHANGED
|
@@ -1,62 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Generate the llms.txt content
|
|
2
|
+
* Generate the llms.txt content following the llmstxt.org spec.
|
|
3
|
+
*
|
|
4
|
+
* Structure: H1 title, blockquote summary, H2 sections with
|
|
5
|
+
* markdown links to each page (linking to .md versions).
|
|
3
6
|
*/
|
|
4
7
|
export function generateLlmsTxt(input) {
|
|
5
8
|
const lines = [];
|
|
9
|
+
const base = input.baseUrl ? input.baseUrl.replace(/\/$/, '') : '';
|
|
6
10
|
lines.push(`# ${input.title}`);
|
|
7
11
|
lines.push('');
|
|
8
|
-
lines.push(
|
|
12
|
+
lines.push(`> ${input.description || `${input.title}. Built with LumenJS.`}`);
|
|
9
13
|
lines.push('');
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
// Group pages by top-level path segment
|
|
15
|
+
const groups = new Map();
|
|
16
|
+
for (const page of input.pages) {
|
|
17
|
+
const segments = page.path.split('/').filter(Boolean);
|
|
18
|
+
const section = segments.length > 0 ? segments[0] : 'pages';
|
|
19
|
+
if (!groups.has(section))
|
|
20
|
+
groups.set(section, []);
|
|
21
|
+
groups.get(section).push(page);
|
|
22
|
+
}
|
|
23
|
+
// Pages — grouped by section with links
|
|
24
|
+
for (const [section, pages] of groups) {
|
|
25
|
+
const sectionTitle = section.charAt(0).toUpperCase() + section.slice(1);
|
|
26
|
+
lines.push(`## ${sectionTitle}`);
|
|
13
27
|
lines.push('');
|
|
14
|
-
for (const page of
|
|
15
|
-
lines.push(`### ${page.path}`);
|
|
28
|
+
for (const page of pages) {
|
|
16
29
|
const isDynamic = page.path.includes(':');
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
else {
|
|
31
|
-
lines.push('- Dynamic route');
|
|
32
|
-
lines.push('');
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
else if (page.loaderData && typeof page.loaderData === 'object') {
|
|
36
|
-
// Static page with loader data
|
|
37
|
-
lines.push(flattenData(page.loaderData));
|
|
38
|
-
lines.push('');
|
|
39
|
-
}
|
|
40
|
-
else {
|
|
41
|
-
// Simple page
|
|
42
|
-
const features = [];
|
|
43
|
-
if (page.hasLoader)
|
|
44
|
-
features.push('with loader data');
|
|
45
|
-
if (page.hasSubscribe)
|
|
46
|
-
features.push('with live data');
|
|
47
|
-
lines.push(`- Server-rendered page${features.length ? ' ' + features.join(', ') : ''}`);
|
|
48
|
-
lines.push('');
|
|
49
|
-
}
|
|
30
|
+
const label = page.path === '/' ? 'Home' : page.path;
|
|
31
|
+
const features = [];
|
|
32
|
+
if (isDynamic)
|
|
33
|
+
features.push('dynamic');
|
|
34
|
+
if (page.hasLoader)
|
|
35
|
+
features.push('server data');
|
|
36
|
+
if (page.hasSubscribe)
|
|
37
|
+
features.push('live data');
|
|
38
|
+
const desc = features.length > 0 ? `: ${features.join(', ')}` : '';
|
|
39
|
+
// Link to .md version for LLM-readable content (skip dynamic routes)
|
|
40
|
+
const href = !isDynamic ? `${base}${page.path}.md` : `${base}${page.path}`;
|
|
41
|
+
lines.push(`- [${label}](${href})${desc}`);
|
|
50
42
|
}
|
|
43
|
+
lines.push('');
|
|
51
44
|
}
|
|
52
45
|
// API Routes section
|
|
53
46
|
if (input.apiRoutes.length > 0) {
|
|
54
|
-
lines.push('## API
|
|
47
|
+
lines.push('## API');
|
|
55
48
|
lines.push('');
|
|
56
49
|
for (const route of input.apiRoutes) {
|
|
57
|
-
|
|
58
|
-
lines.push(`- ${method} /api/${route.path}`);
|
|
59
|
-
}
|
|
50
|
+
lines.push(`- [${route.methods.join(', ')} /api/${route.path}](${base}/api/${route.path})`);
|
|
60
51
|
}
|
|
61
52
|
lines.push('');
|
|
62
53
|
}
|
|
@@ -85,6 +76,25 @@ export function generateLlmsTxt(input) {
|
|
|
85
76
|
}
|
|
86
77
|
return lines.join('\n').trimEnd() + '\n';
|
|
87
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Generate llms-full.txt — all page content inlined as one markdown document.
|
|
81
|
+
*/
|
|
82
|
+
export function generateLlmsFullTxt(input) {
|
|
83
|
+
const lines = [];
|
|
84
|
+
lines.push(`# ${input.title}`);
|
|
85
|
+
lines.push('');
|
|
86
|
+
lines.push(`> ${input.description || `${input.title}. Built with LumenJS.`}`);
|
|
87
|
+
lines.push('');
|
|
88
|
+
for (const page of input.pageContents) {
|
|
89
|
+
lines.push('---');
|
|
90
|
+
lines.push(`source: ${page.path}`);
|
|
91
|
+
lines.push('---');
|
|
92
|
+
lines.push('');
|
|
93
|
+
lines.push(page.markdown.trim());
|
|
94
|
+
lines.push('');
|
|
95
|
+
}
|
|
96
|
+
return lines.join('\n').trimEnd() + '\n';
|
|
97
|
+
}
|
|
88
98
|
/**
|
|
89
99
|
* Flatten a loader data object into key-value text lines.
|
|
90
100
|
*/
|
|
@@ -1,26 +1,65 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Client-side communication SDK.
|
|
3
|
-
* Connects to the server via Socket.io and provides a clean API for chat, typing, and
|
|
3
|
+
* Connects to the server via Socket.io and provides a clean API for chat, typing, presence, and calls.
|
|
4
4
|
*/
|
|
5
5
|
/**
|
|
6
6
|
* Connect to the communication socket.
|
|
7
|
-
*
|
|
7
|
+
* Reuses an existing socket if one is already connected.
|
|
8
8
|
*/
|
|
9
|
-
export declare function connectChat(params?: Record<string, string>): Promise<
|
|
10
|
-
/**
|
|
11
|
-
export declare function
|
|
12
|
-
/**
|
|
13
|
-
export declare function
|
|
14
|
-
/** Send a message */
|
|
15
|
-
export declare function sendMessage(conversationId: string, content: string,
|
|
16
|
-
|
|
17
|
-
|
|
9
|
+
export declare function connectChat(params?: Record<string, string>): Promise<any>;
|
|
10
|
+
/** Get the underlying socket instance (for call-service or other integrations) */
|
|
11
|
+
export declare function getSocket(): any;
|
|
12
|
+
/** Set an externally-created socket (e.g. from call-service or router) */
|
|
13
|
+
export declare function setSocket(socket: any): void;
|
|
14
|
+
/** Send a message (text, image, file, audio) */
|
|
15
|
+
export declare function sendMessage(conversationId: string, content: string, opts?: {
|
|
16
|
+
type?: string;
|
|
17
|
+
attachment?: any;
|
|
18
|
+
replyTo?: any;
|
|
19
|
+
encrypted?: boolean;
|
|
20
|
+
}): void;
|
|
21
|
+
/** Mark messages as read */
|
|
22
|
+
export declare function markRead(conversationId: string, messageIds?: string[]): void;
|
|
18
23
|
/** React to a message with an emoji (toggle) */
|
|
19
24
|
export declare function reactToMessage(messageId: string, conversationId: string, emoji: string): void;
|
|
20
25
|
/** Edit a message */
|
|
21
26
|
export declare function editMessage(messageId: string, conversationId: string, content: string): void;
|
|
22
27
|
/** Delete a message */
|
|
23
28
|
export declare function deleteMessage(messageId: string, conversationId: string): void;
|
|
29
|
+
/** Load messages for a conversation (lazy-load) */
|
|
30
|
+
export declare function loadMessages(conversationId: string): void;
|
|
31
|
+
/** Join a conversation room to receive messages */
|
|
32
|
+
export declare function joinConversation(conversationId: string): void;
|
|
33
|
+
/** Leave a conversation room */
|
|
34
|
+
export declare function leaveConversation(conversationId: string): void;
|
|
35
|
+
/** Start typing indicator */
|
|
36
|
+
export declare function startTyping(conversationId: string): void;
|
|
37
|
+
/** Stop typing indicator */
|
|
38
|
+
export declare function stopTyping(conversationId: string): void;
|
|
39
|
+
/** Update presence status */
|
|
40
|
+
export declare function updatePresence(status: 'online' | 'offline' | 'away' | 'busy'): void;
|
|
41
|
+
/** Request bulk presence sync for a list of user IDs */
|
|
42
|
+
export declare function requestPresenceSync(userIds: string[]): void;
|
|
43
|
+
/** Refresh notification/message badge counts */
|
|
44
|
+
export declare function refreshBadge(): void;
|
|
45
|
+
/** Listen for new messages */
|
|
46
|
+
export declare function onMessage(handler: (message: any) => void): () => void;
|
|
47
|
+
/** Listen for typing updates */
|
|
48
|
+
export declare function onTyping(handler: (data: {
|
|
49
|
+
conversationId: string;
|
|
50
|
+
userId: string;
|
|
51
|
+
isTyping: boolean;
|
|
52
|
+
}) => void): () => void;
|
|
53
|
+
/** Listen for presence changes */
|
|
54
|
+
export declare function onPresence(handler: (data: {
|
|
55
|
+
userId: string;
|
|
56
|
+
status: string;
|
|
57
|
+
lastSeen: string;
|
|
58
|
+
}) => void): () => void;
|
|
59
|
+
/** Listen for bulk presence sync response */
|
|
60
|
+
export declare function onPresenceSync(handler: (data: {
|
|
61
|
+
presences: Record<string, any>;
|
|
62
|
+
}) => void): () => void;
|
|
24
63
|
/** Listen for reaction updates */
|
|
25
64
|
export declare function onReactionUpdate(handler: (data: {
|
|
26
65
|
messageId: string;
|
|
@@ -37,6 +76,14 @@ export declare function onMessageDeleted(handler: (data: {
|
|
|
37
76
|
messageId: string;
|
|
38
77
|
conversationId: string;
|
|
39
78
|
}) => void): () => void;
|
|
79
|
+
/** Listen for read receipts */
|
|
80
|
+
export declare function onReadReceipt(handler: (data: any) => void): () => void;
|
|
81
|
+
/** Listen for lazy-loaded conversation messages */
|
|
82
|
+
export declare function onConversationMessages(handler: (data: {
|
|
83
|
+
conversationId: string;
|
|
84
|
+
messages: any[];
|
|
85
|
+
participants: any[];
|
|
86
|
+
}) => void): () => void;
|
|
40
87
|
/** Upload a file (returns attachment metadata) */
|
|
41
88
|
export declare function uploadFile(file: Blob, filename: string, encrypted?: boolean): Promise<{
|
|
42
89
|
id: string;
|
|
@@ -45,31 +92,9 @@ export declare function uploadFile(file: Blob, filename: string, encrypted?: boo
|
|
|
45
92
|
}>;
|
|
46
93
|
/** Fetch link previews for a text */
|
|
47
94
|
export declare function fetchLinkPreviews(text: string): Promise<any[]>;
|
|
48
|
-
/** Start typing indicator */
|
|
49
|
-
export declare function startTyping(conversationId: string): void;
|
|
50
|
-
/** Stop typing indicator */
|
|
51
|
-
export declare function stopTyping(conversationId: string): void;
|
|
52
|
-
/** Update presence status */
|
|
53
|
-
export declare function updatePresence(status: 'online' | 'offline' | 'away' | 'busy'): void;
|
|
54
|
-
/** Listen for new messages */
|
|
55
|
-
export declare function onMessage(handler: (message: any) => void): () => void;
|
|
56
|
-
/** Listen for typing updates */
|
|
57
|
-
export declare function onTyping(handler: (data: {
|
|
58
|
-
conversationId: string;
|
|
59
|
-
userId: string;
|
|
60
|
-
isTyping: boolean;
|
|
61
|
-
}) => void): () => void;
|
|
62
|
-
/** Listen for presence changes */
|
|
63
|
-
export declare function onPresence(handler: (data: {
|
|
64
|
-
userId: string;
|
|
65
|
-
status: string;
|
|
66
|
-
lastSeen: string;
|
|
67
|
-
}) => void): () => void;
|
|
68
|
-
/** Listen for read receipts */
|
|
69
|
-
export declare function onReadReceipt(handler: (data: any) => void): () => void;
|
|
70
95
|
/** Listen for incoming calls */
|
|
71
96
|
export declare function onIncomingCall(handler: (call: any) => void): () => void;
|
|
72
|
-
/** Listen for call state changes
|
|
97
|
+
/** Listen for call state changes */
|
|
73
98
|
export declare function onCallStateChanged(handler: (data: {
|
|
74
99
|
callId: string;
|
|
75
100
|
state: string;
|
|
@@ -94,11 +119,15 @@ export declare function onMediaChanged(handler: (data: {
|
|
|
94
119
|
screenShare?: boolean;
|
|
95
120
|
}) => void): () => void;
|
|
96
121
|
/** Initiate an audio or video call */
|
|
97
|
-
export declare function initiateCall(conversationId: string, type: 'audio' | 'video', calleeIds: string[]
|
|
122
|
+
export declare function initiateCall(conversationId: string, type: 'audio' | 'video', calleeIds: string[], caller?: {
|
|
123
|
+
callerName?: string;
|
|
124
|
+
callerInitials?: string;
|
|
125
|
+
callerColor?: string;
|
|
126
|
+
}): void;
|
|
98
127
|
/** Respond to an incoming call */
|
|
99
128
|
export declare function respondToCall(callId: string, action: 'accept' | 'reject'): void;
|
|
100
129
|
/** Hang up an active call */
|
|
101
|
-
export declare function hangup(callId: string, reason?: string): void;
|
|
130
|
+
export declare function hangup(callId: string, reason?: string, duration?: string | null): void;
|
|
102
131
|
/** Toggle audio/video/screenshare during a call */
|
|
103
132
|
export declare function toggleMedia(callId: string, opts: {
|
|
104
133
|
audio?: boolean;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Client-side communication SDK.
|
|
3
|
-
* Connects to the server via Socket.io and provides a clean API for chat, typing, and
|
|
3
|
+
* Connects to the server via Socket.io and provides a clean API for chat, typing, presence, and calls.
|
|
4
4
|
*/
|
|
5
5
|
let _socket = null;
|
|
6
6
|
let _handlers = new Map();
|
|
7
7
|
function emit(event, data) {
|
|
8
8
|
if (!_socket)
|
|
9
|
-
|
|
9
|
+
return;
|
|
10
10
|
_socket.emit(`nk:${event}`, data);
|
|
11
11
|
}
|
|
12
12
|
function addHandler(event, handler) {
|
|
@@ -22,15 +22,19 @@ function removeHandler(event, handler) {
|
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
24
|
* Connect to the communication socket.
|
|
25
|
-
*
|
|
25
|
+
* Reuses an existing socket if one is already connected.
|
|
26
26
|
*/
|
|
27
27
|
export async function connectChat(params) {
|
|
28
|
-
if (_socket)
|
|
29
|
-
return;
|
|
28
|
+
if (_socket?.connected)
|
|
29
|
+
return _socket;
|
|
30
30
|
const { io } = await import('socket.io-client');
|
|
31
31
|
_socket = io('/nk/messages', {
|
|
32
32
|
path: '/__nk_socketio/',
|
|
33
33
|
query: { ...params, __params: JSON.stringify(params || {}) },
|
|
34
|
+
reconnection: true,
|
|
35
|
+
reconnectionAttempts: Infinity,
|
|
36
|
+
reconnectionDelay: 1000,
|
|
37
|
+
reconnectionDelayMax: 10000,
|
|
34
38
|
});
|
|
35
39
|
_socket.on('nk:data', (data) => {
|
|
36
40
|
if (data?.event) {
|
|
@@ -41,22 +45,44 @@ export async function connectChat(params) {
|
|
|
41
45
|
}
|
|
42
46
|
}
|
|
43
47
|
});
|
|
48
|
+
return _socket;
|
|
44
49
|
}
|
|
45
|
-
/**
|
|
46
|
-
export function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
/** Get the underlying socket instance (for call-service or other integrations) */
|
|
51
|
+
export function getSocket() { return _socket; }
|
|
52
|
+
/** Set an externally-created socket (e.g. from call-service or router) */
|
|
53
|
+
export function setSocket(socket) {
|
|
54
|
+
if (_socket === socket)
|
|
55
|
+
return;
|
|
56
|
+
_socket = socket;
|
|
57
|
+
// Attach the nk:data handler if not already attached
|
|
58
|
+
if (_socket && !_socket.__nk_comm_attached) {
|
|
59
|
+
_socket.__nk_comm_attached = true;
|
|
60
|
+
_socket.on('nk:data', (data) => {
|
|
61
|
+
if (data?.event) {
|
|
62
|
+
const handlers = _handlers.get(data.event);
|
|
63
|
+
if (handlers) {
|
|
64
|
+
for (const h of handlers)
|
|
65
|
+
h(data.data);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
52
70
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
71
|
+
// ── Messages ─────────────────────────────────────────────────────
|
|
72
|
+
/** Send a message (text, image, file, audio) */
|
|
73
|
+
export function sendMessage(conversationId, content, opts) {
|
|
74
|
+
emit('message:send', {
|
|
75
|
+
conversationId,
|
|
76
|
+
content,
|
|
77
|
+
type: opts?.type || 'text',
|
|
78
|
+
...(opts?.attachment ? { attachment: opts.attachment } : {}),
|
|
79
|
+
...(opts?.replyTo ? { replyTo: opts.replyTo } : {}),
|
|
80
|
+
...(opts?.encrypted ? { encrypted: true } : {}),
|
|
81
|
+
});
|
|
56
82
|
}
|
|
57
|
-
/** Mark
|
|
58
|
-
export function markRead(conversationId,
|
|
59
|
-
emit('message:read', { conversationId,
|
|
83
|
+
/** Mark messages as read */
|
|
84
|
+
export function markRead(conversationId, messageIds) {
|
|
85
|
+
emit('message:read', { conversationId, ...(messageIds ? { messageIds } : {}) });
|
|
60
86
|
}
|
|
61
87
|
/** React to a message with an emoji (toggle) */
|
|
62
88
|
export function reactToMessage(messageId, conversationId, emoji) {
|
|
@@ -70,6 +96,61 @@ export function editMessage(messageId, conversationId, content) {
|
|
|
70
96
|
export function deleteMessage(messageId, conversationId) {
|
|
71
97
|
emit('message:delete', { messageId, conversationId });
|
|
72
98
|
}
|
|
99
|
+
/** Load messages for a conversation (lazy-load) */
|
|
100
|
+
export function loadMessages(conversationId) {
|
|
101
|
+
emit('conversation:load-messages', { conversationId });
|
|
102
|
+
}
|
|
103
|
+
/** Join a conversation room to receive messages */
|
|
104
|
+
export function joinConversation(conversationId) {
|
|
105
|
+
emit('conversation:join', { conversationId });
|
|
106
|
+
}
|
|
107
|
+
/** Leave a conversation room */
|
|
108
|
+
export function leaveConversation(conversationId) {
|
|
109
|
+
emit('conversation:leave', { conversationId });
|
|
110
|
+
}
|
|
111
|
+
// ── Typing ───────────────────────────────────────────────────────
|
|
112
|
+
/** Start typing indicator */
|
|
113
|
+
export function startTyping(conversationId) {
|
|
114
|
+
emit('typing:start', { conversationId });
|
|
115
|
+
}
|
|
116
|
+
/** Stop typing indicator */
|
|
117
|
+
export function stopTyping(conversationId) {
|
|
118
|
+
emit('typing:stop', { conversationId });
|
|
119
|
+
}
|
|
120
|
+
// ── Presence ─────────────────────────────────────────────────────
|
|
121
|
+
/** Update presence status */
|
|
122
|
+
export function updatePresence(status) {
|
|
123
|
+
emit('presence:update', { status });
|
|
124
|
+
}
|
|
125
|
+
/** Request bulk presence sync for a list of user IDs */
|
|
126
|
+
export function requestPresenceSync(userIds) {
|
|
127
|
+
emit('presence:sync', { userIds });
|
|
128
|
+
}
|
|
129
|
+
/** Refresh notification/message badge counts */
|
|
130
|
+
export function refreshBadge() {
|
|
131
|
+
emit('badge:refresh', {});
|
|
132
|
+
}
|
|
133
|
+
// ── Event Listeners ──────────────────────────────────────────────
|
|
134
|
+
/** Listen for new messages */
|
|
135
|
+
export function onMessage(handler) {
|
|
136
|
+
addHandler('message:new', handler);
|
|
137
|
+
return () => removeHandler('message:new', handler);
|
|
138
|
+
}
|
|
139
|
+
/** Listen for typing updates */
|
|
140
|
+
export function onTyping(handler) {
|
|
141
|
+
addHandler('typing:update', handler);
|
|
142
|
+
return () => removeHandler('typing:update', handler);
|
|
143
|
+
}
|
|
144
|
+
/** Listen for presence changes */
|
|
145
|
+
export function onPresence(handler) {
|
|
146
|
+
addHandler('presence:changed', handler);
|
|
147
|
+
return () => removeHandler('presence:changed', handler);
|
|
148
|
+
}
|
|
149
|
+
/** Listen for bulk presence sync response */
|
|
150
|
+
export function onPresenceSync(handler) {
|
|
151
|
+
addHandler('presence:sync', handler);
|
|
152
|
+
return () => removeHandler('presence:sync', handler);
|
|
153
|
+
}
|
|
73
154
|
/** Listen for reaction updates */
|
|
74
155
|
export function onReactionUpdate(handler) {
|
|
75
156
|
addHandler('message:reaction-update', handler);
|
|
@@ -85,6 +166,17 @@ export function onMessageDeleted(handler) {
|
|
|
85
166
|
addHandler('message:deleted', handler);
|
|
86
167
|
return () => removeHandler('message:deleted', handler);
|
|
87
168
|
}
|
|
169
|
+
/** Listen for read receipts */
|
|
170
|
+
export function onReadReceipt(handler) {
|
|
171
|
+
addHandler('read-receipt:update', handler);
|
|
172
|
+
return () => removeHandler('read-receipt:update', handler);
|
|
173
|
+
}
|
|
174
|
+
/** Listen for lazy-loaded conversation messages */
|
|
175
|
+
export function onConversationMessages(handler) {
|
|
176
|
+
addHandler('conversation:messages', handler);
|
|
177
|
+
return () => removeHandler('conversation:messages', handler);
|
|
178
|
+
}
|
|
179
|
+
// ── File Uploads & Link Previews ─────────────────────────────────
|
|
88
180
|
/** Upload a file (returns attachment metadata) */
|
|
89
181
|
export async function uploadFile(file, filename, encrypted = false) {
|
|
90
182
|
const res = await fetch('/__nk_comm/upload', {
|
|
@@ -112,45 +204,13 @@ export async function fetchLinkPreviews(text) {
|
|
|
112
204
|
const data = await res.json();
|
|
113
205
|
return data.previews || [];
|
|
114
206
|
}
|
|
115
|
-
|
|
116
|
-
export function startTyping(conversationId) {
|
|
117
|
-
emit('typing:start', { conversationId });
|
|
118
|
-
}
|
|
119
|
-
/** Stop typing indicator */
|
|
120
|
-
export function stopTyping(conversationId) {
|
|
121
|
-
emit('typing:stop', { conversationId });
|
|
122
|
-
}
|
|
123
|
-
/** Update presence status */
|
|
124
|
-
export function updatePresence(status) {
|
|
125
|
-
emit('presence:update', { status });
|
|
126
|
-
}
|
|
127
|
-
/** Listen for new messages */
|
|
128
|
-
export function onMessage(handler) {
|
|
129
|
-
addHandler('message:new', handler);
|
|
130
|
-
return () => removeHandler('message:new', handler);
|
|
131
|
-
}
|
|
132
|
-
/** Listen for typing updates */
|
|
133
|
-
export function onTyping(handler) {
|
|
134
|
-
addHandler('typing:update', handler);
|
|
135
|
-
return () => removeHandler('typing:update', handler);
|
|
136
|
-
}
|
|
137
|
-
/** Listen for presence changes */
|
|
138
|
-
export function onPresence(handler) {
|
|
139
|
-
addHandler('presence:changed', handler);
|
|
140
|
-
return () => removeHandler('presence:changed', handler);
|
|
141
|
-
}
|
|
142
|
-
/** Listen for read receipts */
|
|
143
|
-
export function onReadReceipt(handler) {
|
|
144
|
-
addHandler('read-receipt:update', handler);
|
|
145
|
-
return () => removeHandler('read-receipt:update', handler);
|
|
146
|
-
}
|
|
147
|
-
// ── Calls ─────────────────────────────────────────────────────────
|
|
207
|
+
// ── Calls ────────────────────────────────────────────────────────
|
|
148
208
|
/** Listen for incoming calls */
|
|
149
209
|
export function onIncomingCall(handler) {
|
|
150
210
|
addHandler('call:incoming', handler);
|
|
151
211
|
return () => removeHandler('call:incoming', handler);
|
|
152
212
|
}
|
|
153
|
-
/** Listen for call state changes
|
|
213
|
+
/** Listen for call state changes */
|
|
154
214
|
export function onCallStateChanged(handler) {
|
|
155
215
|
addHandler('call:state-changed', handler);
|
|
156
216
|
return () => removeHandler('call:state-changed', handler);
|
|
@@ -171,22 +231,22 @@ export function onMediaChanged(handler) {
|
|
|
171
231
|
return () => removeHandler('call:media-changed', handler);
|
|
172
232
|
}
|
|
173
233
|
/** Initiate an audio or video call */
|
|
174
|
-
export function initiateCall(conversationId, type, calleeIds) {
|
|
175
|
-
emit('call:initiate', { conversationId, type, calleeIds });
|
|
234
|
+
export function initiateCall(conversationId, type, calleeIds, caller) {
|
|
235
|
+
emit('call:initiate', { conversationId, type, calleeIds, ...caller });
|
|
176
236
|
}
|
|
177
237
|
/** Respond to an incoming call */
|
|
178
238
|
export function respondToCall(callId, action) {
|
|
179
239
|
emit('call:respond', { callId, action });
|
|
180
240
|
}
|
|
181
241
|
/** Hang up an active call */
|
|
182
|
-
export function hangup(callId, reason = 'completed') {
|
|
183
|
-
emit('call:hangup', { callId, reason });
|
|
242
|
+
export function hangup(callId, reason = 'completed', duration) {
|
|
243
|
+
emit('call:hangup', { callId, reason, ...(duration ? { duration } : {}) });
|
|
184
244
|
}
|
|
185
245
|
/** Toggle audio/video/screenshare during a call */
|
|
186
246
|
export function toggleMedia(callId, opts) {
|
|
187
247
|
emit('call:media-toggle', { callId, ...opts });
|
|
188
248
|
}
|
|
189
|
-
// ── WebRTC Signaling
|
|
249
|
+
// ── WebRTC Signaling ─────────────────────────────────────────────
|
|
190
250
|
/** Send SDP offer to a peer */
|
|
191
251
|
export function sendOffer(callId, toUserId, sdp) {
|
|
192
252
|
emit('signal:offer', { callId, fromUserId: '', toUserId, type: 'offer', sdp });
|
package/dist/runtime/router.js
CHANGED
|
@@ -170,7 +170,7 @@ export class NkRouter {
|
|
|
170
170
|
es.onmessage = (e) => {
|
|
171
171
|
const pageEl = this.findPageElement(match.route.tagName);
|
|
172
172
|
if (pageEl)
|
|
173
|
-
pageEl
|
|
173
|
+
this.spreadData(pageEl, JSON.parse(e.data));
|
|
174
174
|
};
|
|
175
175
|
this.subscriptions.push(es);
|
|
176
176
|
}
|
|
@@ -204,7 +204,7 @@ export class NkRouter {
|
|
|
204
204
|
socket.on('nk:data', (data) => {
|
|
205
205
|
const pageEl = this.findPageElement(match.route.tagName);
|
|
206
206
|
if (pageEl) {
|
|
207
|
-
pageEl
|
|
207
|
+
this.spreadData(pageEl, data);
|
|
208
208
|
injectEmit();
|
|
209
209
|
}
|
|
210
210
|
});
|
|
@@ -217,7 +217,7 @@ export class NkRouter {
|
|
|
217
217
|
es.onmessage = (e) => {
|
|
218
218
|
const layoutEl = this.outlet?.querySelector(layout.tagName);
|
|
219
219
|
if (layoutEl)
|
|
220
|
-
layoutEl
|
|
220
|
+
this.spreadData(layoutEl, JSON.parse(e.data));
|
|
221
221
|
};
|
|
222
222
|
this.subscriptions.push(es);
|
|
223
223
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert simple HTML to markdown.
|
|
3
|
+
* Handles the subset of HTML that Lit SSR produces for LumenJS pages.
|
|
4
|
+
* Not a full HTML parser — intentionally minimal.
|
|
5
|
+
*/
|
|
6
|
+
export function htmlToMarkdown(html) {
|
|
7
|
+
let md = html;
|
|
8
|
+
// Extract content from declarative shadow DOM (<template shadowroot="open">...</template>)
|
|
9
|
+
md = md.replace(/<template\s+shadowroot(?:mode)?="open"[^>]*>([\s\S]*?)<\/template>/gi, '$1');
|
|
10
|
+
// Remove script/style tags and their content
|
|
11
|
+
md = md.replace(/<script[\s\S]*?<\/script>/gi, '');
|
|
12
|
+
md = md.replace(/<style[\s\S]*?<\/style>/gi, '');
|
|
13
|
+
md = md.replace(/<template[\s\S]*?<\/template>/gi, '');
|
|
14
|
+
// Remove Lit SSR markers (<!--lit-part-->, <!--/lit-part-->, etc.)
|
|
15
|
+
md = md.replace(/<!--[\s\S]*?-->/g, '');
|
|
16
|
+
// Headings
|
|
17
|
+
md = md.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, (_, c) => `# ${strip(c)}\n\n`);
|
|
18
|
+
md = md.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, (_, c) => `## ${strip(c)}\n\n`);
|
|
19
|
+
md = md.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, (_, c) => `### ${strip(c)}\n\n`);
|
|
20
|
+
md = md.replace(/<h4[^>]*>([\s\S]*?)<\/h4>/gi, (_, c) => `#### ${strip(c)}\n\n`);
|
|
21
|
+
// Links
|
|
22
|
+
md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, (_, href, text) => `[${strip(text)}](${href})`);
|
|
23
|
+
// Bold / italic
|
|
24
|
+
md = md.replace(/<(strong|b)[^>]*>([\s\S]*?)<\/\1>/gi, (_, __, c) => `**${strip(c)}**`);
|
|
25
|
+
md = md.replace(/<(em|i)[^>]*>([\s\S]*?)<\/\1>/gi, (_, __, c) => `*${strip(c)}*`);
|
|
26
|
+
// Inline code
|
|
27
|
+
md = md.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, (_, c) => `\`${strip(c)}\``);
|
|
28
|
+
// Code blocks (pre > code or pre alone)
|
|
29
|
+
md = md.replace(/<pre[^>]*>\s*<code[^>]*>([\s\S]*?)<\/code>\s*<\/pre>/gi, (_, c) => `\n\`\`\`\n${decodeEntities(strip(c))}\n\`\`\`\n\n`);
|
|
30
|
+
md = md.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, (_, c) => `\n\`\`\`\n${decodeEntities(strip(c))}\n\`\`\`\n\n`);
|
|
31
|
+
// List items
|
|
32
|
+
md = md.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, (_, c) => `- ${strip(c).trim()}\n`);
|
|
33
|
+
// Table → simple text rows
|
|
34
|
+
md = md.replace(/<tr[^>]*>([\s\S]*?)<\/tr>/gi, (_, c) => {
|
|
35
|
+
const cells = [...c.matchAll(/<t[hd][^>]*>([\s\S]*?)<\/t[hd]>/gi)].map((m) => strip(m[1]).trim());
|
|
36
|
+
return cells.length > 0 ? `| ${cells.join(' | ')} |\n` : '';
|
|
37
|
+
});
|
|
38
|
+
// Paragraphs and divs → newlines
|
|
39
|
+
md = md.replace(/<\/p>/gi, '\n\n');
|
|
40
|
+
md = md.replace(/<br\s*\/?>/gi, '\n');
|
|
41
|
+
md = md.replace(/<\/div>/gi, '\n');
|
|
42
|
+
// Images
|
|
43
|
+
md = md.replace(/<img[^>]*alt="([^"]*)"[^>]*>/gi, (_, alt) => alt ? `[${alt}]` : '');
|
|
44
|
+
md = md.replace(/<img[^>]*>/gi, '');
|
|
45
|
+
// Strip all remaining HTML tags
|
|
46
|
+
md = md.replace(/<[^>]+>/g, '');
|
|
47
|
+
// Decode HTML entities
|
|
48
|
+
md = decodeEntities(md);
|
|
49
|
+
// Clean up whitespace
|
|
50
|
+
md = md.replace(/\n{3,}/g, '\n\n');
|
|
51
|
+
md = md.trim();
|
|
52
|
+
return md + '\n';
|
|
53
|
+
}
|
|
54
|
+
/** Strip HTML tags from a string. */
|
|
55
|
+
function strip(html) {
|
|
56
|
+
return html.replace(/<[^>]+>/g, '').trim();
|
|
57
|
+
}
|
|
58
|
+
/** Decode common HTML entities. */
|
|
59
|
+
function decodeEntities(text) {
|
|
60
|
+
return text
|
|
61
|
+
.replace(/&/g, '&')
|
|
62
|
+
.replace(/</g, '<')
|
|
63
|
+
.replace(/>/g, '>')
|
|
64
|
+
.replace(/"/g, '"')
|
|
65
|
+
.replace(/'/g, "'")
|
|
66
|
+
.replace(/→/g, '→')
|
|
67
|
+
.replace(/←/g, '←')
|
|
68
|
+
.replace(/·/g, '·')
|
|
69
|
+
.replace(/©/g, '©')
|
|
70
|
+
.replace(/\\u003c/g, '<')
|
|
71
|
+
.replace(/'/g, "'")
|
|
72
|
+
.replace(/ /g, ' ');
|
|
73
|
+
}
|
package/package.json
CHANGED
|
@@ -33,8 +33,8 @@ export async function loader() {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export class PageIndex extends LitElement {
|
|
36
|
-
static properties = {
|
|
37
|
-
|
|
36
|
+
static properties = { posts: { type: Array } };
|
|
37
|
+
posts: any[] = [];
|
|
38
38
|
|
|
39
39
|
static styles = css`
|
|
40
40
|
:host { display: block; }
|
|
@@ -49,7 +49,7 @@ export class PageIndex extends LitElement {
|
|
|
49
49
|
`;
|
|
50
50
|
|
|
51
51
|
render() {
|
|
52
|
-
const posts = this.
|
|
52
|
+
const posts = this.posts || [];
|
|
53
53
|
return html`
|
|
54
54
|
<h1>Blog</h1>
|
|
55
55
|
<p class="subtitle">Thoughts and tutorials</p>
|
|
@@ -29,8 +29,19 @@ export async function loader({ params }: { params: { slug: string } }) {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export class PagePost extends LitElement {
|
|
32
|
-
static properties = {
|
|
33
|
-
|
|
32
|
+
static properties = {
|
|
33
|
+
title: { type: String },
|
|
34
|
+
date: { type: String },
|
|
35
|
+
content: { type: String },
|
|
36
|
+
readingTime: { type: Number },
|
|
37
|
+
notFound: { type: Boolean },
|
|
38
|
+
slug: { type: String },
|
|
39
|
+
};
|
|
40
|
+
title = '';
|
|
41
|
+
date = '';
|
|
42
|
+
content = '';
|
|
43
|
+
readingTime = 0;
|
|
44
|
+
notFound = false;
|
|
34
45
|
slug = '';
|
|
35
46
|
|
|
36
47
|
static styles = css`
|
|
@@ -44,7 +55,7 @@ export class PagePost extends LitElement {
|
|
|
44
55
|
`;
|
|
45
56
|
|
|
46
57
|
render() {
|
|
47
|
-
if (this.
|
|
58
|
+
if (this.notFound) {
|
|
48
59
|
return html`
|
|
49
60
|
<a class="back" href="/posts">← Back to posts</a>
|
|
50
61
|
<p class="not-found">Post not found.</p>
|
|
@@ -52,9 +63,9 @@ export class PagePost extends LitElement {
|
|
|
52
63
|
}
|
|
53
64
|
return html`
|
|
54
65
|
<a class="back" href="/">← Back to posts</a>
|
|
55
|
-
<h1>${this.
|
|
56
|
-
<div class="date">${this.
|
|
57
|
-
<p class="content">${this.
|
|
66
|
+
<h1>${this.title}</h1>
|
|
67
|
+
<div class="date">${this.date} · ${this.readingTime} min read</div>
|
|
68
|
+
<p class="content">${this.content}</p>
|
|
58
69
|
`;
|
|
59
70
|
}
|
|
60
71
|
}
|
|
@@ -11,8 +11,9 @@ export async function loader({ params }: { params: { tag: string } }) {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export class PageTag extends LitElement {
|
|
14
|
-
static properties = {
|
|
15
|
-
|
|
14
|
+
static properties = { tag: { type: String }, posts: { type: Array } };
|
|
15
|
+
tag = '';
|
|
16
|
+
posts: any[] = [];
|
|
16
17
|
|
|
17
18
|
static styles = css`
|
|
18
19
|
:host { display: block; }
|
|
@@ -28,12 +29,11 @@ export class PageTag extends LitElement {
|
|
|
28
29
|
`;
|
|
29
30
|
|
|
30
31
|
render() {
|
|
31
|
-
const { tag, posts } = this.loaderData;
|
|
32
32
|
return html`
|
|
33
33
|
<a class="back" href="/">← All posts</a>
|
|
34
|
-
<h1>Tagged: ${tag}</h1>
|
|
35
|
-
<p class="subtitle">${posts?.length || 0} post${posts?.length !== 1 ? 's' : ''}</p>
|
|
36
|
-
${(posts || []).map((p: any) => html`
|
|
34
|
+
<h1>Tagged: ${this.tag}</h1>
|
|
35
|
+
<p class="subtitle">${this.posts?.length || 0} post${this.posts?.length !== 1 ? 's' : ''}</p>
|
|
36
|
+
${(this.posts || []).map((p: any) => html`
|
|
37
37
|
<div class="post">
|
|
38
38
|
<a href="/posts/${p.slug}">${p.title}</a>
|
|
39
39
|
<div class="meta">${p.date}</div>
|
|
@@ -31,11 +31,11 @@ export function subscribe({ push }: { push: (data: any) => void }) {
|
|
|
31
31
|
|
|
32
32
|
export class PageIndex extends LitElement {
|
|
33
33
|
static properties = {
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
stats: { type: Array },
|
|
35
|
+
updatedAt: { type: String },
|
|
36
36
|
};
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
stats: any[] = [];
|
|
38
|
+
updatedAt = '';
|
|
39
39
|
|
|
40
40
|
static styles = css`
|
|
41
41
|
:host { display: block; }
|
|
@@ -50,8 +50,8 @@ export class PageIndex extends LitElement {
|
|
|
50
50
|
`;
|
|
51
51
|
|
|
52
52
|
render() {
|
|
53
|
-
const stats = this.
|
|
54
|
-
const isLive = !!this.
|
|
53
|
+
const stats = this.stats || [];
|
|
54
|
+
const isLive = !!this.updatedAt;
|
|
55
55
|
return html`
|
|
56
56
|
<h1>Overview</h1>
|
|
57
57
|
<div class="grid">
|
|
@@ -64,7 +64,7 @@ export class PageIndex extends LitElement {
|
|
|
64
64
|
</div>
|
|
65
65
|
${isLive ? html`
|
|
66
66
|
<div class="status">
|
|
67
|
-
<span class="dot"></span>Live — updated ${this.
|
|
67
|
+
<span class="dot"></span>Live — updated ${this.updatedAt ? new Date(this.updatedAt).toLocaleTimeString() : ''}
|
|
68
68
|
</div>
|
|
69
69
|
` : ''}
|
|
70
70
|
`;
|
|
@@ -5,8 +5,8 @@ export async function loader() {
|
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
export class PageIndex extends LitElement {
|
|
8
|
-
static properties = {
|
|
9
|
-
|
|
8
|
+
static properties = { title: { type: String } };
|
|
9
|
+
title = '';
|
|
10
10
|
|
|
11
11
|
static styles = css`
|
|
12
12
|
:host { display: block; max-width: 640px; margin: 0 auto; padding: 2rem; font-family: system-ui; }
|
|
@@ -17,7 +17,7 @@ export class PageIndex extends LitElement {
|
|
|
17
17
|
|
|
18
18
|
render() {
|
|
19
19
|
return html`
|
|
20
|
-
<h1>${this.
|
|
20
|
+
<h1>${this.title}</h1>
|
|
21
21
|
<p>Edit <code>pages/index.ts</code> to get started.</p>
|
|
22
22
|
`;
|
|
23
23
|
}
|