@nuraly/lumenjs 0.1.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 (76) hide show
  1. package/README.md +297 -0
  2. package/dist/build/build.d.ts +5 -0
  3. package/dist/build/build.js +172 -0
  4. package/dist/build/error-page.d.ts +1 -0
  5. package/dist/build/error-page.js +74 -0
  6. package/dist/build/scan.d.ts +21 -0
  7. package/dist/build/scan.js +93 -0
  8. package/dist/build/serve-api.d.ts +3 -0
  9. package/dist/build/serve-api.js +56 -0
  10. package/dist/build/serve-loaders.d.ts +4 -0
  11. package/dist/build/serve-loaders.js +115 -0
  12. package/dist/build/serve-ssr.d.ts +7 -0
  13. package/dist/build/serve-ssr.js +121 -0
  14. package/dist/build/serve-static.d.ts +6 -0
  15. package/dist/build/serve-static.js +80 -0
  16. package/dist/build/serve.d.ts +5 -0
  17. package/dist/build/serve.js +79 -0
  18. package/dist/cli.d.ts +2 -0
  19. package/dist/cli.js +65 -0
  20. package/dist/dev-server/config.d.ts +25 -0
  21. package/dist/dev-server/config.js +55 -0
  22. package/dist/dev-server/index-html.d.ts +16 -0
  23. package/dist/dev-server/index-html.js +46 -0
  24. package/dist/dev-server/nuralyui-aliases.d.ts +16 -0
  25. package/dist/dev-server/nuralyui-aliases.js +164 -0
  26. package/dist/dev-server/plugins/vite-plugin-api-routes.d.ts +23 -0
  27. package/dist/dev-server/plugins/vite-plugin-api-routes.js +250 -0
  28. package/dist/dev-server/plugins/vite-plugin-auto-import.d.ts +5 -0
  29. package/dist/dev-server/plugins/vite-plugin-auto-import.js +47 -0
  30. package/dist/dev-server/plugins/vite-plugin-lit-dedup.d.ts +5 -0
  31. package/dist/dev-server/plugins/vite-plugin-lit-dedup.js +62 -0
  32. package/dist/dev-server/plugins/vite-plugin-lit-hmr.d.ts +5 -0
  33. package/dist/dev-server/plugins/vite-plugin-lit-hmr.js +46 -0
  34. package/dist/dev-server/plugins/vite-plugin-loaders.d.ts +38 -0
  35. package/dist/dev-server/plugins/vite-plugin-loaders.js +320 -0
  36. package/dist/dev-server/plugins/vite-plugin-routes.d.ts +21 -0
  37. package/dist/dev-server/plugins/vite-plugin-routes.js +157 -0
  38. package/dist/dev-server/plugins/vite-plugin-source-annotator.d.ts +5 -0
  39. package/dist/dev-server/plugins/vite-plugin-source-annotator.js +39 -0
  40. package/dist/dev-server/plugins/vite-plugin-virtual-modules.d.ts +5 -0
  41. package/dist/dev-server/plugins/vite-plugin-virtual-modules.js +38 -0
  42. package/dist/dev-server/server.d.ts +23 -0
  43. package/dist/dev-server/server.js +155 -0
  44. package/dist/dev-server/ssr-render.d.ts +20 -0
  45. package/dist/dev-server/ssr-render.js +170 -0
  46. package/dist/editor/click-select.d.ts +1 -0
  47. package/dist/editor/click-select.js +46 -0
  48. package/dist/editor/editor-bridge.d.ts +17 -0
  49. package/dist/editor/editor-bridge.js +101 -0
  50. package/dist/editor/element-annotator.d.ts +33 -0
  51. package/dist/editor/element-annotator.js +83 -0
  52. package/dist/editor/hover-detect.d.ts +1 -0
  53. package/dist/editor/hover-detect.js +36 -0
  54. package/dist/editor/inline-text-edit.d.ts +1 -0
  55. package/dist/editor/inline-text-edit.js +114 -0
  56. package/dist/integrations/add.d.ts +1 -0
  57. package/dist/integrations/add.js +89 -0
  58. package/dist/runtime/app-shell.d.ts +1 -0
  59. package/dist/runtime/app-shell.js +22 -0
  60. package/dist/runtime/response.d.ts +15 -0
  61. package/dist/runtime/response.js +13 -0
  62. package/dist/runtime/router-data.d.ts +3 -0
  63. package/dist/runtime/router-data.js +40 -0
  64. package/dist/runtime/router-hydration.d.ts +10 -0
  65. package/dist/runtime/router-hydration.js +68 -0
  66. package/dist/runtime/router.d.ts +35 -0
  67. package/dist/runtime/router.js +202 -0
  68. package/dist/shared/dom-shims.d.ts +5 -0
  69. package/dist/shared/dom-shims.js +63 -0
  70. package/dist/shared/route-matching.d.ts +6 -0
  71. package/dist/shared/route-matching.js +44 -0
  72. package/dist/shared/types.d.ts +16 -0
  73. package/dist/shared/types.js +1 -0
  74. package/dist/shared/utils.d.ts +42 -0
  75. package/dist/shared/utils.js +109 -0
  76. package/package.json +53 -0
