@nuraly/lumenjs 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +80 -7
  2. package/dist/build/build-markdown.d.ts +15 -0
  3. package/dist/build/build-markdown.js +90 -0
  4. package/dist/build/build-server.js +13 -2
  5. package/dist/build/build.js +34 -2
  6. package/dist/build/scan.js +4 -1
  7. package/dist/build/serve-loaders.js +12 -2
  8. package/dist/build/serve-ssr.js +12 -3
  9. package/dist/build/serve-static.js +2 -1
  10. package/dist/build/serve.js +1 -1
  11. package/dist/communication/server.js +1 -0
  12. package/dist/communication/signaling.d.ts +2 -0
  13. package/dist/communication/signaling.js +41 -0
  14. package/dist/dev-server/plugins/vite-plugin-llms.js +1 -0
  15. package/dist/dev-server/plugins/vite-plugin-loaders.d.ts +4 -3
  16. package/dist/dev-server/plugins/vite-plugin-loaders.js +20 -5
  17. package/dist/dev-server/ssr-render.js +15 -3
  18. package/dist/editor/ai/types.d.ts +1 -1
  19. package/dist/editor/ai/types.js +2 -2
  20. package/dist/llms/generate.d.ts +15 -1
  21. package/dist/llms/generate.js +54 -44
  22. package/dist/runtime/communication.d.ts +65 -36
  23. package/dist/runtime/communication.js +117 -57
  24. package/dist/runtime/router.js +3 -3
  25. package/dist/runtime/webrtc.d.ts +8 -1
  26. package/dist/runtime/webrtc.js +49 -15
  27. package/dist/shared/html-to-markdown.d.ts +6 -0
  28. package/dist/shared/html-to-markdown.js +73 -0
  29. package/dist/shared/utils.js +8 -0
  30. package/package.json +2 -2
  31. package/templates/blog/pages/index.ts +3 -3
  32. package/templates/blog/pages/posts/[slug].ts +17 -6
  33. package/templates/blog/pages/tag/[tag].ts +6 -6
  34. package/templates/dashboard/pages/index.ts +7 -7
  35. package/templates/default/pages/index.ts +3 -3
@@ -54,16 +54,11 @@ export class WebRTCManager {
54
54
  throw err;
55
55
  }
56
56
  try {
57
- // Always request video so both peers negotiate a video track in the SDP.
58
- // For audio-only calls the video track is immediately disabled (black frame)
59
- // but stays in the SDP as sendrecv, allowing replaceTrack for screen sharing.
60
- this._localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio });
57
+ // Only request video when the call type is video.
58
+ // For audio-only calls, skip the camera entirely to avoid the permission
59
+ // prompt and camera LED. Screen sharing can add a video track later.
60
+ this._localStream = await navigator.mediaDevices.getUserMedia({ video, audio });
61
61
  this._callbacks.onLocalStream(this._localStream);
62
- if (!video) {
63
- for (const vt of this._localStream.getVideoTracks()) {
64
- vt.enabled = false;
65
- }
66
- }
67
62
  for (const track of this._localStream.getTracks()) {
68
63
  this._pc?.addTrack(track, this._localStream);
69
64
  }
@@ -232,19 +227,24 @@ export class GroupWebRTCManager {
232
227
  return result;
233
228
  }
234
229
  /** Acquire local media — call once before adding peers */
