@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.
- package/README.md +297 -0
- package/dist/build/build.d.ts +5 -0
- package/dist/build/build.js +172 -0
- package/dist/build/error-page.d.ts +1 -0
- package/dist/build/error-page.js +74 -0
- package/dist/build/scan.d.ts +21 -0
- package/dist/build/scan.js +93 -0
- package/dist/build/serve-api.d.ts +3 -0
- package/dist/build/serve-api.js +56 -0
- package/dist/build/serve-loaders.d.ts +4 -0
- package/dist/build/serve-loaders.js +115 -0
- package/dist/build/serve-ssr.d.ts +7 -0
- package/dist/build/serve-ssr.js +121 -0
- package/dist/build/serve-static.d.ts +6 -0
- package/dist/build/serve-static.js +80 -0
- package/dist/build/serve.d.ts +5 -0
- package/dist/build/serve.js +79 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +65 -0
- package/dist/dev-server/config.d.ts +25 -0
- package/dist/dev-server/config.js +55 -0
- package/dist/dev-server/index-html.d.ts +16 -0
- package/dist/dev-server/index-html.js +46 -0
- package/dist/dev-server/nuralyui-aliases.d.ts +16 -0
- package/dist/dev-server/nuralyui-aliases.js +164 -0
- package/dist/dev-server/plugins/vite-plugin-api-routes.d.ts +23 -0
- package/dist/dev-server/plugins/vite-plugin-api-routes.js +250 -0
- package/dist/dev-server/plugins/vite-plugin-auto-import.d.ts +5 -0
- package/dist/dev-server/plugins/vite-plugin-auto-import.js +47 -0
- package/dist/dev-server/plugins/vite-plugin-lit-dedup.d.ts +5 -0
- package/dist/dev-server/plugins/vite-plugin-lit-dedup.js +62 -0
- package/dist/dev-server/plugins/vite-plugin-lit-hmr.d.ts +5 -0
- package/dist/dev-server/plugins/vite-plugin-lit-hmr.js +46 -0
- package/dist/dev-server/plugins/vite-plugin-loaders.d.ts +38 -0
- package/dist/dev-server/plugins/vite-plugin-loaders.js +320 -0
- package/dist/dev-server/plugins/vite-plugin-routes.d.ts +21 -0
- package/dist/dev-server/plugins/vite-plugin-routes.js +157 -0
- package/dist/dev-server/plugins/vite-plugin-source-annotator.d.ts +5 -0
- package/dist/dev-server/plugins/vite-plugin-source-annotator.js +39 -0
- package/dist/dev-server/plugins/vite-plugin-virtual-modules.d.ts +5 -0
- package/dist/dev-server/plugins/vite-plugin-virtual-modules.js +38 -0
- package/dist/dev-server/server.d.ts +23 -0
- package/dist/dev-server/server.js +155 -0
- package/dist/dev-server/ssr-render.d.ts +20 -0
- package/dist/dev-server/ssr-render.js +170 -0
- package/dist/editor/click-select.d.ts +1 -0
- package/dist/editor/click-select.js +46 -0
- package/dist/editor/editor-bridge.d.ts +17 -0
- package/dist/editor/editor-bridge.js +101 -0
- package/dist/editor/element-annotator.d.ts +33 -0
- package/dist/editor/element-annotator.js +83 -0
- package/dist/editor/hover-detect.d.ts +1 -0
- package/dist/editor/hover-detect.js +36 -0
- package/dist/editor/inline-text-edit.d.ts +1 -0
- package/dist/editor/inline-text-edit.js +114 -0
- package/dist/integrations/add.d.ts +1 -0
- package/dist/integrations/add.js +89 -0
- package/dist/runtime/app-shell.d.ts +1 -0
- package/dist/runtime/app-shell.js +22 -0
- package/dist/runtime/response.d.ts +15 -0
- package/dist/runtime/response.js +13 -0
- package/dist/runtime/router-data.d.ts +3 -0
- package/dist/runtime/router-data.js +40 -0
- package/dist/runtime/router-hydration.d.ts +10 -0
- package/dist/runtime/router-hydration.js +68 -0
- package/dist/runtime/router.d.ts +35 -0
- package/dist/runtime/router.js +202 -0
- package/dist/shared/dom-shims.d.ts +5 -0
- package/dist/shared/dom-shims.js +63 -0
- package/dist/shared/route-matching.d.ts +6 -0
- package/dist/shared/route-matching.js +44 -0
- package/dist/shared/types.d.ts +16 -0
- package/dist/shared/types.js +1 -0
- package/dist/shared/utils.d.ts +42 -0
- package/dist/shared/utils.js +109 -0
- 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,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
|
+
}
|