@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 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 = { loaderData: { type: Object } };
81
- loaderData: any = {};
82
+ static properties = { post: { type: Object } };
83
+ post: any = null;
82
84
 
83
85
  render() {
84
- return html`<h1>${this.loaderData.post?.title}</h1>`;
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 = { liveData: { type: Object } };
114
- liveData: any = null;
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.liveData?.time}</p>`;
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
- if (entry.hasLoader || entry.hasSubscribe || entry.prerender) {
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) {
@@ -2,11 +2,13 @@ import path from 'path';
2
2
  import fs from 'fs';
3
3
  import { getSharedViteConfig } from '../dev-server/server.js';
4
4
  import { readProjectConfig } from '../dev-server/config.js';
5
- import { filePathToTagName } from '../shared/utils.js';
5
+ import { filePathToTagName, fileGetApiMethods } from '../shared/utils.js';
6
6
  import { scanPages, scanLayouts, scanApiRoutes, scanMiddleware, getLayoutDirsForPage } from './scan.js';
7
7
  import { buildClient } from './build-client.js';
8
8
  import { buildServer } from './build-server.js';
9
9
  import { prerenderPages } from './build-prerender.js';
10
+ import { generateMarkdownPages } from './build-markdown.js';
11
+ import { generateLlmsTxt } from '../llms/generate.js';
10
12
  export async function buildProject(options) {
11
13
  const { projectDir } = options;
12
14
  const outDir = options.outDir || path.join(projectDir, '.lumenjs');
@@ -79,7 +81,7 @@ export async function buildProject(options) {
79
81
  const relPath = path.relative(pagesDir, e.filePath).replace(/\\/g, '/');
80
82
  return {
81
83
  path: e.routePath,
82
- module: (e.hasLoader || e.hasSubscribe || e.hasSocket || e.prerender) ? `pages/${e.name.replace(/\[(\w+)\]/g, '_$1_')}.js` : '',
84
+ module: `pages/${e.name.replace(/\[(\w+)\]/g, '_$1_')}.js`,
83
85
  hasLoader: e.hasLoader,
84
86
  hasSubscribe: e.hasSubscribe,
85
87
  tagName: filePathToTagName(relPath),
@@ -114,6 +116,28 @@ export async function buildProject(options) {
114
116
  prefetch: prefetchStrategy,
115
117
  };
116
118
  fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
119
+ // --- Generate llms.txt ---
120
+ const publicLlms = path.join(publicDir, 'llms.txt');
121
+ if (!fs.existsSync(publicLlms)) {
122
+ const llmsPages = pageEntries.map(e => ({
123
+ path: e.routePath,
124
+ hasLoader: e.hasLoader,
125
+ hasSubscribe: e.hasSubscribe,
126
+ }));
127
+ const llmsApiRoutes = apiEntries.map(e => ({
128
+ path: e.routePath,
129
+ methods: fileGetApiMethods(e.filePath),
130
+ })).filter(r => r.methods.length > 0);
131
+ const llmsContent = generateLlmsTxt({
132
+ title,
133
+ pages: llmsPages,
134
+ apiRoutes: llmsApiRoutes,
135
+ integrations,
136
+ i18n: i18nConfig ? { locales: i18nConfig.locales, defaultLocale: i18nConfig.defaultLocale } : undefined,
137
+ });
138
+ fs.writeFileSync(path.join(clientDir, 'llms.txt'), llmsContent);
139
+ console.log('[LumenJS] Generated llms.txt');
140
+ }
117
141
  // --- Pre-render phase ---
118
142
  await prerenderPages({
119
143
  serverDir,
@@ -123,6 +147,14 @@ export async function buildProject(options) {
123
147
  layoutEntries,
124
148
  manifest,
125
149
  });
150
+ // --- Generate .md files for each page (llms.txt per-page support) ---
151
+ await generateMarkdownPages({
152
+ serverDir,
153
+ clientDir,
154
+ pagesDir,
155
+ pageEntries,
156
+ manifest,
157
+ });
126
158
  console.log('[LumenJS] Build complete.');
127
159
  console.log(` Output: ${outDir}`);
128
160
  console.log(` Client assets: ${clientDir}`);
@@ -4,6 +4,8 @@ import { createGzip } from 'zlib';
4
4
  import { pipeline } from 'stream';
5
5
  export const MIME_TYPES = {
6
6
  '.html': 'text/html; charset=utf-8',
7
+ '.md': 'text/markdown; charset=utf-8',
8
+ '.txt': 'text/plain; charset=utf-8',
7
9
  '.js': 'application/javascript; charset=utf-8',
8
10
  '.mjs': 'application/javascript; charset=utf-8',
9
11
  '.css': 'text/css; charset=utf-8',
@@ -21,7 +23,6 @@ export const MIME_TYPES = {
21
23
  '.eot': 'application/vnd.ms-fontobject',
22
24
  '.otf': 'font/otf',
23
25
  '.map': 'application/json',
24
- '.txt': 'text/plain; charset=utf-8',
25
26
  '.xml': 'application/xml',
26
27
  '.webmanifest': 'application/manifest+json',
27
28
  };
@@ -332,7 +332,7 @@ export async function serveProject(options) {
332
332
  fs.createReadStream(filePath).pipe(res);
333
333
  return;
334
334
  }
335
- // 3. Static assets — try to serve from client dir
335
+ // 3. Static assets — try to serve from client dir (includes pre-generated .md files)
336
336
  if (pathname.includes('.')) {
337
337
  const served = serveStaticFile(clientDir, pathname, req, res);
338
338
  if (served)
@@ -75,6 +75,7 @@ export function lumenLlmsPlugin(projectDir) {
75
75
  integrations: config.integrations,
76
76
  i18n: config.i18n ? { locales: config.i18n.locales, defaultLocale: config.i18n.defaultLocale } : undefined,
77
77
  db: config.db,
78
+ baseUrl: '',
78
79
  });
79
80
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
80
81
  res.setHeader('Cache-Control', 'no-store');
@@ -13,15 +13,16 @@ import { Plugin } from 'vite';
13
13
  * }
14
14
  *
15
15
  * export class PageItem extends LitElement {
16
- * @property({ type: Object }) loaderData = {};
16
+ * @property({ type: Object }) item = null;
17
+ * @property({ type: Number }) timestamp = 0;
17
18
  * render() {
18
- * return html`<h1>${this.loaderData.item?.name}</h1>`;
19
+ * return html`<h1>${this.item?.name}</h1>`;
19
20
  * }
20
21
  * }
21
22
  *
22
23
  * The loader runs server-side via /__nk_loader/<page-path>
23
24
  * Layout loaders run via /__nk_loader/__layout/?__dir=<dir>
24
- * The router auto-fetches and passes the result as `loaderData` property.
25
+ * The router auto-fetches and spreads each key as an individual property on the element.
25
26
  */
26
27
  export declare function lumenLoadersPlugin(pagesDir: string): Plugin;
27
28
  /**
@@ -16,15 +16,16 @@ import { installDomShims } from '../../shared/dom-shims.js';
16
16
  * }
17
17
  *
18
18
  * export class PageItem extends LitElement {
19
- * @property({ type: Object }) loaderData = {};
19
+ * @property({ type: Object }) item = null;
20
+ * @property({ type: Number }) timestamp = 0;
20
21
  * render() {
21
- * return html`<h1>${this.loaderData.item?.name}</h1>`;
22
+ * return html`<h1>${this.item?.name}</h1>`;
22
23
  * }
23
24
  * }
24
25
  *
25
26
  * The loader runs server-side via /__nk_loader/<page-path>
26
27
  * Layout loaders run via /__nk_loader/__layout/?__dir=<dir>
27
- * The router auto-fetches and passes the result as `loaderData` property.
28
+ * The router auto-fetches and spreads each key as an individual property on the element.
28
29
  */
29
30
  export function lumenLoadersPlugin(pagesDir) {
30
31
  return {
@@ -26,5 +26,5 @@ export interface AiStatusResult {
26
26
  configured: boolean;
27
27
  backend: 'claude-code' | 'opencode';
28
28
  }
29
- export declare const SYSTEM_PROMPT = "You are an AI coding assistant working inside a LumenJS project.\n\nLumenJS is a full-stack Lit web component framework with file-based routing, server loaders, SSR, and API routes.\n\nKey conventions:\n- Pages live in `pages/` directory \u2014 file path maps to URL route\n- Components are Lit web components (LitElement) auto-registered by file path\n- Layouts: `_layout.ts` in any directory for nested layouts (use <slot>)\n- API routes: `api/` directory with named exports (GET, POST, PUT, DELETE)\n- Server loaders: `export async function loader()` for server-side data fetching\n- Styles: use Tailwind CSS classes or Lit's `static styles` with css template tag\n- Config: `lumenjs.config.ts` at project root\n\nAuto-registration:\n- Pages and layouts are auto-registered by file path \u2014 do NOT add @customElement decorators.\n `pages/about.ts` \u2192 `<page-about>`, `pages/blog/_layout.ts` \u2192 `<layout-blog>`\n\nServer loaders:\n- `export async function loader({ params, query, url, headers, locale })` at file top level.\n- Return a data object \u2192 available as `this.loaderData` on the page element.\n\nSubscribe (SSE):\n- `export async function subscribe({ params, headers, locale, push })` for real-time data.\n- Call `push(data)` to send events \u2192 available as `this.liveData` on the page element.\n\nMiddleware:\n- `_middleware.ts` in any directory applies to that route subtree.\n\nDynamic routes:\n- `[slug]` for dynamic params, `[...rest]` for catch-all.\n\nProperties:\n- Use `@property()` for public reactive props, `@state()` for internal state (from `lit/decorators.js`).\n\nExample \u2014 adding a new page (`pages/contact.ts`):\n```\nimport { LitElement, html, css } from 'lit';\nimport { property } from 'lit/decorators.js';\n\nexport default class extends LitElement {\n static styles = css\\`/* styles here */\\`;\n render() { return html\\`<h1>Contact</h1>\\`; }\n}\n```\n\nIMPORTANT \u2014 Styling rules:\n- When asked to change a style (color, font, spacing, etc.), find and UPDATE the EXISTING CSS rule in `static styles = css\\`...\\``. Do NOT add a new class or duplicate rule.\n- Never add inline `style=\"...\"` attributes on HTML template elements. Always modify the CSS rule in `static styles`.\n- Example: to change the h1 color, find the `h1 { ... }` rule in `static styles` and update its `color` property. Do not create a new class.\n- If no CSS rule exists for the element, add one to the existing `static styles` block \u2014 do not add a separate `<style>` tag.\n\nIMPORTANT \u2014 i18n / translation rules (when the project uses i18n):\n- Text content in templates uses `t('key')` from `@lumenjs/i18n` \u2014 NEVER replace a `t()` call with hardcoded text.\n- To change displayed text, edit the translation value in `locales/<locale>.json` \u2014 do NOT modify the template.\n- Example: to change the subtitle, update `\"home.subtitle\"` in `locales/en.json` (and other locale files like `locales/fr.json`).\n- To add new text, add a key to ALL locale JSON files and use `t('new.key')` in the template.\n- The dev server watches locale files and updates the page automatically via HMR.\n\nYou have full access to the filesystem and can run shell commands.\nWhen a task requires a new npm package, install it with `npm install <package>`.\nAfter npm install, the dev server will automatically restart to load the new dependency.\nVite's HMR will pick up file changes automatically \u2014 no manual restart needed.\n\nIMPORTANT \u2014 Be fast and direct:\n- Make changes immediately \u2014 do not explain what you will do before doing it.\n- Read the file, make the edit, done. Minimize tool calls.\n- For simple CSS/text changes, edit directly without reading first if you have the source context.\n- Keep responses under 2 sentences. The user sees the diff, not your explanation.\n";
29
+ export declare const SYSTEM_PROMPT = "You are an AI coding assistant working inside a LumenJS project.\n\nLumenJS is a full-stack Lit web component framework with file-based routing, server loaders, SSR, and API routes.\n\nKey conventions:\n- Pages live in `pages/` directory \u2014 file path maps to URL route\n- Components are Lit web components (LitElement) auto-registered by file path\n- Layouts: `_layout.ts` in any directory for nested layouts (use <slot>)\n- API routes: `api/` directory with named exports (GET, POST, PUT, DELETE)\n- Server loaders: `export async function loader()` for server-side data fetching\n- Styles: use Tailwind CSS classes or Lit's `static styles` with css template tag\n- Config: `lumenjs.config.ts` at project root\n\nAuto-registration:\n- Pages and layouts are auto-registered by file path \u2014 do NOT add @customElement decorators.\n `pages/about.ts` \u2192 `<page-about>`, `pages/blog/_layout.ts` \u2192 `<layout-blog>`\n\nServer loaders:\n- `export async function loader({ params, query, url, headers, locale })` at file top level.\n- Return a data object \u2192 each key is spread as an individual property on the page element (e.g., return `{ posts }` \u2192 access as `this.posts`).\n\nSubscribe (SSE):\n- `export async function subscribe({ params, headers, locale, push })` for real-time data.\n- Call `push(data)` to send events \u2192 each key is spread as an individual property on the page element (same as loader data).\n\nMiddleware:\n- `_middleware.ts` in any directory applies to that route subtree.\n\nDynamic routes:\n- `[slug]` for dynamic params, `[...rest]` for catch-all.\n\nProperties:\n- Use `@property()` for public reactive props, `@state()` for internal state (from `lit/decorators.js`).\n\nExample \u2014 adding a new page (`pages/contact.ts`):\n```\nimport { LitElement, html, css } from 'lit';\nimport { property } from 'lit/decorators.js';\n\nexport default class extends LitElement {\n static styles = css\\`/* styles here */\\`;\n render() { return html\\`<h1>Contact</h1>\\`; }\n}\n```\n\nIMPORTANT \u2014 Styling rules:\n- When asked to change a style (color, font, spacing, etc.), find and UPDATE the EXISTING CSS rule in `static styles = css\\`...\\``. Do NOT add a new class or duplicate rule.\n- Never add inline `style=\"...\"` attributes on HTML template elements. Always modify the CSS rule in `static styles`.\n- Example: to change the h1 color, find the `h1 { ... }` rule in `static styles` and update its `color` property. Do not create a new class.\n- If no CSS rule exists for the element, add one to the existing `static styles` block \u2014 do not add a separate `<style>` tag.\n\nIMPORTANT \u2014 i18n / translation rules (when the project uses i18n):\n- Text content in templates uses `t('key')` from `@lumenjs/i18n` \u2014 NEVER replace a `t()` call with hardcoded text.\n- To change displayed text, edit the translation value in `locales/<locale>.json` \u2014 do NOT modify the template.\n- Example: to change the subtitle, update `\"home.subtitle\"` in `locales/en.json` (and other locale files like `locales/fr.json`).\n- To add new text, add a key to ALL locale JSON files and use `t('new.key')` in the template.\n- The dev server watches locale files and updates the page automatically via HMR.\n\nYou have full access to the filesystem and can run shell commands.\nWhen a task requires a new npm package, install it with `npm install <package>`.\nAfter npm install, the dev server will automatically restart to load the new dependency.\nVite's HMR will pick up file changes automatically \u2014 no manual restart needed.\n\nIMPORTANT \u2014 Be fast and direct:\n- Make changes immediately \u2014 do not explain what you will do before doing it.\n- Read the file, make the edit, done. Minimize tool calls.\n- For simple CSS/text changes, edit directly without reading first if you have the source context.\n- Keep responses under 2 sentences. The user sees the diff, not your explanation.\n";
30
30
  export declare function buildPrompt(options: AiChatOptions): string;