235
- async startLocalMedia(video = true, audio = true) {
230
+ async startLocalMedia(video = true, audio = true, deviceIds) {
236
231
  if (!navigator.mediaDevices?.getUserMedia) {
237
232
  const err = new Error('Media devices unavailable — HTTPS is required for calls');
238
233
  this._callbacks.onError(err);
239
234
  throw err;
240
235
  }
241
236
  try {
242
- this._localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio });
237
+ const audioConstraint = audio
238
+ ? (deviceIds?.audioInputId ? { deviceId: { exact: deviceIds.audioInputId } } : true)
239
+ : false;
240
+ const videoConstraint = video
241
+ ? (deviceIds?.videoInputId ? { deviceId: { exact: deviceIds.videoInputId } } : true)
242
+ : false;
243
+ this._localStream = await navigator.mediaDevices.getUserMedia({
244
+ audio: audioConstraint,
245
+ video: videoConstraint,
246
+ });
243
247
  this._callbacks.onLocalStream(this._localStream);
244
- if (!video) {
245
- for (const vt of this._localStream.getVideoTracks())
246
- vt.enabled = false;
247
- }
248
248
  return this._localStream;
249
249
  }
250
250
  catch (err) {
@@ -252,6 +252,40 @@ export class GroupWebRTCManager {
252
252
  throw err;
253
253
  }
254
254
  }
255
+ /** Replace the audio track on all peer connections (for switching mic mid-call) */
256
+ async replaceAudioTrack(newTrack) {
257
+ // Replace in local stream
258
+ if (this._localStream) {
259
+ const old = this._localStream.getAudioTracks()[0];
260
+ if (old) {
261
+ this._localStream.removeTrack(old);
262
+ old.stop();
263
+ }
264
+ this._localStream.addTrack(newTrack);
265
+ }
266
+ // Replace on all peer connections
267
+ for (const [, entry] of this._peers) {
268
+ const sender = entry.pc.getSenders().find(s => s.track?.kind === 'audio');
269
+ if (sender)
270
+ await sender.replaceTrack(newTrack);
271
+ }
272
+ }
273
+ /** Replace the video track on all peer connections (for switching camera mid-call) */
274
+ async replaceVideoTrack(newTrack) {
275
+ if (this._localStream) {
276
+ const old = this._localStream.getVideoTracks()[0];
277
+ if (old) {
278
+ this._localStream.removeTrack(old);
279
+ old.stop();
280
+ }
281
+ this._localStream.addTrack(newTrack);
282
+ }
283
+ for (const [, entry] of this._peers) {
284
+ const sender = entry.pc.getSenders().find(s => s.track?.kind === 'video');
285
+ if (sender)
286
+ await sender.replaceTrack(newTrack);
287
+ }
288
+ }
255
289
  /** Create a peer connection for a remote user and optionally create an offer */
256
290
  async addPeer(userId, isCaller) {
257
291
  if (this._peers.has(userId))
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  import fs from 'fs';
2
+ import path from 'path';
2
3
  /**
3
4
  * Strip the outer Lit SSR template markers from rendered HTML.
4
5
  * Lit SSR wraps every template render in <!--lit-part HASH-->...<!--/lit-part-->.
@@ -158,6 +159,13 @@ export function fileHasPrerender(filePath) {
158
159
  */
159
160
  export function fileHasLoader(filePath) {
160
161
  try {
162
+ // Check for co-located _loader.ts (folder route convention: index.ts + _loader.ts)
163
+ if (path.basename(filePath).replace(/\.(ts|js)$/, '') === 'index') {
164
+ const dir = path.dirname(filePath);
165
+ if (fs.existsSync(path.join(dir, '_loader.ts')) || fs.existsSync(path.join(dir, '_loader.js'))) {
166
+ return true;
167
+ }
168
+ }
161
169
  const content = fs.readFileSync(filePath, 'utf-8');
162
170
  return hasTopLevelExport(content, 'loader');
163
171
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuraly/lumenjs",
3
- "version": "0.2.0",
3
+ "version": "0.5.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",
@@ -43,7 +43,7 @@
43
43
  "license": "MIT",
44
44
  "dependencies": {
45
45
  "@lit-labs/ssr": "^3.2.0",
46
- "glob": "^10.3.0",
46
+ "glob": "^10.2.1",
47
47
  "lit": "^3.1.0",
48
48
  "vite": "^5.4.0"
49
49
  },
@@ -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
  }