@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,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lit HMR plugin — patches existing custom element prototypes instead of re-registering.
|
|
3
|
+
*/
|
|
4
|
+
export function litHmrPlugin(projectDir) {
|
|
5
|
+
return {
|
|
6
|
+
name: 'lumenjs-lit-hmr',
|
|
7
|
+
enforce: 'post',
|
|
8
|
+
transform(code, id) {
|
|
9
|
+
if (!id.startsWith(projectDir) || !id.endsWith('.ts'))
|
|
10
|
+
return;
|
|
11
|
+
const match = code.match(/(\w+)\s*=\s*__decorateClass\(\s*\[\s*\n?\s*customElement\(\s*"([^"]+)"\s*\)\s*\n?\s*\]\s*,\s*\1\s*\)/);
|
|
12
|
+
if (!match)
|
|
13
|
+
return;
|
|
14
|
+
const [fullMatch, className, tagName] = match;
|
|
15
|
+
const transformed = code.replace(fullMatch, `if (!customElements.get("${tagName}")) {\n customElements.define("${tagName}", ${className});\n}`) + `
|
|
16
|
+
// --- Lit HMR ---
|
|
17
|
+
if (import.meta.hot) {
|
|
18
|
+
import.meta.hot.accept((newModule) => {
|
|
19
|
+
if (!newModule) return;
|
|
20
|
+
const NewClass = newModule.${className};
|
|
21
|
+
if (!NewClass) return;
|
|
22
|
+
const OldClass = customElements.get("${tagName}");
|
|
23
|
+
if (!OldClass) return;
|
|
24
|
+
const descriptors = Object.getOwnPropertyDescriptors(NewClass.prototype);
|
|
25
|
+
for (const [key, desc] of Object.entries(descriptors)) {
|
|
26
|
+
if (key === "constructor") continue;
|
|
27
|
+
Object.defineProperty(OldClass.prototype, key, desc);
|
|
28
|
+
}
|
|
29
|
+
if (NewClass.styles) {
|
|
30
|
+
OldClass.styles = NewClass.styles;
|
|
31
|
+
OldClass.elementStyles = undefined;
|
|
32
|
+
OldClass.finalizeStyles();
|
|
33
|
+
}
|
|
34
|
+
if (NewClass.properties) {
|
|
35
|
+
OldClass.properties = NewClass.properties;
|
|
36
|
+
}
|
|
37
|
+
document.querySelectorAll("${tagName}").forEach((el) => {
|
|
38
|
+
if (el.requestUpdate) el.requestUpdate();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
`;
|
|
43
|
+
return { code: transformed, map: null };
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Plugin } from 'vite';
|
|
2
|
+
/**
|
|
3
|
+
* LumenJS Server Loaders plugin.
|
|
4
|
+
*
|
|
5
|
+
* Pages can export a `loader()` function that runs on the server.
|
|
6
|
+
* The router fetches the data before rendering the page.
|
|
7
|
+
*
|
|
8
|
+
* Usage in a page file:
|
|
9
|
+
*
|
|
10
|
+
* export async function loader({ params, url }) {
|
|
11
|
+
* const data = await fetchFromDB(params.id);
|
|
12
|
+
* return { item: data, timestamp: Date.now() };
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* @customElement('page-item')
|
|
16
|
+
* export class PageItem extends LitElement {
|
|
17
|
+
* @property({ type: Object }) loaderData = {};
|
|
18
|
+
* render() {
|
|
19
|
+
* return html`<h1>${this.loaderData.item?.name}</h1>`;
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* The loader runs server-side via /__nk_loader/<page-path>
|
|
24
|
+
* Layout loaders run via /__nk_loader/__layout/?__dir=<dir>
|
|
25
|
+
* The router auto-fetches and passes the result as `loaderData` property.
|
|
26
|
+
*/
|
|
27
|
+
export declare function lumenLoadersPlugin(pagesDir: string): Plugin;
|
|
28
|
+
/**
|
|
29
|
+
* Map a URL path to a page file.
|
|
30
|
+
* / → pages/index.ts
|
|
31
|
+
* /about → pages/about.ts
|
|
32
|
+
* /blog/foo → pages/blog/[slug].ts or pages/blog/foo.ts
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolvePageFile(pagesDir: string, urlPath: string): string | null;
|
|
35
|
+
/**
|
|
36
|
+
* Extract dynamic route params by comparing URL segments against [param] file path segments.
|
|
37
|
+
*/
|
|
38
|
+
export declare function extractRouteParams(pagesDir: string, urlPath: string, filePath: string): Record<string, string>;
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { isRedirectResponse } from '../../shared/utils.js';
|
|
4
|
+
import { installDomShims } from '../../shared/dom-shims.js';
|
|
5
|
+
/**
|
|
6
|
+
* LumenJS Server Loaders plugin.
|
|
7
|
+
*
|
|
8
|
+
* Pages can export a `loader()` function that runs on the server.
|
|
9
|
+
* The router fetches the data before rendering the page.
|
|
10
|
+
*
|
|
11
|
+
* Usage in a page file:
|
|
12
|
+
*
|
|
13
|
+
* export async function loader({ params, url }) {
|
|
14
|
+
* const data = await fetchFromDB(params.id);
|
|
15
|
+
* return { item: data, timestamp: Date.now() };
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* @customElement('page-item')
|
|
19
|
+
* export class PageItem extends LitElement {
|
|
20
|
+
* @property({ type: Object }) loaderData = {};
|
|
21
|
+
* render() {
|
|
22
|
+
* return html`<h1>${this.loaderData.item?.name}</h1>`;
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
*
|
|
26
|
+
* The loader runs server-side via /__nk_loader/<page-path>
|
|
27
|
+
* Layout loaders run via /__nk_loader/__layout/?__dir=<dir>
|
|
28
|
+
* The router auto-fetches and passes the result as `loaderData` property.
|
|
29
|
+
*/
|
|
30
|
+
export function lumenLoadersPlugin(pagesDir) {
|
|
31
|
+
return {
|
|
32
|
+
name: 'lumenjs-loaders',
|
|
33
|
+
configureServer(server) {
|
|
34
|
+
server.middlewares.use(async (req, res, next) => {
|
|
35
|
+
if (!req.url?.startsWith('/__nk_loader/')) {
|
|
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 loader requests: /__nk_loader/__layout/?__dir=<dir>
|
|
48
|
+
if (pathname === '/__nk_loader/__layout/' || pathname === '/__nk_loader/__layout') {
|
|
49
|
+
const dir = query.__dir || '';
|
|
50
|
+
await handleLayoutLoader(server, pagesDir, dir, req, res);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const pagePath = pathname.replace('/__nk_loader', '') || '/';
|
|
54
|
+
// Parse URL params passed as __params query
|
|
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
|
+
// Find the page file
|
|
64
|
+
const filePath = resolvePageFile(pagesDir, pagePath);
|
|
65
|
+
if (!filePath) {
|
|
66
|
+
res.statusCode = 404;
|
|
67
|
+
res.setHeader('Content-Type', 'application/json');
|
|
68
|
+
res.end(JSON.stringify({ error: 'Page not found' }));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
// Extract params from URL if not provided via __params query
|
|
72
|
+
if (Object.keys(params).length === 0) {
|
|
73
|
+
Object.assign(params, extractRouteParams(pagesDir, pagePath, filePath));
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
// Provide minimal DOM shims for SSR so Lit class definitions don't crash
|
|
77
|
+
installDomShims();
|
|
78
|
+
const mod = await server.ssrLoadModule(filePath);
|
|
79
|
+
if (!mod.loader || typeof mod.loader !== 'function') {
|
|
80
|
+
// No loader — return empty data
|
|
81
|
+
res.statusCode = 200;
|
|
82
|
+
res.setHeader('Content-Type', 'application/json');
|
|
83
|
+
res.end(JSON.stringify({ __nk_no_loader: true }));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const result = await mod.loader({ params, query, url: pagePath, headers: req.headers });
|
|
87
|
+
if (isRedirectResponse(result)) {
|
|
88
|
+
res.statusCode = result.status || 302;
|
|
89
|
+
res.setHeader('Location', result.location);
|
|
90
|
+
res.end();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
res.statusCode = 200;
|
|
94
|
+
res.setHeader('Content-Type', 'application/json');
|
|
95
|
+
res.end(JSON.stringify(result ?? null));
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
if (isRedirectResponse(err)) {
|
|
99
|
+
res.statusCode = err.status || 302;
|
|
100
|
+
res.setHeader('Location', err.location);
|
|
101
|
+
res.end();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
console.error(`[LumenJS] Loader error for ${pagePath}:`, err);
|
|
105
|
+
const status = err?.status || 500;
|
|
106
|
+
const message = err?.message || 'Loader failed';
|
|
107
|
+
res.statusCode = status;
|
|
108
|
+
res.setHeader('Content-Type', 'application/json');
|
|
109
|
+
res.end(JSON.stringify({ error: message }));
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
/**
|
|
114
|
+
* Strip the loader() export from client bundles.
|
|
115
|
+
* Runs before esbuild (enforce: 'pre') so we operate on raw TypeScript source.
|
|
116
|
+
* Skip for SSR so ssrLoadModule can access the loader.
|
|
117
|
+
* Applies to both page files and _layout files.
|
|
118
|
+
*/
|
|
119
|
+
enforce: 'pre',
|
|
120
|
+
transform(code, id, options) {
|
|
121
|
+
if (options?.ssr)
|
|
122
|
+
return;
|
|
123
|
+
// Apply to page files and layout files within the pages directory
|
|
124
|
+
if (!id.startsWith(pagesDir) || !id.endsWith('.ts'))
|
|
125
|
+
return;
|
|
126
|
+
if (!code.includes('export') || !code.includes('loader'))
|
|
127
|
+
return;
|
|
128
|
+
const hasLoader = /export\s+(async\s+)?function\s+loader\s*\(/.test(code);
|
|
129
|
+
if (!hasLoader)
|
|
130
|
+
return;
|
|
131
|
+
// Find the loader function by tracking brace depth
|
|
132
|
+
const match = code.match(/export\s+(async\s+)?function\s+loader\s*\(/);
|
|
133
|
+
if (!match)
|
|
134
|
+
return;
|
|
135
|
+
const startIdx = match.index;
|
|
136
|
+
// Skip past the function signature's closing parenthesis (handles nested braces in type annotations)
|
|
137
|
+
let parenDepth = 1;
|
|
138
|
+
let sigIdx = startIdx + match[0].length;
|
|
139
|
+
while (sigIdx < code.length && parenDepth > 0) {
|
|
140
|
+
if (code[sigIdx] === '(')
|
|
141
|
+
parenDepth++;
|
|
142
|
+
else if (code[sigIdx] === ')')
|
|
143
|
+
parenDepth--;
|
|
144
|
+
sigIdx++;
|
|
145
|
+
}
|
|
146
|
+
// Find the opening brace of the function body (after the closing paren and optional return type)
|
|
147
|
+
let braceStart = code.indexOf('{', sigIdx);
|
|
148
|
+
if (braceStart === -1)
|
|
149
|
+
return;
|
|
150
|
+
let depth = 1;
|
|
151
|
+
let i = braceStart + 1;
|
|
152
|
+
while (i < code.length && depth > 0) {
|
|
153
|
+
if (code[i] === '{')
|
|
154
|
+
depth++;
|
|
155
|
+
else if (code[i] === '}')
|
|
156
|
+
depth--;
|
|
157
|
+
i++;
|
|
158
|
+
}
|
|
159
|
+
// Replace the entire loader function
|
|
160
|
+
const transformed = code.substring(0, startIdx)
|
|
161
|
+
+ '// loader() — runs server-side only'
|
|
162
|
+
+ code.substring(i);
|
|
163
|
+
const withFlag = transformed + '\nexport const __nk_has_loader = true;\n';
|
|
164
|
+
return { code: withFlag, map: null };
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Handle layout loader requests.
|
|
170
|
+
* GET /__nk_loader/__layout/?__dir=dashboard
|
|
171
|
+
*/
|
|
172
|
+
async function handleLayoutLoader(server, pagesDir, dir, req, res) {
|
|
173
|
+
// Resolve the layout file from the directory
|
|
174
|
+
const layoutDir = path.join(pagesDir, dir);
|
|
175
|
+
let layoutFile = null;
|
|
176
|
+
for (const ext of ['.ts', '.js']) {
|
|
177
|
+
const p = path.join(layoutDir, `_layout${ext}`);
|
|
178
|
+
if (fs.existsSync(p)) {
|
|
179
|
+
layoutFile = p;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (!layoutFile) {
|
|
184
|
+
res.statusCode = 200;
|
|
185
|
+
res.setHeader('Content-Type', 'application/json');
|
|
186
|
+
res.end(JSON.stringify({ __nk_no_loader: true }));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
installDomShims();
|
|
191
|
+
const mod = await server.ssrLoadModule(layoutFile);
|
|
192
|
+
if (!mod.loader || typeof mod.loader !== 'function') {
|
|
193
|
+
res.statusCode = 200;
|
|
194
|
+
res.setHeader('Content-Type', 'application/json');
|
|
195
|
+
res.end(JSON.stringify({ __nk_no_loader: true }));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const result = await mod.loader({ params: {}, query: {}, url: `/__layout/${dir}`, headers: req.headers });
|
|
199
|
+
if (isRedirectResponse(result)) {
|
|
200
|
+
res.statusCode = result.status || 302;
|
|
201
|
+
res.setHeader('Location', result.location);
|
|
202
|
+
res.end();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
res.statusCode = 200;
|
|
206
|
+
res.setHeader('Content-Type', 'application/json');
|
|
207
|
+
res.end(JSON.stringify(result ?? null));
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
if (isRedirectResponse(err)) {
|
|
211
|
+
res.statusCode = err.status || 302;
|
|
212
|
+
res.setHeader('Location', err.location);
|
|
213
|
+
res.end();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
console.error(`[LumenJS] Layout loader error for dir=${dir}:`, err);
|
|
217
|
+
const status = err?.status || 500;
|
|
218
|
+
const message = err?.message || 'Layout loader failed';
|
|
219
|
+
res.statusCode = status;
|
|
220
|
+
res.setHeader('Content-Type', 'application/json');
|
|
221
|
+
res.end(JSON.stringify({ error: message }));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Map a URL path to a page file.
|
|
226
|
+
* / → pages/index.ts
|
|
227
|
+
* /about → pages/about.ts
|
|
228
|
+
* /blog/foo → pages/blog/[slug].ts or pages/blog/foo.ts
|
|
229
|
+
*/
|
|
230
|
+
export function resolvePageFile(pagesDir, urlPath) {
|
|
231
|
+
const relative = urlPath.replace(/^\//, '') || 'index';
|
|
232
|
+
const segments = relative.split('/');
|
|
233
|
+
// Try exact match
|
|
234
|
+
const exactPath = path.join(pagesDir, ...segments);
|
|
235
|
+
for (const ext of ['.ts', '.js']) {
|
|
236
|
+
if (fs.existsSync(exactPath + ext)) {
|
|
237
|
+
return exactPath + ext;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Try index
|
|
241
|
+
const indexPath = path.join(pagesDir, ...segments, 'index');
|
|
242
|
+
for (const ext of ['.ts', '.js']) {
|
|
243
|
+
if (fs.existsSync(indexPath + ext)) {
|
|
244
|
+
return indexPath + ext;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// Try dynamic segments
|
|
248
|
+
return findDynamicPage(pagesDir, segments);
|
|
249
|
+
}
|
|
250
|
+
function findDynamicPage(baseDir, segments) {
|
|
251
|
+
if (segments.length === 0)
|
|
252
|
+
return null;
|
|
253
|
+
if (!fs.existsSync(baseDir))
|
|
254
|
+
return null;
|
|
255
|
+
const [current, ...rest] = segments;
|
|
256
|
+
const entries = fs.readdirSync(baseDir, { withFileTypes: true });
|
|
257
|
+
if (rest.length === 0) {
|
|
258
|
+
// Try exact or single-segment dynamic match first
|
|
259
|
+
for (const entry of entries) {
|
|
260
|
+
if (!entry.isFile())
|
|
261
|
+
continue;
|
|
262
|
+
const name = entry.name.replace(/\.(ts|js)$/, '');
|
|
263
|
+
if (name === current || (/^\[[^\.]/.test(name) && /^\[.+\]$/.test(name))) {
|
|
264
|
+
return path.join(baseDir, entry.name);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Try catch-all [...name] file
|
|
268
|
+
for (const entry of entries) {
|
|
269
|
+
if (!entry.isFile())
|
|
270
|
+
continue;
|
|
271
|
+
const name = entry.name.replace(/\.(ts|js)$/, '');
|
|
272
|
+
if (/^\[\.\.\./.test(name)) {
|
|
273
|
+
return path.join(baseDir, entry.name);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
// Try exact or single-segment dynamic directory match first
|
|
279
|
+
for (const entry of entries) {
|
|
280
|
+
if (!entry.isDirectory())
|
|
281
|
+
continue;
|
|
282
|
+
if (entry.name === current || (/^\[[^\.]/.test(entry.name) && /^\[.+\]$/.test(entry.name))) {
|
|
283
|
+
const result = findDynamicPage(path.join(baseDir, entry.name), rest);
|
|
284
|
+
if (result)
|
|
285
|
+
return result;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// Try catch-all [...name] file (consumes all remaining segments)
|
|
289
|
+
for (const entry of entries) {
|
|
290
|
+
if (!entry.isFile())
|
|
291
|
+
continue;
|
|
292
|
+
const name = entry.name.replace(/\.(ts|js)$/, '');
|
|
293
|
+
if (/^\[\.\.\./.test(name)) {
|
|
294
|
+
return path.join(baseDir, entry.name);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Extract dynamic route params by comparing URL segments against [param] file path segments.
|
|
301
|
+
*/
|
|
302
|
+
export function extractRouteParams(pagesDir, urlPath, filePath) {
|
|
303
|
+
const params = {};
|
|
304
|
+
const urlSegments = (urlPath.replace(/^\//, '') || 'index').split('/');
|
|
305
|
+
const fileRelative = path.relative(pagesDir, filePath).replace(/\.(ts|js)$/, '');
|
|
306
|
+
const fileSegments = fileRelative.split(path.sep);
|
|
307
|
+
for (let i = 0; i < fileSegments.length && i < urlSegments.length; i++) {
|
|
308
|
+
const catchAllMatch = fileSegments[i].match(/^\[\.\.\.(.+)\]$/);
|
|
309
|
+
if (catchAllMatch) {
|
|
310
|
+
// Catch-all: capture all remaining URL segments joined with /
|
|
311
|
+
params[catchAllMatch[1]] = urlSegments.slice(i).join('/');
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
const match = fileSegments[i].match(/^\[(.+)\]$/);
|
|
315
|
+
if (match) {
|
|
316
|
+
params[match[1]] = urlSegments[i];
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return params;
|
|
320
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Plugin } from 'vite';
|
|
2
|
+
export interface RouteEntry {
|
|
3
|
+
path: string;
|
|
4
|
+
componentPath: string;
|
|
5
|
+
tagName: string;
|
|
6
|
+
}
|
|
7
|
+
export interface LayoutEntry {
|
|
8
|
+
/** Relative directory within pages/ ('' for root) */
|
|
9
|
+
dir: string;
|
|
10
|
+
filePath: string;
|
|
11
|
+
tagName: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Scans pages/ directory and generates a virtual route manifest module.
|
|
15
|
+
* Supports:
|
|
16
|
+
* pages/index.ts → /
|
|
17
|
+
* pages/about.ts → /about
|
|
18
|
+
* pages/blog/[slug].ts → /blog/:slug
|
|
19
|
+
* pages/_layout.ts → layout wrapping all pages in directory + subdirectories
|
|
20
|
+
*/
|
|
21
|
+
export declare function lumenRoutesPlugin(pagesDir: string): Plugin;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { dirToLayoutTagName, fileHasLoader, filePathToRoute } from '../../shared/utils.js';
|
|
4
|
+
const VIRTUAL_MODULE_ID = 'virtual:lumenjs-routes';
|
|
5
|
+
const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
|
|
6
|
+
/**
|
|
7
|
+
* Scans pages/ directory and generates a virtual route manifest module.
|
|
8
|
+
* Supports:
|
|
9
|
+
* pages/index.ts → /
|
|
10
|
+
* pages/about.ts → /about
|
|
11
|
+
* pages/blog/[slug].ts → /blog/:slug
|
|
12
|
+
* pages/_layout.ts → layout wrapping all pages in directory + subdirectories
|
|
13
|
+
*/
|
|
14
|
+
export function lumenRoutesPlugin(pagesDir) {
|
|
15
|
+
function scanLayouts() {
|
|
16
|
+
if (!fs.existsSync(pagesDir))
|
|
17
|
+
return [];
|
|
18
|
+
const layouts = [];
|
|
19
|
+
walkForLayouts(pagesDir, '', layouts);
|
|
20
|
+
return layouts;
|
|
21
|
+
}
|
|
22
|
+
function walkForLayouts(baseDir, relativePath, layouts) {
|
|
23
|
+
const fullDir = path.join(baseDir, relativePath);
|
|
24
|
+
const entries = fs.readdirSync(fullDir, { withFileTypes: true });
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
if (entry.isFile() && /^_layout\.(ts|js)$/.test(entry.name)) {
|
|
27
|
+
const filePath = path.join(fullDir, entry.name);
|
|
28
|
+
const tagName = dirToLayoutTagName(relativePath);
|
|
29
|
+
layouts.push({ dir: relativePath.replace(/\\/g, '/'), filePath, tagName });
|
|
30
|
+
}
|
|
31
|
+
if (entry.isDirectory()) {
|
|
32
|
+
walkForLayouts(baseDir, path.join(relativePath, entry.name), layouts);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function scanPages() {
|
|
37
|
+
if (!fs.existsSync(pagesDir))
|
|
38
|
+
return [];
|
|
39
|
+
const routes = [];
|
|
40
|
+
walkDir(pagesDir, '', routes);
|
|
41
|
+
// Sort: static → dynamic → catch-all
|
|
42
|
+
routes.sort((a, b) => {
|
|
43
|
+
const aCatchAll = a.path.includes(':...');
|
|
44
|
+
const bCatchAll = b.path.includes(':...');
|
|
45
|
+
if (aCatchAll !== bCatchAll)
|
|
46
|
+
return aCatchAll ? 1 : -1;
|
|
47
|
+
const aDynamic = a.path.includes(':');
|
|
48
|
+
const bDynamic = b.path.includes(':');
|
|
49
|
+
if (aDynamic !== bDynamic)
|
|
50
|
+
return aDynamic ? 1 : -1;
|
|
51
|
+
return a.path.localeCompare(b.path);
|
|
52
|
+
});
|
|
53
|
+
return routes;
|
|
54
|
+
}
|
|
55
|
+
function walkDir(baseDir, relativePath, routes) {
|
|
56
|
+
const fullDir = path.join(baseDir, relativePath);
|
|
57
|
+
const entries = fs.readdirSync(fullDir, { withFileTypes: true });
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
const entryRelative = path.join(relativePath, entry.name);
|
|
60
|
+
if (entry.isDirectory()) {
|
|
61
|
+
walkDir(baseDir, entryRelative, routes);
|
|
62
|
+
}
|
|
63
|
+
else if (entry.isFile() && /\.(ts|js)$/.test(entry.name) && !entry.name.startsWith('_')) {
|
|
64
|
+
const routePath = filePathToRoute(entryRelative);
|
|
65
|
+
const componentPath = path.join(pagesDir, entryRelative);
|
|
66
|
+
const tagName = filePathToTagName(entryRelative);
|
|
67
|
+
routes.push({ path: routePath, componentPath, tagName });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function filePathToTagName(filePath) {
|
|
72
|
+
const name = filePath
|
|
73
|
+
.replace(/\.(ts|js)$/, '')
|
|
74
|
+
.replace(/\\/g, '-')
|
|
75
|
+
.replace(/\//g, '-')
|
|
76
|
+
.replace(/\[\.\.\.([^\]]+)\]/g, '$1')
|
|
77
|
+
.replace(/\[([^\]]+)\]/g, '$1')
|
|
78
|
+
.toLowerCase();
|
|
79
|
+
return `page-${name}`;
|
|
80
|
+
}
|
|
81
|
+
/** Build the layout chain for a route based on its file path within pages/ */
|
|
82
|
+
function getLayoutChain(componentPath, layouts) {
|
|
83
|
+
const relativeToPages = path.relative(pagesDir, componentPath).replace(/\\/g, '/');
|
|
84
|
+
const dirParts = path.dirname(relativeToPages).split('/').filter(p => p && p !== '.');
|
|
85
|
+
const chain = [];
|
|
86
|
+
const rootLayout = layouts.find(l => l.dir === '');
|
|
87
|
+
if (rootLayout)
|
|
88
|
+
chain.push(rootLayout);
|
|
89
|
+
let currentDir = '';
|
|
90
|
+
for (const part of dirParts) {
|
|
91
|
+
currentDir = currentDir ? `${currentDir}/${part}` : part;
|
|
92
|
+
const layout = layouts.find(l => l.dir === currentDir);
|
|
93
|
+
if (layout)
|
|
94
|
+
chain.push(layout);
|
|
95
|
+
}
|
|
96
|
+
return chain;
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
name: 'lumenjs-routes',
|
|
100
|
+
resolveId(id) {
|
|
101
|
+
if (id === VIRTUAL_MODULE_ID)
|
|
102
|
+
return RESOLVED_VIRTUAL_MODULE_ID;
|
|
103
|
+
},
|
|
104
|
+
load(id) {
|
|
105
|
+
if (id === RESOLVED_VIRTUAL_MODULE_ID) {
|
|
106
|
+
const routes = scanPages();
|
|
107
|
+
const layouts = scanLayouts();
|
|
108
|
+
const routeArray = routes
|
|
109
|
+
.map(r => {
|
|
110
|
+
const hasLoader = fileHasLoader(r.componentPath);
|
|
111
|
+
const componentPath = r.componentPath.replace(/\\/g, '/');
|
|
112
|
+
const chain = getLayoutChain(r.componentPath, layouts);
|
|
113
|
+
let layoutsStr = '';
|
|
114
|
+
if (chain.length > 0) {
|
|
115
|
+
const items = chain.map(l => {
|
|
116
|
+
const lHasLoader = fileHasLoader(l.filePath);
|
|
117
|
+
const lPath = l.filePath.replace(/\\/g, '/');
|
|
118
|
+
return `{ tagName: ${JSON.stringify(l.tagName)}, loaderPath: ${JSON.stringify(l.dir)}${lHasLoader ? ', hasLoader: true' : ''}, load: () => import('${lPath}') }`;
|
|
119
|
+
});
|
|
120
|
+
layoutsStr = `, layouts: [${items.join(', ')}]`;
|
|
121
|
+
}
|
|
122
|
+
return ` { path: ${JSON.stringify(r.path)}, tagName: ${JSON.stringify(r.tagName)}${hasLoader ? ', hasLoader: true' : ''}, load: () => import('${componentPath}')${layoutsStr} }`;
|
|
123
|
+
})
|
|
124
|
+
.join(',\n');
|
|
125
|
+
return `export const routes = [\n${routeArray}\n];\n`;
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
configureServer(server) {
|
|
129
|
+
// Full-reload when route structure changes (file added/removed)
|
|
130
|
+
let lastRoutes = JSON.stringify(scanPages().map(r => r.path));
|
|
131
|
+
let lastLayouts = JSON.stringify(scanLayouts().map(l => l.dir));
|
|
132
|
+
const checkReload = () => {
|
|
133
|
+
const newRoutes = JSON.stringify(scanPages().map(r => r.path));
|
|
134
|
+
const newLayouts = JSON.stringify(scanLayouts().map(l => l.dir));
|
|
135
|
+
if (newRoutes !== lastRoutes || newLayouts !== lastLayouts) {
|
|
136
|
+
lastRoutes = newRoutes;
|
|
137
|
+
lastLayouts = newLayouts;
|
|
138
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID);
|
|
139
|
+
if (mod) {
|
|
140
|
+
server.moduleGraph.invalidateModule(mod);
|
|
141
|
+
server.ws.send({ type: 'full-reload' });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
server.watcher.on('add', (file) => {
|
|
146
|
+
if (!file.startsWith(pagesDir))
|
|
147
|
+
return;
|
|
148
|
+
checkReload();
|
|
149
|
+
});
|
|
150
|
+
server.watcher.on('unlink', (file) => {
|
|
151
|
+
if (!file.startsWith(pagesDir))
|
|
152
|
+
return;
|
|
153
|
+
checkReload();
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
/**
|
|
3
|
+
* In editor mode, inject data-nk-source attributes into html`` template literals.
|
|
4
|
+
*/
|
|
5
|
+
export function sourceAnnotatorPlugin(projectDir) {
|
|
6
|
+
return {
|
|
7
|
+
name: 'lumenjs-source-annotator',
|
|
8
|
+
transform(code, id) {
|
|
9
|
+
if (!id.startsWith(projectDir) || !id.endsWith('.ts'))
|
|
10
|
+
return;
|
|
11
|
+
if (!code.includes('html`'))
|
|
12
|
+
return;
|
|
13
|
+
const relativePath = path.relative(projectDir, id);
|
|
14
|
+
const transformed = code.replace(/html`([\s\S]*?)`/g, (match, templateContent) => {
|
|
15
|
+
let offset = 0;
|
|
16
|
+
const beforeTemplate = code.substring(0, code.indexOf(match));
|
|
17
|
+
const baseLine = beforeTemplate.split('\n').length;
|
|
18
|
+
const annotated = templateContent.replace(/<([a-z][a-z0-9]*-[a-z0-9-]*)([\s>])/gi, (tagMatch, tagName, after) => {
|
|
19
|
+
const beforeTag = templateContent.substring(0, templateContent.indexOf(tagMatch, offset));
|
|
20
|
+
const lineInTemplate = beforeTag.split('\n').length - 1;
|
|
21
|
+
offset = templateContent.indexOf(tagMatch, offset) + tagMatch.length;
|
|
22
|
+
const line = baseLine + lineInTemplate;
|
|
23
|
+
return `<${tagName} data-nk-source="${relativePath}:${line}"${after}`;
|
|
24
|
+
});
|
|
25
|
+
const dynamicAnnotated = annotated.replace(/<(h[1-6]|p|span|a|label|li|button|div)(\s[^>]*)?>([^<]*\$\{[^<]*)<\//gi, (m, tag, attrs, content) => {
|
|
26
|
+
const attrStr = attrs || '';
|
|
27
|
+
if (attrStr.includes('data-nk-dynamic'))
|
|
28
|
+
return m;
|
|
29
|
+
const escaped = content.trim().replace(/"/g, '"').replace(/\$\{/g, '__NK_EXPR__');
|
|
30
|
+
return `<${tag}${attrStr} data-nk-dynamic="${escaped}">${content}</`;
|
|
31
|
+
});
|
|
32
|
+
return 'html`' + dynamicAnnotated + '`';
|
|
33
|
+
});
|
|
34
|
+
if (transformed !== code) {
|
|
35
|
+
return { code: transformed, map: null };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
/**
|
|
4
|
+
* Virtual module plugin — serves compiled LumenJS runtime and editor modules.
|
|
5
|
+
*/
|
|
6
|
+
export function virtualModulesPlugin(runtimeDir, editorDir) {
|
|
7
|
+
return {
|
|
8
|
+
name: 'lumenjs-virtual-modules',
|
|
9
|
+
resolveId(id) {
|
|
10
|
+
if (id === '/@lumenjs/app-shell')
|
|
11
|
+
return '\0lumenjs:app-shell';
|
|
12
|
+
if (id === '/@lumenjs/router')
|
|
13
|
+
return '\0lumenjs:router';
|
|
14
|
+
if (id === '/@lumenjs/editor-bridge')
|
|
15
|
+
return '\0lumenjs:editor-bridge';
|
|
16
|
+
if (id === '/@lumenjs/element-annotator')
|
|
17
|
+
return '\0lumenjs:element-annotator';
|
|
18
|
+
},
|
|
19
|
+
load(id) {
|
|
20
|
+
if (id === '\0lumenjs:app-shell') {
|
|
21
|
+
let code = fs.readFileSync(path.join(runtimeDir, 'app-shell.js'), 'utf-8');
|
|
22
|
+
code = code.replace(/from\s+['"]\.\/router\.js['"]/g, "from '/@lumenjs/router'");
|
|
23
|
+
return code;
|
|
24
|
+
}
|
|
25
|
+
if (id === '\0lumenjs:router') {
|
|
26
|
+
return fs.readFileSync(path.join(runtimeDir, 'router.js'), 'utf-8');
|
|
27
|
+
}
|
|
28
|
+
if (id === '\0lumenjs:editor-bridge') {
|
|
29
|
+
let code = fs.readFileSync(path.join(editorDir, 'editor-bridge.js'), 'utf-8');
|
|
30
|
+
code = code.replace(/from\s+['"]\.\/element-annotator\.js['"]/g, "from '/@lumenjs/element-annotator'");
|
|
31
|
+
return code;
|
|
32
|
+
}
|
|
33
|
+
if (id === '\0lumenjs:element-annotator') {
|
|
34
|
+
return fs.readFileSync(path.join(editorDir, 'element-annotator.js'), 'utf-8');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|