@nuraly/lumenjs 0.1.1 → 0.1.3
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 +145 -8
- package/dist/build/build.js +8 -4
- package/dist/build/scan.d.ts +2 -0
- package/dist/build/scan.js +4 -3
- package/dist/build/serve-loaders.d.ts +2 -0
- package/dist/build/serve-loaders.js +110 -0
- package/dist/build/serve.js +15 -3
- package/dist/db/context.d.ts +2 -0
- package/dist/db/context.js +9 -0
- package/dist/db/index.d.ts +19 -0
- package/dist/db/index.js +79 -0
- package/dist/dev-server/config.d.ts +3 -0
- package/dist/dev-server/config.js +15 -1
- package/dist/dev-server/plugins/vite-plugin-loaders.d.ts +0 -1
- package/dist/dev-server/plugins/vite-plugin-loaders.js +173 -37
- package/dist/dev-server/plugins/vite-plugin-routes.js +5 -3
- package/dist/dev-server/server.js +13 -0
- package/dist/runtime/router-data.d.ts +2 -0
- package/dist/runtime/router-data.js +20 -0
- package/dist/runtime/router.d.ts +5 -0
- package/dist/runtime/router.js +37 -1
- package/dist/shared/types.d.ts +2 -0
- package/dist/shared/utils.d.ts +5 -1
- package/dist/shared/utils.js +13 -1
- package/package.json +18 -6
- package/templates/blog/api/posts.ts +20 -0
- package/templates/blog/data/migrations/001_init.sql +12 -0
- package/templates/blog/pages/index.ts +39 -0
- package/templates/blog/pages/posts/[slug].ts +35 -0
- package/templates/dashboard/api/stats.ts +7 -0
- package/templates/dashboard/data/migrations/001_init.sql +13 -0
- package/templates/dashboard/pages/index.ts +41 -0
|
@@ -15,7 +15,6 @@ import { installDomShims } from '../../shared/dom-shims.js';
|
|
|
15
15
|
* return { item: data, timestamp: Date.now() };
|
|
16
16
|
* }
|
|
17
17
|
*
|
|
18
|
-
* @customElement('page-item')
|
|
19
18
|
* export class PageItem extends LitElement {
|
|
20
19
|
* @property({ type: Object }) loaderData = {};
|
|
21
20
|
* render() {
|
|
@@ -31,6 +30,78 @@ export function lumenLoadersPlugin(pagesDir) {
|
|
|
31
30
|
return {
|
|
32
31
|
name: 'lumenjs-loaders',
|
|
33
32
|
configureServer(server) {
|
|
33
|
+
// SSE subscribe middleware
|
|
34
|
+
server.middlewares.use(async (req, res, next) => {
|
|
35
|
+
if (!req.url?.startsWith('/__nk_subscribe/')) {
|
|
36
|
+
return next();
|
|
37
|
+
}
|
|
38
|
+
const [pathname, queryString] = req.url.split('?');
|
|
39
|
+
// Parse query params
|
|
40
|
+
const query = {};
|
|
41
|
+
if (queryString) {
|
|
42
|
+
for (const pair of queryString.split('&')) {
|
|
43
|
+
const [key, val] = pair.split('=');
|
|
44
|
+
query[decodeURIComponent(key)] = decodeURIComponent(val || '');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Handle layout subscribe: /__nk_subscribe/__layout/?__dir=<dir>
|
|
48
|
+
if (pathname === '/__nk_subscribe/__layout/' || pathname === '/__nk_subscribe/__layout') {
|
|
49
|
+
const dir = query.__dir || '';
|
|
50
|
+
await handleLayoutSubscribe(server, pagesDir, dir, query, req, res);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const pagePath = pathname.replace('/__nk_subscribe', '') || '/';
|
|
54
|
+
// Parse URL params
|
|
55
|
+
let params = {};
|
|
56
|
+
if (query.__params) {
|
|
57
|
+
try {
|
|
58
|
+
params = JSON.parse(query.__params);
|
|
59
|
+
}
|
|
60
|
+
catch { /* ignore */ }
|
|
61
|
+
delete query.__params;
|
|
62
|
+
}
|
|
63
|
+
const filePath = resolvePageFile(pagesDir, pagePath);
|
|
64
|
+
if (!filePath) {
|
|
65
|
+
res.statusCode = 404;
|
|
66
|
+
res.end();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (Object.keys(params).length === 0) {
|
|
70
|
+
Object.assign(params, extractRouteParams(pagesDir, pagePath, filePath));
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
installDomShims();
|
|
74
|
+
const mod = await server.ssrLoadModule(filePath);
|
|
75
|
+
if (!mod.subscribe || typeof mod.subscribe !== 'function') {
|
|
76
|
+
res.statusCode = 204;
|
|
77
|
+
res.end();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// Set SSE headers
|
|
81
|
+
res.writeHead(200, {
|
|
82
|
+
'Content-Type': 'text/event-stream',
|
|
83
|
+
'Cache-Control': 'no-cache',
|
|
84
|
+
'Connection': 'keep-alive',
|
|
85
|
+
});
|
|
86
|
+
const locale = query.__locale;
|
|
87
|
+
const push = (data) => {
|
|
88
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
89
|
+
};
|
|
90
|
+
const cleanup = mod.subscribe({ params, push, headers: req.headers, locale });
|
|
91
|
+
res.on('close', () => {
|
|
92
|
+
if (typeof cleanup === 'function')
|
|
93
|
+
cleanup();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
console.error(`[LumenJS] Subscribe error for ${pagePath}:`, err);
|
|
98
|
+
if (!res.headersSent) {
|
|
99
|
+
res.statusCode = 500;
|
|
100
|
+
res.end();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
// Loader middleware
|
|
34
105
|
server.middlewares.use(async (req, res, next) => {
|
|
35
106
|
if (!req.url?.startsWith('/__nk_loader/')) {
|
|
36
107
|
return next();
|
|
@@ -126,45 +197,26 @@ export function lumenLoadersPlugin(pagesDir) {
|
|
|
126
197
|
// Apply to page files and layout files within the pages directory
|
|
127
198
|
if (!id.startsWith(pagesDir) || !id.endsWith('.ts'))
|
|
128
199
|
return;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (!hasLoader)
|
|
133
|
-
return;
|
|
134
|
-
// Find the loader function by tracking brace depth
|
|
135
|
-
const match = code.match(/export\s+(async\s+)?function\s+loader\s*\(/);
|
|
136
|
-
if (!match)
|
|
200
|
+
const hasLoader = code.includes('export') && code.includes('loader') && /export\s+(async\s+)?function\s+loader\s*\(/.test(code);
|
|
201
|
+
const hasSubscribe = code.includes('export') && code.includes('subscribe') && /export\s+(async\s+)?function\s+subscribe\s*\(/.test(code);
|
|
202
|
+
if (!hasLoader && !hasSubscribe)
|
|
137
203
|
return;
|
|
138
|
-
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
while (sigIdx < code.length && parenDepth > 0) {
|
|
143
|
-
if (code[sigIdx] === '(')
|
|
144
|
-
parenDepth++;
|
|
145
|
-
else if (code[sigIdx] === ')')
|
|
146
|
-
parenDepth--;
|
|
147
|
-
sigIdx++;
|
|
204
|
+
let result = code;
|
|
205
|
+
// Strip loader function
|
|
206
|
+
if (hasLoader) {
|
|
207
|
+
result = stripServerFunction(result, 'loader');
|
|
148
208
|
}
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
else if (code[i] === '}')
|
|
159
|
-
depth--;
|
|
160
|
-
i++;
|
|
209
|
+
// Strip subscribe function
|
|
210
|
+
if (hasSubscribe) {
|
|
211
|
+
result = stripServerFunction(result, 'subscribe');
|
|
212
|
+
}
|
|
213
|
+
if (hasLoader) {
|
|
214
|
+
result += '\nexport const __nk_has_loader = true;\n';
|
|
215
|
+
}
|
|
216
|
+
if (hasSubscribe) {
|
|
217
|
+
result += '\nexport const __nk_has_subscribe = true;\n';
|
|
161
218
|
}
|
|
162
|
-
|
|
163
|
-
const transformed = code.substring(0, startIdx)
|
|
164
|
-
+ '// loader() — runs server-side only'
|
|
165
|
-
+ code.substring(i);
|
|
166
|
-
const withFlag = transformed + '\nexport const __nk_has_loader = true;\n';
|
|
167
|
-
return { code: withFlag, map: null };
|
|
219
|
+
return { code: result, map: null };
|
|
168
220
|
},
|
|
169
221
|
};
|
|
170
222
|
}
|
|
@@ -310,6 +362,90 @@ function findDynamicPage(baseDir, segments) {
|
|
|
310
362
|
}
|
|
311
363
|
return null;
|
|
312
364
|
}
|
|
365
|
+
/**
|
|
366
|
+
* Strip a named server-side function (loader/subscribe) from client code using brace-depth tracking.
|
|
367
|
+
*/
|
|
368
|
+
function stripServerFunction(code, fnName) {
|
|
369
|
+
const regex = new RegExp(`export\\s+(async\\s+)?function\\s+${fnName}\\s*\\(`);
|
|
370
|
+
const match = code.match(regex);
|
|
371
|
+
if (!match)
|
|
372
|
+
return code;
|
|
373
|
+
const startIdx = match.index;
|
|
374
|
+
let parenDepth = 1;
|
|
375
|
+
let sigIdx = startIdx + match[0].length;
|
|
376
|
+
while (sigIdx < code.length && parenDepth > 0) {
|
|
377
|
+
if (code[sigIdx] === '(')
|
|
378
|
+
parenDepth++;
|
|
379
|
+
else if (code[sigIdx] === ')')
|
|
380
|
+
parenDepth--;
|
|
381
|
+
sigIdx++;
|
|
382
|
+
}
|
|
383
|
+
let braceStart = code.indexOf('{', sigIdx);
|
|
384
|
+
if (braceStart === -1)
|
|
385
|
+
return code;
|
|
386
|
+
let depth = 1;
|
|
387
|
+
let i = braceStart + 1;
|
|
388
|
+
while (i < code.length && depth > 0) {
|
|
389
|
+
if (code[i] === '{')
|
|
390
|
+
depth++;
|
|
391
|
+
else if (code[i] === '}')
|
|
392
|
+
depth--;
|
|
393
|
+
i++;
|
|
394
|
+
}
|
|
395
|
+
return code.substring(0, startIdx)
|
|
396
|
+
+ `// ${fnName}() — runs server-side only`
|
|
397
|
+
+ code.substring(i);
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Handle layout subscribe requests in dev mode.
|
|
401
|
+
* GET /__nk_subscribe/__layout/?__dir=<dir>
|
|
402
|
+
*/
|
|
403
|
+
async function handleLayoutSubscribe(server, pagesDir, dir, query, req, res) {
|
|
404
|
+
const layoutDir = path.join(pagesDir, dir);
|
|
405
|
+
let layoutFile = null;
|
|
406
|
+
for (const ext of ['.ts', '.js']) {
|
|
407
|
+
const p = path.join(layoutDir, `_layout${ext}`);
|
|
408
|
+
if (fs.existsSync(p)) {
|
|
409
|
+
layoutFile = p;
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (!layoutFile) {
|
|
414
|
+
res.statusCode = 204;
|
|
415
|
+
res.end();
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
installDomShims();
|
|
420
|
+
const mod = await server.ssrLoadModule(layoutFile);
|
|
421
|
+
if (!mod.subscribe || typeof mod.subscribe !== 'function') {
|
|
422
|
+
res.statusCode = 204;
|
|
423
|
+
res.end();
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
res.writeHead(200, {
|
|
427
|
+
'Content-Type': 'text/event-stream',
|
|
428
|
+
'Cache-Control': 'no-cache',
|
|
429
|
+
'Connection': 'keep-alive',
|
|
430
|
+
});
|
|
431
|
+
const locale = query.__locale;
|
|
432
|
+
const push = (data) => {
|
|
433
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
434
|
+
};
|
|
435
|
+
const cleanup = mod.subscribe({ params: {}, push, headers: req.headers, locale });
|
|
436
|
+
res.on('close', () => {
|
|
437
|
+
if (typeof cleanup === 'function')
|
|
438
|
+
cleanup();
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
console.error(`[LumenJS] Layout subscribe error for dir=${dir}:`, err);
|
|
443
|
+
if (!res.headersSent) {
|
|
444
|
+
res.statusCode = 500;
|
|
445
|
+
res.end();
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
313
449
|
/**
|
|
314
450
|
* Extract dynamic route params by comparing URL segments against [param] file path segments.
|
|
315
451
|
*/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { dirToLayoutTagName, fileHasLoader, filePathToRoute, filePathToTagName } from '../../shared/utils.js';
|
|
3
|
+
import { dirToLayoutTagName, fileHasLoader, fileHasSubscribe, filePathToRoute, filePathToTagName } from '../../shared/utils.js';
|
|
4
4
|
const VIRTUAL_MODULE_ID = 'virtual:lumenjs-routes';
|
|
5
5
|
const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
|
|
6
6
|
/**
|
|
@@ -98,18 +98,20 @@ export function lumenRoutesPlugin(pagesDir) {
|
|
|
98
98
|
const routeArray = routes
|
|
99
99
|
.map(r => {
|
|
100
100
|
const hasLoader = fileHasLoader(r.componentPath);
|
|
101
|
+
const hasSubscribe = fileHasSubscribe(r.componentPath);
|
|
101
102
|
const componentPath = r.componentPath.replace(/\\/g, '/');
|
|
102
103
|
const chain = getLayoutChain(r.componentPath, layouts);
|
|
103
104
|
let layoutsStr = '';
|
|
104
105
|
if (chain.length > 0) {
|
|
105
106
|
const items = chain.map(l => {
|
|
106
107
|
const lHasLoader = fileHasLoader(l.filePath);
|
|
108
|
+
const lHasSubscribe = fileHasSubscribe(l.filePath);
|
|
107
109
|
const lPath = l.filePath.replace(/\\/g, '/');
|
|
108
|
-
return `{ tagName: ${JSON.stringify(l.tagName)}, loaderPath: ${JSON.stringify(l.dir)}${lHasLoader ? ', hasLoader: true' : ''}, load: () => import('${lPath}') }`;
|
|
110
|
+
return `{ tagName: ${JSON.stringify(l.tagName)}, loaderPath: ${JSON.stringify(l.dir)}${lHasLoader ? ', hasLoader: true' : ''}${lHasSubscribe ? ', hasSubscribe: true' : ''}, load: () => import('${lPath}') }`;
|
|
109
111
|
});
|
|
110
112
|
layoutsStr = `, layouts: [${items.join(', ')}]`;
|
|
111
113
|
}
|
|
112
|
-
return ` { path: ${JSON.stringify(r.path)}, tagName: ${JSON.stringify(r.tagName)}${hasLoader ? ', hasLoader: true' : ''}, load: () => import('${componentPath}')${layoutsStr} }`;
|
|
114
|
+
return ` { path: ${JSON.stringify(r.path)}, tagName: ${JSON.stringify(r.tagName)}${hasLoader ? ', hasLoader: true' : ''}${hasSubscribe ? ', hasSubscribe: true' : ''}, load: () => import('${componentPath}')${layoutsStr} }`;
|
|
113
115
|
})
|
|
114
116
|
.join(',\n');
|
|
115
117
|
return `export const routes = [\n${routeArray}\n];\n`;
|
|
@@ -17,6 +17,7 @@ import { sourceAnnotatorPlugin } from './plugins/vite-plugin-source-annotator.js
|
|
|
17
17
|
import { virtualModulesPlugin } from './plugins/vite-plugin-virtual-modules.js';
|
|
18
18
|
import { i18nPlugin, loadTranslationsFromDisk } from './plugins/vite-plugin-i18n.js';
|
|
19
19
|
import { resolveLocale } from './middleware/locale.js';
|
|
20
|
+
import { setProjectDir } from '../db/context.js';
|
|
20
21
|
// Re-export for backwards compatibility
|
|
21
22
|
export { readProjectConfig, readProjectTitle, getLumenJSNodeModules, getLumenJSDirs } from './config.js';
|
|
22
23
|
export { getNuralyUIAliases, resolveNuralyUIPaths } from './nuralyui-aliases.js';
|
|
@@ -82,6 +83,7 @@ export function getSharedViteConfig(projectDir, options) {
|
|
|
82
83
|
}
|
|
83
84
|
export async function createDevServer(options) {
|
|
84
85
|
const { projectDir, port, editorMode = false, base = '/' } = options;
|
|
86
|
+
setProjectDir(projectDir);
|
|
85
87
|
const pagesDir = path.join(projectDir, 'pages');
|
|
86
88
|
const apiDir = path.join(projectDir, 'api');
|
|
87
89
|
const publicDir = path.join(projectDir, 'public');
|
|
@@ -110,6 +112,17 @@ export async function createDevServer(options) {
|
|
|
110
112
|
name: 'lumenjs-index-html',
|
|
111
113
|
configureServer(server) {
|
|
112
114
|
server.middlewares.use((req, res, next) => {
|
|
115
|
+
// Guard against malformed percent-encoded URLs that crash Vite's transformIndexHtml
|
|
116
|
+
if (req.url) {
|
|
117
|
+
try {
|
|
118
|
+
decodeURIComponent(req.url);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
res.statusCode = 400;
|
|
122
|
+
res.end('Bad Request');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
113
126
|
if (req.url && !req.url.startsWith('/@') && !req.url.startsWith('/node_modules') &&
|
|
114
127
|
!req.url.startsWith('/api/') && !req.url.startsWith('/__nk_loader/') &&
|
|
115
128
|
!req.url.startsWith('/__nk_i18n/') &&
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export declare function fetchLoaderData(pathname: string, params: Record<string, string>): Promise<any>;
|
|
2
2
|
export declare function fetchLayoutLoaderData(dir: string): Promise<any>;
|
|
3
|
+
export declare function connectSubscribe(pathname: string, params: Record<string, string>): EventSource;
|
|
4
|
+
export declare function connectLayoutSubscribe(dir: string): EventSource;
|
|
3
5
|
export declare function render404(pathname: string): string;
|
|
@@ -33,6 +33,26 @@ export async function fetchLayoutLoaderData(dir) {
|
|
|
33
33
|
return undefined;
|
|
34
34
|
return data;
|
|
35
35
|
}
|
|
36
|
+
export function connectSubscribe(pathname, params) {
|
|
37
|
+
const url = new URL(`/__nk_subscribe${pathname}`, location.origin);
|
|
38
|
+
if (Object.keys(params).length > 0) {
|
|
39
|
+
url.searchParams.set('__params', JSON.stringify(params));
|
|
40
|
+
}
|
|
41
|
+
const config = getI18nConfig();
|
|
42
|
+
if (config) {
|
|
43
|
+
url.searchParams.set('__locale', getLocale());
|
|
44
|
+
}
|
|
45
|
+
return new EventSource(url.toString());
|
|
46
|
+
}
|
|
47
|
+
export function connectLayoutSubscribe(dir) {
|
|
48
|
+
const url = new URL('/__nk_subscribe/__layout/', location.origin);
|
|
49
|
+
url.searchParams.set('__dir', dir);
|
|
50
|
+
const config = getI18nConfig();
|
|
51
|
+
if (config) {
|
|
52
|
+
url.searchParams.set('__locale', getLocale());
|
|
53
|
+
}
|
|
54
|
+
return new EventSource(url.toString());
|
|
55
|
+
}
|
|
36
56
|
export function render404(pathname) {
|
|
37
57
|
return `<div style="display:flex;align-items:center;justify-content:center;min-height:80vh;font-family:system-ui,-apple-system,sans-serif;padding:2rem">
|
|
38
58
|
<div style="text-align:center;max-width:400px">
|
package/dist/runtime/router.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export interface LayoutInfo {
|
|
2
2
|
tagName: string;
|
|
3
3
|
hasLoader?: boolean;
|
|
4
|
+
hasSubscribe?: boolean;
|
|
4
5
|
load?: () => Promise<any>;
|
|
5
6
|
loaderPath?: string;
|
|
6
7
|
}
|
|
@@ -8,6 +9,7 @@ export interface Route {
|
|
|
8
9
|
path: string;
|
|
9
10
|
tagName: string;
|
|
10
11
|
hasLoader?: boolean;
|
|
12
|
+
hasSubscribe?: boolean;
|
|
11
13
|
load?: () => Promise<any>;
|
|
12
14
|
layouts?: LayoutInfo[];
|
|
13
15
|
pattern?: RegExp;
|
|
@@ -23,14 +25,17 @@ export declare class NkRouter {
|
|
|
23
25
|
private outlet;
|
|
24
26
|
private currentTag;
|
|
25
27
|
private currentLayoutTags;
|
|
28
|
+
private subscriptions;
|
|
26
29
|
params: Record<string, string>;
|
|
27
30
|
constructor(routes: Route[], outlet: HTMLElement, hydrate?: boolean);
|
|
28
31
|
private compilePattern;
|
|
32
|
+
private cleanupSubscriptions;
|
|
29
33
|
navigate(pathname: string, pushState?: boolean): Promise<void>;
|
|
30
34
|
private matchRoute;
|
|
31
35
|
private renderRoute;
|
|
32
36
|
private buildLayoutTree;
|
|
33
37
|
private createPageElement;
|
|
38
|
+
private findPageElement;
|
|
34
39
|
private handleLinkClick;
|
|
35
40
|
/** Strip locale prefix from a path for internal route matching. */
|
|
36
41
|
private stripLocale;
|
package/dist/runtime/router.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fetchLoaderData, fetchLayoutLoaderData, render404 } from './router-data.js';
|
|
1
|
+
import { fetchLoaderData, fetchLayoutLoaderData, connectSubscribe, connectLayoutSubscribe, render404 } from './router-data.js';
|
|
2
2
|
import { hydrateInitialRoute } from './router-hydration.js';
|
|
3
3
|
import { getI18nConfig, getLocale, stripLocalePrefix, buildLocalePath } from './i18n.js';
|
|
4
4
|
/**
|
|
@@ -12,6 +12,7 @@ export class NkRouter {
|
|
|
12
12
|
this.outlet = null;
|
|
13
13
|
this.currentTag = null;
|
|
14
14
|
this.currentLayoutTags = [];
|
|
15
|
+
this.subscriptions = [];
|
|
15
16
|
this.params = {};
|
|
16
17
|
this.outlet = outlet;
|
|
17
18
|
this.routes = routes.map(r => ({
|
|
@@ -43,7 +44,14 @@ export class NkRouter {
|
|
|
43
44
|
});
|
|
44
45
|
return { pattern: new RegExp(`^${pattern}$`), paramNames };
|
|
45
46
|
}
|
|
47
|
+
cleanupSubscriptions() {
|
|
48
|
+
for (const es of this.subscriptions) {
|
|
49
|
+
es.close();
|
|
50
|
+
}
|
|
51
|
+
this.subscriptions = [];
|
|
52
|
+
}
|
|
46
53
|
async navigate(pathname, pushState = true) {
|
|
54
|
+
this.cleanupSubscriptions();
|
|
47
55
|
const match = this.matchRoute(pathname);
|
|
48
56
|
if (!match) {
|
|
49
57
|
if (this.outlet)
|
|
@@ -55,6 +63,7 @@ export class NkRouter {
|
|
|
55
63
|
if (pushState) {
|
|
56
64
|
const localePath = this.withLocale(pathname);
|
|
57
65
|
history.pushState(null, '', localePath);
|
|
66
|
+
window.scrollTo(0, 0);
|
|
58
67
|
}
|
|
59
68
|
this.params = match.params;
|
|
60
69
|
// Lazy-load the page component if not yet registered
|
|
@@ -96,6 +105,28 @@ export class NkRouter {
|
|
|
96
105
|
}
|
|
97
106
|
}
|
|
98
107
|
this.renderRoute(match.route, loaderData, layouts, layoutDataList);
|
|
108
|
+
// Set up SSE subscriptions for page
|
|
109
|
+
if (match.route.hasSubscribe) {
|
|
110
|
+
const es = connectSubscribe(pathname, match.params);
|
|
111
|
+
es.onmessage = (e) => {
|
|
112
|
+
const pageEl = this.findPageElement(match.route.tagName);
|
|
113
|
+
if (pageEl)
|
|
114
|
+
pageEl.liveData = JSON.parse(e.data);
|
|
115
|
+
};
|
|
116
|
+
this.subscriptions.push(es);
|
|
117
|
+
}
|
|
118
|
+
// Set up SSE subscriptions for layouts
|
|
119
|
+
for (const layout of layouts) {
|
|
120
|
+
if (layout.hasSubscribe) {
|
|
121
|
+
const es = connectLayoutSubscribe(layout.loaderPath || '');
|
|
122
|
+
es.onmessage = (e) => {
|
|
123
|
+
const layoutEl = this.outlet?.querySelector(layout.tagName);
|
|
124
|
+
if (layoutEl)
|
|
125
|
+
layoutEl.liveData = JSON.parse(e.data);
|
|
126
|
+
};
|
|
127
|
+
this.subscriptions.push(es);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
99
130
|
}
|
|
100
131
|
matchRoute(pathname) {
|
|
101
132
|
for (const route of this.routes) {
|
|
@@ -194,6 +225,11 @@ export class NkRouter {
|
|
|
194
225
|
}
|
|
195
226
|
return el;
|
|
196
227
|
}
|
|
228
|
+
findPageElement(tagName) {
|
|
229
|
+
if (!this.outlet)
|
|
230
|
+
return null;
|
|
231
|
+
return this.outlet.querySelector(tagName) ?? this.outlet.querySelector(`${tagName}:last-child`);
|
|
232
|
+
}
|
|
197
233
|
handleLinkClick(event) {
|
|
198
234
|
const path = event.composedPath();
|
|
199
235
|
const anchor = path.find((el) => el instanceof HTMLElement && el.tagName === 'A');
|
package/dist/shared/types.d.ts
CHANGED
|
@@ -2,11 +2,13 @@ export interface ManifestLayout {
|
|
|
2
2
|
dir: string;
|
|
3
3
|
module: string;
|
|
4
4
|
hasLoader: boolean;
|
|
5
|
+
hasSubscribe: boolean;
|
|
5
6
|
}
|
|
6
7
|
export interface ManifestRoute {
|
|
7
8
|
path: string;
|
|
8
9
|
module: string;
|
|
9
10
|
hasLoader: boolean;
|
|
11
|
+
hasSubscribe: boolean;
|
|
10
12
|
tagName?: string;
|
|
11
13
|
layouts?: string[];
|
|
12
14
|
}
|
package/dist/shared/utils.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export declare function stripOuterLitMarkers(html: string): string;
|
|
|
14
14
|
export declare function dirToLayoutTagName(dir: string): string;
|
|
15
15
|
/**
|
|
16
16
|
* Find the custom element tag name from a page module.
|
|
17
|
-
* Pages
|
|
17
|
+
* Pages are auto-registered by the auto-define plugin based on file path.
|
|
18
18
|
*/
|
|
19
19
|
export declare function findTagName(mod: Record<string, any>): string | null;
|
|
20
20
|
/**
|
|
@@ -43,6 +43,10 @@ export declare function escapeHtml(text: string): string;
|
|
|
43
43
|
* Check if a page/layout file exports a loader() function.
|
|
44
44
|
*/
|
|
45
45
|
export declare function fileHasLoader(filePath: string): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Check if a page/layout file exports a subscribe() function.
|
|
48
|
+
*/
|
|
49
|
+
export declare function fileHasSubscribe(filePath: string): boolean;
|
|
46
50
|
/**
|
|
47
51
|
* Convert a file path (relative to pages/) to a route path.
|
|
48
52
|
*/
|
package/dist/shared/utils.js
CHANGED
|
@@ -29,7 +29,7 @@ export function dirToLayoutTagName(dir) {
|
|
|
29
29
|
}
|
|
30
30
|
/**
|
|
31
31
|
* Find the custom element tag name from a page module.
|
|
32
|
-
* Pages
|
|
32
|
+
* Pages are auto-registered by the auto-define plugin based on file path.
|
|
33
33
|
*/
|
|
34
34
|
export function findTagName(mod) {
|
|
35
35
|
for (const key of Object.keys(mod)) {
|
|
@@ -109,6 +109,18 @@ export function fileHasLoader(filePath) {
|
|
|
109
109
|
return false;
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Check if a page/layout file exports a subscribe() function.
|
|
114
|
+
*/
|
|
115
|
+
export function fileHasSubscribe(filePath) {
|
|
116
|
+
try {
|
|
117
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
118
|
+
return /export\s+(async\s+)?function\s+subscribe\s*\(/.test(content);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
112
124
|
/**
|
|
113
125
|
* Convert a file path (relative to pages/) to a route path.
|
|
114
126
|
*/
|
package/package.json
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nuraly/lumenjs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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",
|
|
7
7
|
"bin": {
|
|
8
8
|
"lumenjs": "dist/cli.js"
|
|
9
9
|
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./dist/cli.js",
|
|
12
|
+
"./db": "./dist/db/index.js"
|
|
13
|
+
},
|
|
10
14
|
"files": [
|
|
11
15
|
"dist",
|
|
16
|
+
"templates",
|
|
12
17
|
"README.md"
|
|
13
18
|
],
|
|
14
19
|
"scripts": {
|
|
15
20
|
"build": "tsc",
|
|
16
21
|
"dev": "tsc --watch",
|
|
17
|
-
"prepublishOnly": "tsc"
|
|
22
|
+
"prepublishOnly": "tsc",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"test:watch": "vitest",
|
|
25
|
+
"test:coverage": "vitest run --coverage"
|
|
18
26
|
},
|
|
19
27
|
"keywords": [
|
|
20
28
|
"lit",
|
|
@@ -38,14 +46,18 @@
|
|
|
38
46
|
"author": "labidiaymen <labidi@aymen.co>",
|
|
39
47
|
"license": "MIT",
|
|
40
48
|
"dependencies": {
|
|
41
|
-
"vite": "^5.4.0",
|
|
42
|
-
"lit": "^3.1.0",
|
|
43
49
|
"@lit-labs/ssr": "^3.2.0",
|
|
44
|
-
"
|
|
50
|
+
"better-sqlite3": "^11.0.0",
|
|
51
|
+
"glob": "^10.3.0",
|
|
52
|
+
"lit": "^3.1.0",
|
|
53
|
+
"vite": "^5.4.0"
|
|
45
54
|
},
|
|
46
55
|
"devDependencies": {
|
|
56
|
+
"@types/better-sqlite3": "^7.6.0",
|
|
57
|
+
"@types/node": "^20.14.2",
|
|
58
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
47
59
|
"typescript": "^5.4.5",
|
|
48
|
-
"
|
|
60
|
+
"vitest": "^4.0.18"
|
|
49
61
|
},
|
|
50
62
|
"engines": {
|
|
51
63
|
"node": ">=18"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useDb } from '@nuraly/lumenjs/db';
|
|
2
|
+
|
|
3
|
+
export function GET() {
|
|
4
|
+
const db = useDb();
|
|
5
|
+
const posts = db.all('SELECT id, title, slug, content, date FROM posts ORDER BY date DESC');
|
|
6
|
+
return { posts };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function POST(req: { body: any }) {
|
|
10
|
+
const { title, slug, content } = req.body;
|
|
11
|
+
if (!title || !slug || !content) {
|
|
12
|
+
throw { status: 400, message: 'title, slug, and content are required' };
|
|
13
|
+
}
|
|
14
|
+
const db = useDb();
|
|
15
|
+
const result = db.run(
|
|
16
|
+
'INSERT INTO posts (title, slug, content) VALUES (?, ?, ?)',
|
|
17
|
+
title, slug, content
|
|
18
|
+
);
|
|
19
|
+
return { id: result.lastInsertRowid, title, slug, content };
|
|
20
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS posts (
|
|
2
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3
|
+
title TEXT NOT NULL,
|
|
4
|
+
slug TEXT NOT NULL UNIQUE,
|
|
5
|
+
content TEXT NOT NULL,
|
|
6
|
+
date TEXT NOT NULL DEFAULT (date('now'))
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
INSERT INTO posts (title, slug, content, date) VALUES
|
|
10
|
+
('Hello World', 'hello-world', 'Welcome to your new LumenJS blog! This post was loaded from SQLite.', '2025-01-15'),
|
|
11
|
+
('Getting Started with LumenJS', 'getting-started', 'LumenJS makes it easy to build full-stack web apps with Lit web components and file-based routing.', '2025-01-20'),
|
|
12
|
+
('SQLite Persistence', 'sqlite-persistence', 'LumenJS includes built-in SQLite support via better-sqlite3. Just use useDb() in your loaders and API routes.', '2025-01-25');
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { LitElement, html, css } from 'lit';
|
|
2
|
+
import { customElement, property } from 'lit/decorators.js';
|
|
3
|
+
import { useDb } from '@nuraly/lumenjs/db';
|
|
4
|
+
|
|
5
|
+
export function loader() {
|
|
6
|
+
const db = useDb();
|
|
7
|
+
const posts = db.all('SELECT id, title, slug, content, date FROM posts ORDER BY date DESC');
|
|
8
|
+
return { posts };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
@customElement('page-home')
|
|
12
|
+
export class PageHome extends LitElement {
|
|
13
|
+
@property({ type: Object }) data: any;
|
|
14
|
+
|
|
15
|
+
static styles = css`
|
|
16
|
+
:host { display: block; max-width: 720px; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif; }
|
|
17
|
+
h1 { font-size: 2rem; margin-bottom: 1.5rem; }
|
|
18
|
+
.post { margin-bottom: 1.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid #eee; }
|
|
19
|
+
.post h2 { margin: 0 0 0.25rem; }
|
|
20
|
+
.post a { color: #0066cc; text-decoration: none; }
|
|
21
|
+
.post a:hover { text-decoration: underline; }
|
|
22
|
+
.post .date { color: #666; font-size: 0.875rem; }
|
|
23
|
+
.post p { color: #333; margin: 0.5rem 0 0; }
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
render() {
|
|
27
|
+
const posts = this.data?.posts || [];
|
|
28
|
+
return html`
|
|
29
|
+
<h1>Blog</h1>
|
|
30
|
+
${posts.map((post: any) => html`
|
|
31
|
+
<div class="post">
|
|
32
|
+
<h2><a href="/posts/${post.slug}">${post.title}</a></h2>
|
|
33
|
+
<span class="date">${post.date}</span>
|
|
34
|
+
<p>${post.content}</p>
|
|
35
|
+
</div>
|
|
36
|
+
`)}
|
|
37
|
+
`;
|
|
38
|
+
}
|
|
39
|
+
}
|