@@ -0,0 +1,36 @@
1
+ import { findAnnotatedElement } from './element-annotator.js';
2
+ import { sendToHost, serializeRect, isPreviewMode } from './editor-bridge.js';
3
+ export function setupHoverDetection() {
4
+ let lastHovered = null;
5
+ document.addEventListener('mouseover', (event) => {
6
+ if (isPreviewMode())
7
+ return;
8
+ const result = findAnnotatedElement(event);
9
+ if (!result)
10
+ return;
11
+ const nkId = result.element.getAttribute('data-nk-id');
12
+ if (nkId === lastHovered)
13
+ return;
14
+ lastHovered = nkId;
15
+ sendToHost({
16
+ type: 'NK_ELEMENT_HOVERED',
17
+ payload: {
18
+ tag: result.source.tag,
19
+ nkId,
20
+ rect: serializeRect(result.element.getBoundingClientRect()),
21
+ }
22
+ });
23
+ }, true);
24
+ document.addEventListener('mouseout', (event) => {
25
+ if (isPreviewMode())
26
+ return;
27
+ const related = event.relatedTarget;
28
+ if (related) {
29
+ const result = findAnnotatedElement(event);
30
+ if (result)
31
+ return; // Still hovering over an annotated element
32
+ }
33
+ lastHovered = null;
34
+ sendToHost({ type: 'NK_ELEMENT_HOVERED', payload: null });
35
+ }, true);
36
+ }
@@ -0,0 +1 @@
1
+ export declare function setupInlineTextEdit(): void;
@@ -0,0 +1,114 @@
1
+ import { sendToHost, isPreviewMode } from './editor-bridge.js';
2
+ export function setupInlineTextEdit() {
3
+ let editingEl = null;
4
+ document.addEventListener('dblclick', (event) => {
5
+ if (isPreviewMode())
6
+ return;
7
+ // Walk the composed path to find a text-bearing element
8
+ const editableTags = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'P', 'SPAN', 'A', 'LABEL', 'LI'];
9
+ const composedPath = event.composedPath();
10
+ let textEl = null;
11
+ let annotatedParent = null;
12
+ for (const node of composedPath) {
13
+ if (!(node instanceof HTMLElement))
14
+ continue;
15
+ if (!textEl && editableTags.includes(node.tagName)) {
16
+ textEl = node;
17
+ }
18
+ if (!annotatedParent && node.getAttribute('data-nk-source')) {
19
+ annotatedParent = node;
20
+ }
21
+ if (textEl && annotatedParent)
22
+ break;
23
+ }
24
+ // If no direct text element, check if target itself has only text content
25
+ if (!textEl) {
26
+ const target = event.target;
27
+ if (target && target.childNodes.length > 0) {
28
+ const hasOnlyText = Array.from(target.childNodes).every(n => n.nodeType === Node.TEXT_NODE);
29
+ if (hasOnlyText && target.textContent?.trim()) {
30
+ textEl = target;
31
+ }
32
+ }
33
+ }
34
+ if (!textEl || !annotatedParent || editingEl)
35
+ return;
36
+ // Block inline editing for elements bound to dynamic expressions
37
+ if (textEl.hasAttribute('data-nk-dynamic') || textEl.closest('[data-nk-dynamic]')) {
38
+ event.preventDefault();
39
+ event.stopPropagation();
40
+ textEl.style.outline = '2px dashed #f59e0b';
41
+ textEl.style.outlineOffset = '2px';
42
+ const indicator = document.createElement('div');
43
+ Object.assign(indicator.style, {
44
+ position: 'fixed',
45
+ background: '#f59e0b',
46
+ color: '#000',
47
+ padding: '4px 8px',
48
+ borderRadius: '4px',
49
+ fontSize: '11px',
50
+ fontFamily: 'system-ui',
51
+ zIndex: '10000',
52
+ pointerEvents: 'none',
53
+ });
54
+ indicator.textContent = '\u26A1 Bound to variable \u2014 edit in code';
55
+ const rect = textEl.getBoundingClientRect();
56
+ indicator.style.left = `${rect.left}px`;
57
+ indicator.style.top = `${rect.top - 28}px`;
58
+ document.body.appendChild(indicator);
59
+ setTimeout(() => {
60
+ textEl.style.outline = '';
61
+ textEl.style.outlineOffset = '';
62
+ indicator.remove();
63
+ }, 2000);
64
+ return;
65
+ }
66
+ event.preventDefault();
67
+ event.stopPropagation();
68
+ editingEl = textEl;
69
+ const originalText = textEl.textContent || '';
70
+ textEl.setAttribute('contenteditable', 'true');
71
+ textEl.focus();
72
+ textEl.style.outline = '2px solid #3b82f6';
73
+ textEl.style.outlineOffset = '2px';
74
+ textEl.style.borderRadius = '2px';
75
+ textEl.style.minWidth = '20px';
76
+ const range = document.createRange();
77
+ range.selectNodeContents(textEl);
78
+ const sel = window.getSelection();
79
+ sel?.removeAllRanges();
80
+ sel?.addRange(range);
81
+ const sourceAttr = annotatedParent.getAttribute('data-nk-source');
82
+ const lastColon = sourceAttr.lastIndexOf(':');
83
+ const sourceFile = sourceAttr.substring(0, lastColon);
84
+ const line = parseInt(sourceAttr.substring(lastColon + 1), 10);
85
+ const commitEdit = () => {
86
+ if (!editingEl)
87
+ return;
88
+ const newText = editingEl.textContent || '';
89
+ editingEl.removeAttribute('contenteditable');
90
+ editingEl.style.outline = '';
91
+ editingEl.style.outlineOffset = '';
92
+ editingEl.style.borderRadius = '';
93
+ editingEl.style.minWidth = '';
94
+ editingEl = null;
95
+ if (newText !== originalText) {
96
+ sendToHost({
97
+ type: 'NK_TEXT_CHANGED',
98
+ payload: { sourceFile, line, originalText, newText }
99
+ });
100
+ }
101
+ };
102
+ textEl.addEventListener('blur', commitEdit, { once: true });
103
+ textEl.addEventListener('keydown', (e) => {
104
+ if (e.key === 'Enter' && !e.shiftKey) {
105
+ e.preventDefault();
106
+ textEl.blur();
107
+ }
108
+ if (e.key === 'Escape') {
109
+ textEl.textContent = originalText;
110
+ textEl.blur();
111
+ }
112
+ });
113
+ });
114
+ }
@@ -0,0 +1 @@
1
+ export declare function addIntegration(projectDir: string, integration: string): Promise<void>;
@@ -0,0 +1,89 @@
1
+ import { execSync } from 'child_process';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ const INTEGRATIONS = {
5
+ tailwind: {
6
+ packages: ['tailwindcss', '@tailwindcss/vite'],
7
+ setup(projectDir) {
8
+ const stylesDir = path.join(projectDir, 'styles');
9
+ const cssPath = path.join(stylesDir, 'tailwind.css');
10
+ if (!fs.existsSync(stylesDir)) {
11
+ fs.mkdirSync(stylesDir, { recursive: true });
12
+ }
13
+ if (!fs.existsSync(cssPath)) {
14
+ fs.writeFileSync(cssPath, '@import "tailwindcss";\n');
15
+ console.log(' \u2713 Created styles/tailwind.css');
16
+ }
17
+ else {
18
+ console.log(' \u2713 styles/tailwind.css already exists');
19
+ }
20
+ },
21
+ message: `Tailwind CSS is ready. Use utility classes in light-DOM pages:
22
+ createRenderRoot() { return this; }`,
23
+ },
24
+ };
25
+ export async function addIntegration(projectDir, integration) {
26
+ if (!integration) {
27
+ console.error('Usage: lumenjs add <integration>');
28
+ console.error(`Available integrations: ${Object.keys(INTEGRATIONS).join(', ')}`);
29
+ process.exit(1);
30
+ }
31
+ const config = INTEGRATIONS[integration];
32
+ if (!config) {
33
+ console.error(`Unknown integration: ${integration}`);
34
+ console.error(`Available integrations: ${Object.keys(INTEGRATIONS).join(', ')}`);
35
+ process.exit(1);
36
+ }
37
+ console.log(`[LumenJS] Adding ${integration} integration...`);
38
+ // Install packages into the project
39
+ const pkgs = config.packages.join(' ');
40
+ console.log(` Installing ${pkgs}...`);
41
+ try {
42
+ execSync(`npm install ${pkgs}`, { cwd: projectDir, stdio: 'inherit' });
43
+ console.log(` \u2713 Installed ${config.packages.join(', ')}`);
44
+ }
45
+ catch {
46
+ console.error(` \u2717 Failed to install packages. Make sure npm is available.`);
47
+ process.exit(1);
48
+ }
49
+ // Run integration-specific setup
50
+ config.setup(projectDir);
51
+ // Update lumenjs.config.ts
52
+ updateConfig(projectDir, integration);
53
+ console.log('');
54
+ console.log(` ${config.message}`);
55
+ }
56
+ function updateConfig(projectDir, integration) {
57
+ const configPath = path.join(projectDir, 'lumenjs.config.ts');
58
+ if (!fs.existsSync(configPath)) {
59
+ // Create config file with the integration
60
+ fs.writeFileSync(configPath, `export default {
61
+ integrations: ['${integration}'],
62
+ };
63
+ `);
64
+ console.log(' \u2713 Created lumenjs.config.ts');
65
+ return;
66
+ }
67
+ let content = fs.readFileSync(configPath, 'utf-8');
68
+ // Check if integrations array already exists
69
+ const integrationsMatch = content.match(/integrations\s*:\s*\[([^\]]*)\]/);
70
+ if (integrationsMatch) {
71
+ const existing = integrationsMatch[1];
72
+ // Check if already included
73
+ if (existing.includes(`'${integration}'`) || existing.includes(`"${integration}"`)) {
74
+ console.log(` \u2713 lumenjs.config.ts already includes '${integration}'`);
75
+ return;
76
+ }
77
+ // Append to existing array
78
+ const newList = existing.trim()
79
+ ? `${existing.trim()}, '${integration}'`
80
+ : `'${integration}'`;
81
+ content = content.replace(/integrations\s*:\s*\[[^\]]*\]/, `integrations: [${newList}]`);
82
+ }
83
+ else {
84
+ // Add integrations field before the closing of the default export
85
+ content = content.replace(/};\s*$/, ` integrations: ['${integration}'],\n};\n`);
86
+ }
87
+ fs.writeFileSync(configPath, content);
88
+ console.log(' \u2713 Updated lumenjs.config.ts');
89
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ import { routes } from 'virtual:lumenjs-routes';
2
+ import { NkRouter } from './router.js';
3
+ /**
4
+ * <nk-app> — The application shell. Sets up the router and renders pages.
5
+ */
6
+ class NkApp extends HTMLElement {
7
+ constructor() {
8
+ super(...arguments);
9
+ this.router = null;
10
+ }
11
+ connectedCallback() {
12
+ const isSSR = this.hasAttribute('data-nk-ssr');
13
+ if (!isSSR) {
14
+ this.innerHTML = '<div id="nk-router-outlet"></div>';
15
+ }
16
+ const outlet = this.querySelector('#nk-router-outlet');
17
+ this.router = new NkRouter(routes, outlet, isSSR);
18
+ }
19
+ }
20
+ if (!customElements.get('nk-app')) {
21
+ customElements.define('nk-app', NkApp);
22
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Redirect from a loader function.
3
+ *
4
+ * Usage:
5
+ * export async function loader({ headers }) {
6
+ * const user = headers['x-user'];
7
+ * if (!user) return redirect('/login');
8
+ * return { user: JSON.parse(user) };
9
+ * }
10
+ */
11
+ export declare function redirect(location: string, status?: number): {
12
+ __nk_redirect: true;
13
+ location: string;
14
+ status: number;
15
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Redirect from a loader function.
3
+ *
4
+ * Usage:
5
+ * export async function loader({ headers }) {
6
+ * const user = headers['x-user'];
7
+ * if (!user) return redirect('/login');
8
+ * return { user: JSON.parse(user) };
9
+ * }
10
+ */
11
+ export function redirect(location, status = 302) {
12
+ return { __nk_redirect: true, location, status };
13
+ }
@@ -0,0 +1,3 @@
1
+ export declare function fetchLoaderData(pathname: string, params: Record<string, string>): Promise<any>;
2
+ export declare function fetchLayoutLoaderData(dir: string): Promise<any>;
3
+ export declare function render404(pathname: string): string;
@@ -0,0 +1,40 @@
1
+ export async function fetchLoaderData(pathname, params) {
2
+ const url = new URL(`/__nk_loader${pathname}`, location.origin);
3
+ if (Object.keys(params).length > 0) {
4
+ url.searchParams.set('__params', JSON.stringify(params));
5
+ }
6
+ const res = await fetch(url.toString());
7
+ if (!res.ok) {
8
+ throw new Error(`Loader returned ${res.status}`);
9
+ }
10
+ const data = await res.json();
11
+ if (data?.__nk_no_loader)
12
+ return undefined;
13
+ return data;
14
+ }
15
+ export async function fetchLayoutLoaderData(dir) {
16
+ const url = new URL(`/__nk_loader/__layout/`, location.origin);
17
+ url.searchParams.set('__dir', dir);
18
+ const res = await fetch(url.toString());
19
+ if (!res.ok) {
20
+ throw new Error(`Layout loader returned ${res.status}`);
21
+ }
22
+ const data = await res.json();
23
+ if (data?.__nk_no_loader)
24
+ return undefined;
25
+ return data;
26
+ }
27
+ export function render404(pathname) {
28
+ return `<div style="display:flex;align-items:center;justify-content:center;min-height:80vh;font-family:system-ui,-apple-system,sans-serif;padding:2rem">
29
+ <div style="text-align:center;max-width:400px">
30
+ <div style="font-size:5rem;font-weight:200;letter-spacing:-2px;color:#cbd5e1;line-height:1">404</div>
31
+ <div style="width:32px;height:2px;background:#e2e8f0;border-radius:1px;margin:1.25rem auto"></div>
32
+ <h1 style="font-size:1rem;font-weight:500;color:#334155;margin:1.25rem 0 .5rem">Page not found</h1>
33
+ <p style="color:#94a3b8;font-size:.8125rem;line-height:1.5;margin:0 0 2rem"><code style="background:#f8fafc;padding:.125rem .375rem;border-radius:3px;font-size:.75rem;color:#64748b;border:1px solid #f1f5f9">${pathname}</code> doesn't exist</p>
34
+ <a href="/" style="display:inline-flex;align-items:center;gap:.375rem;padding:.4375rem 1rem;background:#f8fafc;color:#475569;border:1px solid #e2e8f0;border-radius:6px;font-size:.8125rem;font-weight:400;text-decoration:none;transition:all .15s">
35
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
36
+ Back to home
37
+ </a>
38
+ </div>
39
+ </div>`;
40
+ }
@@ -0,0 +1,10 @@
1
+ import type { Route } from './router.js';
2
+ /**
3
+ * Hydrate the initial SSR-rendered route.
4
+ * Sets loaderData on existing DOM elements BEFORE loading modules to avoid
5
+ * hydration mismatches with Lit's microtask-based hydration.
6
+ */
7
+ export declare function hydrateInitialRoute(routes: Route[], outlet: HTMLElement | null, matchRoute: (pathname: string) => {
8
+ route: Route;
9
+ params: Record<string, string>;
10
+ } | null, onHydrated: (tag: string, layoutTags: string[], params: Record<string, string>) => void): Promise<void>;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Hydrate the initial SSR-rendered route.
3
+ * Sets loaderData on existing DOM elements BEFORE loading modules to avoid
4
+ * hydration mismatches with Lit's microtask-based hydration.
5
+ */
6
+ export async function hydrateInitialRoute(routes, outlet, matchRoute, onHydrated) {
7
+ const match = matchRoute(location.pathname);
8
+ if (!match)
9
+ return;
10
+ const layouts = match.route.layouts || [];
11
+ const params = match.params;
12
+ // Read SSR data FIRST — we need it before loading modules, because
13
+ // loading a module registers the custom element which triggers Lit
14
+ // hydration as a microtask. If loaderData isn't set before the next
15
+ // await, the element hydrates with default values → mismatch.
16
+ let ssrData = null;
17
+ const dataScript = document.getElementById('__nk_ssr_data__');
18
+ if (dataScript) {
19
+ try {
20
+ ssrData = JSON.parse(dataScript.textContent || '');
21
+ }
22
+ catch { /* ignore parse errors */ }
23
+ dataScript.remove();
24
+ }
25
+ // Build a map of loaderPath → data for quick lookup
26
+ const layoutDataMap = new Map();
27
+ if (ssrData?.layouts && Array.isArray(ssrData.layouts)) {
28
+ for (const entry of ssrData.layouts) {
29
+ if (entry.data !== undefined) {
30
+ layoutDataMap.set(entry.loaderPath, entry.data);
31
+ }
32
+ }
33
+ }
34
+ // Load each layout module and immediately set loaderData on the
35
+ // existing DOM element BEFORE the next await yields to microtasks.
36
+ for (const layout of layouts) {
37
+ const existingLayout = outlet?.querySelector(layout.tagName);
38
+ if (existingLayout) {
39
+ const data = layoutDataMap.get(layout.loaderPath ?? '');
40
+ if (data !== undefined) {
41
+ existingLayout.loaderData = data;
42
+ }
43
+ }
44
+ if (layout.load && !customElements.get(layout.tagName)) {
45
+ await layout.load();
46
+ }
47
+ }
48
+ // Set page loaderData BEFORE loading the page module
49
+ const pageData = ssrData?.page !== undefined ? ssrData.page
50
+ : (ssrData && !ssrData.layouts) ? ssrData
51
+ : undefined;
52
+ const existingPage = outlet?.querySelector(match.route.tagName);
53
+ if (existingPage && pageData !== undefined) {
54
+ existingPage.loaderData = pageData;
55
+ }
56
+ // Load the page module (registers element, triggers hydration microtask)
57
+ if (match.route.load && !customElements.get(match.route.tagName)) {
58
+ await match.route.load();
59
+ }
60
+ // Set route params as attributes on the page element
61
+ const pageEl = outlet?.querySelector(match.route.tagName);
62
+ if (pageEl) {
63
+ for (const [key, value] of Object.entries(params)) {
64
+ pageEl.setAttribute(key, value);
65
+ }
66
+ }
67
+ onHydrated(match.route.tagName, layouts.map(l => l.tagName), params);
68
+ }
@@ -0,0 +1,35 @@
1
+ export interface LayoutInfo {
2
+ tagName: string;
3
+ hasLoader?: boolean;
4
+ load?: () => Promise<any>;
5
+ loaderPath?: string;
6
+ }
7
+ export interface Route {
8
+ path: string;
9
+ tagName: string;
10
+ hasLoader?: boolean;
11
+ load?: () => Promise<any>;
12
+ layouts?: LayoutInfo[];
13
+ pattern?: RegExp;
14
+ paramNames?: string[];
15
+ }
16
+ /**
17
+ * Simple client-side router for LumenJS pages.
18
+ * Handles popstate and link clicks for SPA navigation.
19
+ * Supports server loaders and nested layouts with persistence.
20
+ */
21
+ export declare class NkRouter {
22
+ private routes;
23
+ private outlet;
24
+ private currentTag;
25
+ private currentLayoutTags;
26
+ params: Record<string, string>;
27
+ constructor(routes: Route[], outlet: HTMLElement, hydrate?: boolean);
28
+ private compilePattern;
29
+ navigate(pathname: string, pushState?: boolean): Promise<void>;
30
+ private matchRoute;
31
+ private renderRoute;
32
+ private buildLayoutTree;
33
+ private createPageElement;
34
+ private handleLinkClick;
35
+ }
@@ -0,0 +1,202 @@
1
+ import { fetchLoaderData, fetchLayoutLoaderData, render404 } from './router-data.js';
2
+ import { hydrateInitialRoute } from './router-hydration.js';
3
+ /**
4
+ * Simple client-side router for LumenJS pages.
5
+ * Handles popstate and link clicks for SPA navigation.
6
+ * Supports server loaders and nested layouts with persistence.
7
+ */
8
+ export class NkRouter {
9
+ constructor(routes, outlet, hydrate = false) {
10
+ this.routes = [];
11
+ this.outlet = null;
12
+ this.currentTag = null;
13
+ this.currentLayoutTags = [];
14
+ this.params = {};
15
+ this.outlet = outlet;
16
+ this.routes = routes.map(r => ({
17
+ ...r,
18
+ ...this.compilePattern(r.path),
19
+ }));
20
+ window.addEventListener('popstate', () => this.navigate(location.pathname, false));
21
+ document.addEventListener('click', (e) => this.handleLinkClick(e));
22
+ if (hydrate) {
23
+ hydrateInitialRoute(this.routes, this.outlet, (p) => this.matchRoute(p), (tag, layoutTags, params) => {
24
+ this.currentTag = tag;
25
+ this.currentLayoutTags = layoutTags;
26
+ this.params = params;
27
+ });
28
+ }
29
+ else {
30
+ this.navigate(location.pathname, false);
31
+ }
32
+ }
33
+ compilePattern(path) {
34
+ const paramNames = [];
35
+ const pattern = path.replace(/:(?:\.\.\.)?([^/]+)/g, (match, name) => {
36
+ paramNames.push(name);
37
+ return match.startsWith(':...') ? '(.+)' : '([^/]+)';
38
+ });
39
+ return { pattern: new RegExp(`^${pattern}$`), paramNames };
40
+ }
41
+ async navigate(pathname, pushState = true) {
42
+ const match = this.matchRoute(pathname);
43
+ if (!match) {
44
+ if (this.outlet)
45
+ this.outlet.innerHTML = render404(pathname);
46
+ this.currentLayoutTags = [];
47
+ this.currentTag = null;
48
+ return;
49
+ }
50
+ if (pushState) {
51
+ history.pushState(null, '', pathname);
52
+ }
53
+ this.params = match.params;
54
+ // Lazy-load the page component if not yet registered
55
+ if (match.route.load && !customElements.get(match.route.tagName)) {
56
+ await match.route.load();
57
+ }
58
+ // Load layout components
59
+ const layouts = match.route.layouts || [];
60
+ for (const layout of layouts) {
61
+ if (layout.load && !customElements.get(layout.tagName)) {
62
+ await layout.load();
63
+ }
64
+ }
65
+ // Fetch loader data for page
66
+ let loaderData = undefined;
67
+ if (match.route.hasLoader) {
68
+ try {
69
+ loaderData = await fetchLoaderData(pathname, match.params);
70
+ }
71
+ catch (err) {
72
+ console.error('[NkRouter] Loader fetch failed:', err);
73
+ }
74
+ }
75
+ // Fetch loader data for layouts
76
+ const layoutDataList = [];
77
+ for (const layout of layouts) {
78
+ if (layout.hasLoader) {
79
+ try {
80
+ const data = await fetchLayoutLoaderData(layout.loaderPath || '');
81
+ layoutDataList.push(data);
82
+ }
83
+ catch (err) {
84
+ console.error('[NkRouter] Layout loader fetch failed:', err);
85
+ layoutDataList.push(undefined);
86
+ }
87
+ }
88
+ else {
89
+ layoutDataList.push(undefined);
90
+ }
91
+ }
92
+ this.renderRoute(match.route, loaderData, layouts, layoutDataList);
93
+ }
94
+ matchRoute(pathname) {
95
+ for (const route of this.routes) {
96
+ if (!route.pattern)
97
+ continue;
98
+ const match = pathname.match(route.pattern);
99
+ if (match) {
100
+ const params = {};
101
+ route.paramNames?.forEach((name, i) => {
102
+ params[name] = match[i + 1];
103
+ });
104
+ return { route, params };
105
+ }
106
+ }
107
+ return null;
108
+ }
109
+ renderRoute(route, loaderData, layouts, layoutDataList) {
110
+ if (!this.outlet)
111
+ return;
112
+ const newLayoutTags = (layouts || []).map(l => l.tagName);
113
+ // Find the first layout that differs from the current chain
114
+ let divergeIndex = 0;
115
+ while (divergeIndex < this.currentLayoutTags.length &&
116
+ divergeIndex < newLayoutTags.length &&
117
+ this.currentLayoutTags[divergeIndex] === newLayoutTags[divergeIndex]) {
118
+ divergeIndex++;
119
+ }
120
+ const canReuse = this.currentLayoutTags.length > 0 && divergeIndex > 0;
121
+ if (!canReuse || divergeIndex === 0) {
122
+ // Full re-render: no layouts to reuse
123
+ this.outlet.innerHTML = '';
124
+ if (newLayoutTags.length === 0) {
125
+ const pageEl = this.createPageElement(route, loaderData);
126
+ this.outlet.appendChild(pageEl);
127
+ }
128
+ else {
129
+ const tree = this.buildLayoutTree(layouts, layoutDataList || [], route, loaderData);
130
+ this.outlet.appendChild(tree);
131
+ }
132
+ }
133
+ else {
134
+ // Reuse layouts up to divergeIndex, rebuild from there
135
+ let parentEl = this.outlet;
136
+ for (let i = 0; i < divergeIndex; i++) {
137
+ const layoutEl = parentEl?.querySelector(`:scope > ${this.currentLayoutTags[i]}`) ?? null;
138
+ if (!layoutEl) {
139
+ return this.renderRoute(route, loaderData, [], []);
140
+ }
141
+ if (layoutDataList && layoutDataList[i] !== undefined) {
142
+ layoutEl.loaderData = layoutDataList[i];
143
+ }
144
+ parentEl = layoutEl;
145
+ }
146
+ if (!parentEl)
147
+ return;
148
+ parentEl.innerHTML = '';
149
+ if (divergeIndex >= newLayoutTags.length) {
150
+ const pageEl = this.createPageElement(route, loaderData);
151
+ parentEl.appendChild(pageEl);
152
+ }
153
+ else {
154
+ const remainingLayouts = layouts.slice(divergeIndex);
155
+ const remainingData = (layoutDataList || []).slice(divergeIndex);
156
+ const tree = this.buildLayoutTree(remainingLayouts, remainingData, route, loaderData);
157
+ parentEl.appendChild(tree);
158
+ }
159
+ }
160
+ this.currentTag = route.tagName;
161
+ this.currentLayoutTags = newLayoutTags;
162
+ }
163
+ buildLayoutTree(layouts, layoutDataList, route, loaderData) {
164
+ const outerLayout = document.createElement(layouts[0].tagName);
165
+ if (layoutDataList[0] !== undefined) {
166
+ outerLayout.loaderData = layoutDataList[0];
167
+ }
168
+ let current = outerLayout;
169
+ for (let i = 1; i < layouts.length; i++) {
170
+ const inner = document.createElement(layouts[i].tagName);
171
+ if (layoutDataList[i] !== undefined) {
172
+ inner.loaderData = layoutDataList[i];
173
+ }
174
+ current.appendChild(inner);
175
+ current = inner;
176
+ }
177
+ const pageEl = this.createPageElement(route, loaderData);
178
+ current.appendChild(pageEl);
179
+ return outerLayout;
180
+ }
181
+ createPageElement(route, loaderData) {
182
+ const el = document.createElement(route.tagName);
183
+ for (const [key, value] of Object.entries(this.params)) {
184
+ el.setAttribute(key, value);
185
+ }
186
+ if (loaderData !== undefined) {
187
+ el.loaderData = loaderData;
188
+ }
189
+ return el;
190
+ }
191
+ handleLinkClick(event) {
192
+ const path = event.composedPath();
193
+ const anchor = path.find((el) => el instanceof HTMLElement && el.tagName === 'A');
194
+ if (!anchor)
195
+ return;
196
+ const href = anchor.getAttribute('href');
197
+ if (!href || href.startsWith('http') || href.startsWith('#') || anchor.hasAttribute('target'))
198
+ return;
199
+ event.preventDefault();
200
+ this.navigate(href);
201
+ }
202
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Install DOM shims needed for SSR rendering of Lit/NuralyUI components.
3
+ * Consolidates the various partial shim implementations across the codebase.
4
+ */
5
+ export declare function installDomShims(): void;