@@ -20,11 +20,11 @@ Auto-registration:
20
20
 
21
21
  Server loaders:
22
22
  - \`export async function loader({ params, query, url, headers, locale })\` at file top level.
23
- - Return a data object → available as \`this.loaderData\` on the page element.
23
+ - Return a data object → each key is spread as an individual property on the page element (e.g., return \`{ posts }\` → access as \`this.posts\`).
24
24
 
25
25
  Subscribe (SSE):
26
26
  - \`export async function subscribe({ params, headers, locale, push })\` for real-time data.
27
- - Call \`push(data)\` to send events → available as \`this.liveData\` on the page element.
27
+ - Call \`push(data)\` to send events → each key is spread as an individual property on the page element (same as loader data).
28
28
 
29
29
  Middleware:
30
30
  - \`_middleware.ts\` in any directory applies to that route subtree.
@@ -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 from project metadata and resolved page data.
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.
@@ -1,62 +1,53 @@
1
1
  /**
2
- * Generate the llms.txt content from project metadata and resolved page data.
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('> Built with LumenJS');
12
+ lines.push(`> ${input.description || `${input.title}. Built with LumenJS.`}`);
9
13
  lines.push('');
10
- // Pages section
11
- if (input.pages.length > 0) {
12
- lines.push('## Pages');
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 input.pages) {
15
- lines.push(`### ${page.path}`);
28
+ for (const page of pages) {
16
29
  const isDynamic = page.path.includes(':');
17
- if (isDynamic) {
18
- // Dynamic route — show expanded entries if available
19
- if (page.dynamicEntries && page.dynamicEntries.length > 0) {
20
- lines.push(`Dynamic route — ${page.dynamicEntries.length} ${page.dynamicEntries.length === 1 ? 'entry' : 'entries'}:`);
21
- lines.push('');
22
- for (const entry of page.dynamicEntries) {
23
- lines.push(`#### ${entry.path}`);
24
- if (entry.loaderData) {
25
- lines.push(flattenData(entry.loaderData));
26
- }
27
- lines.push('');
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 Routes');
47
+ lines.push('## API');
55
48
  lines.push('');
56
49
  for (const route of input.apiRoutes) {
57
- for (const method of route.methods) {
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 presence.
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
- * Must be called before using any other functions.
7
+ * Reuses an existing socket if one is already connected.
8
8
  */
9
- export declare function connectChat(params?: Record<string, string>): Promise<void>;
10
- /** Join a conversation room to receive messages */
11
- export declare function joinConversation(conversationId: string): void;
12
- /** Leave a conversation room */
13
- export declare function leaveConversation(conversationId: string): void;
14
- /** Send a message */
15
- export declare function sendMessage(conversationId: string, content: string, type?: string): void;
16
- /** Mark a message as read */
17
- export declare function markRead(conversationId: string, messageId: string): void;
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 (initiating, ringing, connecting, connected, ended) */
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[]): void;
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 presence.
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
- throw new Error('Communication not connected. Call connectChat() first.');
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
- * Must be called before using any other functions.
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
- /** Join a conversation room to receive messages */
46
- export function joinConversation(conversationId) {
47
- emit('conversation:join', { conversationId });
48
- }
49
- /** Leave a conversation room */
50
- export function leaveConversation(conversationId) {
51
- emit('conversation:leave', { conversationId });
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
- /** Send a message */
54
- export function sendMessage(conversationId, content, type = 'text') {
55
- emit('message:send', { conversationId, content, type });
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 a message as read */
58
- export function markRead(conversationId, messageId) {
59
- emit('message:read', { conversationId, messageId });
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
- /** Start typing indicator */
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 (initiating, ringing, connecting, connected, ended) */
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 });
@@ -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.liveData = JSON.parse(e.data);
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.liveData = data;
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.liveData = JSON.parse(e.data);
220
+ this.spreadData(layoutEl, JSON.parse(e.data));
221
221
  };
222
222
  this.subscriptions.push(es);
223
223
  }
@@ -0,0 +1,6 @@
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 declare function htmlToMarkdown(html: string): string;
@@ -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(/&amp;/g, '&')
62
+ .replace(/&lt;/g, '<')
63
+ .replace(/&gt;/g, '>')
64
+ .replace(/&quot;/g, '"')
65
+ .replace(/&#39;/g, "'")
66
+ .replace(/&rarr;/g, '→')
67
+ .replace(/&larr;/g, '←')
68
+ .replace(/&middot;/g, '·')
69
+ .replace(/&copy;/g, '©')
70
+ .replace(/\\u003c/g, '<')
71
+ .replace(/&#x27;/g, "'")
72
+ .replace(/&nbsp;/g, ' ');
73
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuraly/lumenjs",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Full-stack Lit web component framework with file-based routing, server loaders, SSR, and API routes",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
@@ -33,8 +33,8 @@ export async function loader() {
33
33
  }
34
34
 
35
35
  export class PageIndex extends LitElement {
36
- static properties = { loaderData: { type: Object } };
37
- loaderData: any = {};
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.loaderData.posts || [];
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 = { loaderData: { type: Object }, slug: { type: String } };
33
- loaderData: any = {};
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.loaderData.notFound) {
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.loaderData.title}</h1>
56
- <div class="date">${this.loaderData.date} · ${this.loaderData.readingTime} min read</div>
57
- <p class="content">${this.loaderData.content}</p>
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 = { loaderData: { type: Object } };
15
- loaderData: any = {};
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
- loaderData: { type: Object },
35
- liveData: { type: Object },
34
+ stats: { type: Array },
35
+ updatedAt: { type: String },
36
36
  };
37
- loaderData: any = {};
38
- liveData: any = null;
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.liveData?.stats || this.loaderData.stats || [];
54
- const isLive = !!this.liveData;
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.liveData.updatedAt ? new Date(this.liveData.updatedAt).toLocaleTimeString() : ''}
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 = { loaderData: { type: Object } };
9
- loaderData: any = {};
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.loaderData.title}</h1>
20
+ <h1>${this.title}</h1>
21
21
  <p>Edit <code>pages/index.ts</code> to get started.</p>
22
22
  `;
23
23
  }