@nuraly/lumenjs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +297 -0
  2. package/dist/build/build.d.ts +5 -0
  3. package/dist/build/build.js +172 -0
  4. package/dist/build/error-page.d.ts +1 -0
  5. package/dist/build/error-page.js +74 -0
  6. package/dist/build/scan.d.ts +21 -0
  7. package/dist/build/scan.js +93 -0
  8. package/dist/build/serve-api.d.ts +3 -0
  9. package/dist/build/serve-api.js +56 -0
  10. package/dist/build/serve-loaders.d.ts +4 -0
  11. package/dist/build/serve-loaders.js +115 -0
  12. package/dist/build/serve-ssr.d.ts +7 -0
  13. package/dist/build/serve-ssr.js +121 -0
  14. package/dist/build/serve-static.d.ts +6 -0
  15. package/dist/build/serve-static.js +80 -0
  16. package/dist/build/serve.d.ts +5 -0
  17. package/dist/build/serve.js +79 -0
  18. package/dist/cli.d.ts +2 -0
  19. package/dist/cli.js +65 -0
  20. package/dist/dev-server/config.d.ts +25 -0
  21. package/dist/dev-server/config.js +55 -0
  22. package/dist/dev-server/index-html.d.ts +16 -0
  23. package/dist/dev-server/index-html.js +46 -0
  24. package/dist/dev-server/nuralyui-aliases.d.ts +16 -0
  25. package/dist/dev-server/nuralyui-aliases.js +164 -0
  26. package/dist/dev-server/plugins/vite-plugin-api-routes.d.ts +23 -0
  27. package/dist/dev-server/plugins/vite-plugin-api-routes.js +250 -0
  28. package/dist/dev-server/plugins/vite-plugin-auto-import.d.ts +5 -0
  29. package/dist/dev-server/plugins/vite-plugin-auto-import.js +47 -0
  30. package/dist/dev-server/plugins/vite-plugin-lit-dedup.d.ts +5 -0
  31. package/dist/dev-server/plugins/vite-plugin-lit-dedup.js +62 -0
  32. package/dist/dev-server/plugins/vite-plugin-lit-hmr.d.ts +5 -0
  33. package/dist/dev-server/plugins/vite-plugin-lit-hmr.js +46 -0
  34. package/dist/dev-server/plugins/vite-plugin-loaders.d.ts +38 -0
  35. package/dist/dev-server/plugins/vite-plugin-loaders.js +320 -0
  36. package/dist/dev-server/plugins/vite-plugin-routes.d.ts +21 -0
  37. package/dist/dev-server/plugins/vite-plugin-routes.js +157 -0
  38. package/dist/dev-server/plugins/vite-plugin-source-annotator.d.ts +5 -0
  39. package/dist/dev-server/plugins/vite-plugin-source-annotator.js +39 -0
  40. package/dist/dev-server/plugins/vite-plugin-virtual-modules.d.ts +5 -0
  41. package/dist/dev-server/plugins/vite-plugin-virtual-modules.js +38 -0
  42. package/dist/dev-server/server.d.ts +23 -0
  43. package/dist/dev-server/server.js +155 -0
  44. package/dist/dev-server/ssr-render.d.ts +20 -0
  45. package/dist/dev-server/ssr-render.js +170 -0
  46. package/dist/editor/click-select.d.ts +1 -0
  47. package/dist/editor/click-select.js +46 -0
  48. package/dist/editor/editor-bridge.d.ts +17 -0
  49. package/dist/editor/editor-bridge.js +101 -0
  50. package/dist/editor/element-annotator.d.ts +33 -0
  51. package/dist/editor/element-annotator.js +83 -0
  52. package/dist/editor/hover-detect.d.ts +1 -0
  53. package/dist/editor/hover-detect.js +36 -0
  54. package/dist/editor/inline-text-edit.d.ts +1 -0
  55. package/dist/editor/inline-text-edit.js +114 -0
  56. package/dist/integrations/add.d.ts +1 -0
  57. package/dist/integrations/add.js +89 -0
  58. package/dist/runtime/app-shell.d.ts +1 -0
  59. package/dist/runtime/app-shell.js +22 -0
  60. package/dist/runtime/response.d.ts +15 -0
  61. package/dist/runtime/response.js +13 -0
  62. package/dist/runtime/router-data.d.ts +3 -0
  63. package/dist/runtime/router-data.js +40 -0
  64. package/dist/runtime/router-hydration.d.ts +10 -0
  65. package/dist/runtime/router-hydration.js +68 -0
  66. package/dist/runtime/router.d.ts +35 -0
  67. package/dist/runtime/router.js +202 -0
  68. package/dist/shared/dom-shims.d.ts +5 -0
  69. package/dist/shared/dom-shims.js +63 -0
  70. package/dist/shared/route-matching.d.ts +6 -0
  71. package/dist/shared/route-matching.js +44 -0
  72. package/dist/shared/types.d.ts +16 -0
  73. package/dist/shared/types.js +1 -0
  74. package/dist/shared/utils.d.ts +42 -0
  75. package/dist/shared/utils.js +109 -0
  76. package/package.json +53 -0
@@ -0,0 +1,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,5 @@
1
+ import { Plugin } from 'vite';
2
+ /**
3
+ * In editor mode, inject data-nk-source attributes into html`` template literals.
4
+ */
5
+ export declare function sourceAnnotatorPlugin(projectDir: string): Plugin;
@@ -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, '&quot;').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,5 @@
1
+ import { Plugin } from 'vite';
2
+ /**
3
+ * Virtual module plugin — serves compiled LumenJS runtime and editor modules.
4
+ */
5
+ export declare function virtualModulesPlugin(runtimeDir: string, editorDir: string): Plugin;
@@ -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
+ }