@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
package/README.md
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# LumenJS
|
|
2
|
+
|
|
3
|
+
A full-stack web framework for [Lit](https://lit.dev/) web components. File-based routing, server loaders, SSR with hydration, nested layouts, API routes, and a Vite-powered dev server.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx lumenjs dev --project ./my-app
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Project Structure
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
my-app/
|
|
15
|
+
├── lumenjs.config.ts # Project config
|
|
16
|
+
├── package.json
|
|
17
|
+
├── pages/ # File-based routes
|
|
18
|
+
│ ├── _layout.ts # Root layout
|
|
19
|
+
│ ├── index.ts # → /
|
|
20
|
+
│ ├── about.ts # → /about
|
|
21
|
+
│ └── blog/
|
|
22
|
+
│ ├── _layout.ts # Nested layout (wraps blog/*)
|
|
23
|
+
│ ├── index.ts # → /blog
|
|
24
|
+
│ └── [slug].ts # → /blog/:slug
|
|
25
|
+
├── api/ # API routes
|
|
26
|
+
│ └── hello.ts # → /api/hello
|
|
27
|
+
└── public/ # Static assets
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Configuration
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
// lumenjs.config.ts
|
|
34
|
+
export default {
|
|
35
|
+
title: 'My App',
|
|
36
|
+
integrations: ['tailwind'],
|
|
37
|
+
};
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
| Option | Type | Description |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `title` | `string` | HTML page title |
|
|
43
|
+
| `integrations` | `string[]` | Optional integrations: `'tailwind'`, `'nuralyui'` |
|
|
44
|
+
|
|
45
|
+
## Pages
|
|
46
|
+
|
|
47
|
+
Pages are Lit components in the `pages/` directory. The file path determines the URL.
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
// pages/index.ts
|
|
51
|
+
import { LitElement, html, css } from 'lit';
|
|
52
|
+
import { customElement } from 'lit/decorators.js';
|
|
53
|
+
|
|
54
|
+
@customElement('page-index')
|
|
55
|
+
export class PageIndex extends LitElement {
|
|
56
|
+
static styles = css`:host { display: block; }`;
|
|
57
|
+
|
|
58
|
+
render() {
|
|
59
|
+
return html`<h1>Hello, LumenJS!</h1>`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Routing
|
|
65
|
+
|
|
66
|
+
| File | URL | Tag |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `pages/index.ts` | `/` | `<page-index>` |
|
|
69
|
+
| `pages/about.ts` | `/about` | `<page-about>` |
|
|
70
|
+
| `pages/blog/index.ts` | `/blog` | `<page-blog-index>` |
|
|
71
|
+
| `pages/blog/[slug].ts` | `/blog/:slug` | `<page-blog-slug>` |
|
|
72
|
+
| `pages/[...slug].ts` | `/*` (catch-all) | `<page-slug>` |
|
|
73
|
+
|
|
74
|
+
Static routes take priority over dynamic ones. Dynamic `[param]` routes take priority over catch-all `[...param]` routes.
|
|
75
|
+
|
|
76
|
+
## Loaders
|
|
77
|
+
|
|
78
|
+
Export a `loader()` function from any page or layout to fetch data on the server.
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
// pages/blog/[slug].ts
|
|
82
|
+
export async function loader({ params, headers, query, url }) {
|
|
83
|
+
const post = await db.posts.findOne({ slug: params.slug });
|
|
84
|
+
if (!post) return { __nk_redirect: true, location: '/404', status: 302 };
|
|
85
|
+
return { post };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@customElement('page-blog-slug')
|
|
89
|
+
export class BlogPost extends LitElement {
|
|
90
|
+
@property({ type: Object }) loaderData: any = {};
|
|
91
|
+
|
|
92
|
+
render() {
|
|
93
|
+
return html`<h1>${this.loaderData.post?.title}</h1>`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Loaders run server-side on initial load (SSR) and are fetched via `/__nk_loader/<path>` during client-side navigation. The `loader()` export is automatically stripped from client bundles.
|
|
99
|
+
|
|
100
|
+
### Loader Context
|
|
101
|
+
|
|
102
|
+
| Property | Type | Description |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| `params` | `Record<string, string>` | Dynamic route parameters |
|
|
105
|
+
| `query` | `Record<string, string>` | Query string parameters |
|
|
106
|
+
| `url` | `string` | Request pathname |
|
|
107
|
+
| `headers` | `Record<string, any>` | Request headers |
|
|
108
|
+
|
|
109
|
+
### Redirects
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
export async function loader({ headers }) {
|
|
113
|
+
const user = await getUser(headers.authorization);
|
|
114
|
+
if (!user) return { __nk_redirect: true, location: '/login', status: 302 };
|
|
115
|
+
return { user };
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Nested Layouts
|
|
120
|
+
|
|
121
|
+
Create `_layout.ts` in any directory to wrap all pages in that directory and its subdirectories.
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
// pages/_layout.ts
|
|
125
|
+
@customElement('layout-root')
|
|
126
|
+
export class RootLayout extends LitElement {
|
|
127
|
+
render() {
|
|
128
|
+
return html`
|
|
129
|
+
<header>My App</header>
|
|
130
|
+
<main><slot></slot></main>
|
|
131
|
+
<footer>Footer</footer>
|
|
132
|
+
`;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Layouts persist across navigation — when navigating between pages that share the same layout, only the page component is swapped.
|
|
138
|
+
|
|
139
|
+
Layouts can have their own `loader()` function for shared data like auth or navigation:
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
// pages/dashboard/_layout.ts
|
|
143
|
+
export async function loader({ headers }) {
|
|
144
|
+
const user = await getUser(headers.authorization);
|
|
145
|
+
if (!user) return { __nk_redirect: true, location: '/login', status: 302 };
|
|
146
|
+
return { user };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
@customElement('layout-dashboard')
|
|
150
|
+
export class DashboardLayout extends LitElement {
|
|
151
|
+
@property({ type: Object }) loaderData: any = {};
|
|
152
|
+
|
|
153
|
+
render() {
|
|
154
|
+
return html`
|
|
155
|
+
<nav>Welcome, ${this.loaderData.user?.name}</nav>
|
|
156
|
+
<slot></slot>
|
|
157
|
+
`;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## API Routes
|
|
163
|
+
|
|
164
|
+
Create files in `api/` and export named functions for each HTTP method.
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
// api/users/[id].ts
|
|
168
|
+
export async function GET(req) {
|
|
169
|
+
return { user: { id: req.params.id, name: 'Alice' } };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function POST(req) {
|
|
173
|
+
const { name } = req.body;
|
|
174
|
+
return { created: true, name };
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Request Object
|
|
179
|
+
|
|
180
|
+
| Property | Type | Description |
|
|
181
|
+
|---|---|---|
|
|
182
|
+
| `method` | `string` | HTTP method |
|
|
183
|
+
| `url` | `string` | Request pathname |
|
|
184
|
+
| `query` | `Record<string, string>` | Query string parameters |
|
|
185
|
+
| `params` | `Record<string, string>` | Dynamic route parameters |
|
|
186
|
+
| `body` | `any` | Parsed JSON body (non-GET) |
|
|
187
|
+
| `files` | `NkUploadedFile[]` | Uploaded files (multipart) |
|
|
188
|
+
| `headers` | `Record<string, any>` | Request headers |
|
|
189
|
+
|
|
190
|
+
### Error Responses
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
export async function GET(req) {
|
|
194
|
+
const item = await db.find(req.params.id);
|
|
195
|
+
if (!item) throw { status: 404, message: 'Not found' };
|
|
196
|
+
return item;
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### File Uploads
|
|
201
|
+
|
|
202
|
+
Multipart form data is parsed automatically:
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
export async function POST(req) {
|
|
206
|
+
for (const file of req.files) {
|
|
207
|
+
console.log(file.fileName, file.size, file.contentType);
|
|
208
|
+
// file.data is a Buffer
|
|
209
|
+
}
|
|
210
|
+
return { uploaded: req.files.length };
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## SSR & Hydration
|
|
215
|
+
|
|
216
|
+
Pages with loaders are automatically server-rendered using `@lit-labs/ssr`:
|
|
217
|
+
|
|
218
|
+
1. Loader runs on the server
|
|
219
|
+
2. Lit component renders to HTML
|
|
220
|
+
3. Loader data is embedded as JSON in the response
|
|
221
|
+
4. Browser receives pre-rendered HTML (fast first paint)
|
|
222
|
+
5. Client hydrates the existing DOM without re-rendering
|
|
223
|
+
|
|
224
|
+
Pages without loaders render client-side only (SPA mode). If SSR fails, LumenJS falls back gracefully to client-side rendering.
|
|
225
|
+
|
|
226
|
+
## Integrations
|
|
227
|
+
|
|
228
|
+
### Tailwind CSS
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
npx lumenjs add tailwind
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
This installs `tailwindcss` and `@tailwindcss/vite`, creates `styles/tailwind.css`, and updates your config. For pages using Tailwind classes in light DOM:
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
createRenderRoot() { return this; }
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### NuralyUI
|
|
241
|
+
|
|
242
|
+
Add `'nuralyui'` to integrations to enable auto-import of `<nr-*>` components:
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
// lumenjs.config.ts
|
|
246
|
+
export default {
|
|
247
|
+
title: 'My App',
|
|
248
|
+
integrations: ['nuralyui'],
|
|
249
|
+
};
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
NuralyUI components are detected in `html\`\`` templates and imported automatically, including implicit dependencies (e.g., `nr-button` auto-imports `nr-icon`).
|
|
253
|
+
|
|
254
|
+
## CLI
|
|
255
|
+
|
|
256
|
+
```
|
|
257
|
+
lumenjs dev [--project <dir>] [--port <port>] [--base <path>] [--editor-mode]
|
|
258
|
+
lumenjs build [--project <dir>] [--out <dir>]
|
|
259
|
+
lumenjs serve [--project <dir>] [--port <port>]
|
|
260
|
+
lumenjs add <integration>
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
| Command | Description |
|
|
264
|
+
|---|---|
|
|
265
|
+
| `dev` | Start Vite dev server with HMR, SSR, and API routes |
|
|
266
|
+
| `build` | Bundle client assets and server modules for production |
|
|
267
|
+
| `serve` | Serve the production build with SSR and gzip compression |
|
|
268
|
+
| `add` | Add an integration (e.g., `tailwind`) |
|
|
269
|
+
|
|
270
|
+
### Default Ports
|
|
271
|
+
|
|
272
|
+
| Mode | Default |
|
|
273
|
+
|---|---|
|
|
274
|
+
| `dev` | 3000 |
|
|
275
|
+
| `serve` | 3000 |
|
|
276
|
+
|
|
277
|
+
## Production Build
|
|
278
|
+
|
|
279
|
+
```bash
|
|
280
|
+
npx lumenjs build --project ./my-app
|
|
281
|
+
npx lumenjs serve --project ./my-app --port 8080
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
The build outputs to `.lumenjs/`:
|
|
285
|
+
|
|
286
|
+
```
|
|
287
|
+
.lumenjs/
|
|
288
|
+
├── client/ # Static assets (HTML, JS, CSS)
|
|
289
|
+
├── server/ # Server modules (loaders, API routes, SSR runtime)
|
|
290
|
+
└── manifest.json # Route manifest
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
The production server includes gzip compression and serves pre-built assets while executing loaders and API routes on demand.
|
|
294
|
+
|
|
295
|
+
## License
|
|
296
|
+
|
|
297
|
+
MIT
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { build as viteBuild } from 'vite';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { getSharedViteConfig } from '../dev-server/server.js';
|
|
5
|
+
import { readProjectConfig } from '../dev-server/config.js';
|
|
6
|
+
import { generateIndexHtml } from '../dev-server/index-html.js';
|
|
7
|
+
import { scanPages, scanLayouts, scanApiRoutes, getLayoutDirsForPage } from './scan.js';
|
|
8
|
+
export async function buildProject(options) {
|
|
9
|
+
const { projectDir } = options;
|
|
10
|
+
const outDir = options.outDir || path.join(projectDir, '.lumenjs');
|
|
11
|
+
const clientDir = path.join(outDir, 'client');
|
|
12
|
+
const serverDir = path.join(outDir, 'server');
|
|
13
|
+
const pagesDir = path.join(projectDir, 'pages');
|
|
14
|
+
const apiDir = path.join(projectDir, 'api');
|
|
15
|
+
const publicDir = path.join(projectDir, 'public');
|
|
16
|
+
// Clean output directory
|
|
17
|
+
if (fs.existsSync(outDir)) {
|
|
18
|
+
fs.rmSync(outDir, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
21
|
+
const { title, integrations } = readProjectConfig(projectDir);
|
|
22
|
+
const shared = getSharedViteConfig(projectDir, { mode: 'production', integrations });
|
|
23
|
+
// Scan pages, layouts, and API routes for the manifest
|
|
24
|
+
const pageEntries = scanPages(pagesDir);
|
|
25
|
+
const layoutEntries = scanLayouts(pagesDir);
|
|
26
|
+
const apiEntries = scanApiRoutes(apiDir);
|
|
27
|
+
// --- Client build ---
|
|
28
|
+
console.log('[LumenJS] Building client bundle...');
|
|
29
|
+
// Generate index.html as build entry
|
|
30
|
+
const indexHtml = generateIndexHtml({ title, editorMode: false, integrations });
|
|
31
|
+
const tempIndexPath = path.join(projectDir, '__nk_build_index.html');
|
|
32
|
+
fs.writeFileSync(tempIndexPath, indexHtml);
|
|
33
|
+
try {
|
|
34
|
+
await viteBuild({
|
|
35
|
+
root: projectDir,
|
|
36
|
+
publicDir: fs.existsSync(publicDir) ? publicDir : undefined,
|
|
37
|
+
resolve: shared.resolve,
|
|
38
|
+
plugins: shared.plugins,
|
|
39
|
+
esbuild: shared.esbuild,
|
|
40
|
+
build: {
|
|
41
|
+
outDir: clientDir,
|
|
42
|
+
emptyOutDir: true,
|
|
43
|
+
rollupOptions: {
|
|
44
|
+
input: tempIndexPath,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
logLevel: 'warn',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
// Clean up temp file
|
|
52
|
+
if (fs.existsSync(tempIndexPath)) {
|
|
53
|
+
fs.unlinkSync(tempIndexPath);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Rename the built HTML file from __nk_build_index.html to index.html
|
|
57
|
+
const builtHtmlPath = path.join(clientDir, '__nk_build_index.html');
|
|
58
|
+
const finalHtmlPath = path.join(clientDir, 'index.html');
|
|
59
|
+
if (fs.existsSync(builtHtmlPath)) {
|
|
60
|
+
fs.renameSync(builtHtmlPath, finalHtmlPath);
|
|
61
|
+
}
|
|
62
|
+
// --- Server build ---
|
|
63
|
+
console.log('[LumenJS] Building server bundle...');
|
|
64
|
+
// Collect server entry points (pages with loaders + layouts with loaders + API routes)
|
|
65
|
+
const serverEntries = {};
|
|
66
|
+
for (const entry of pageEntries) {
|
|
67
|
+
if (entry.hasLoader) {
|
|
68
|
+
serverEntries[`pages/${entry.name}`] = entry.filePath;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const entry of layoutEntries) {
|
|
72
|
+
if (entry.hasLoader) {
|
|
73
|
+
const entryName = entry.dir ? `layouts/${entry.dir}/_layout` : 'layouts/_layout';
|
|
74
|
+
serverEntries[entryName] = entry.filePath;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const entry of apiEntries) {
|
|
78
|
+
serverEntries[`api/${entry.name}`] = entry.filePath;
|
|
79
|
+
}
|
|
80
|
+
// Create SSR runtime entry — bundles @lit-labs/ssr alongside Lit so all
|
|
81
|
+
// server modules share one Lit instance (avoids _$EM mismatches).
|
|
82
|
+
const ssrEntryPath = path.join(projectDir, '__nk_ssr_entry.js');
|
|
83
|
+
const hasPageLoaders = pageEntries.some(e => e.hasLoader);
|
|
84
|
+
const hasLayoutLoaders = layoutEntries.some(e => e.hasLoader);
|
|
85
|
+
if (hasPageLoaders || hasLayoutLoaders) {
|
|
86
|
+
fs.writeFileSync(ssrEntryPath, [
|
|
87
|
+
"import '@lit-labs/ssr/lib/install-global-dom-shim.js';",
|
|
88
|
+
"export { render } from '@lit-labs/ssr';",
|
|
89
|
+
"export { html, unsafeStatic } from 'lit/static-html.js';",
|
|
90
|
+
].join('\n'));
|
|
91
|
+
serverEntries['ssr-runtime'] = ssrEntryPath;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
if (Object.keys(serverEntries).length > 0) {
|
|
95
|
+
await viteBuild({
|
|
96
|
+
root: projectDir,
|
|
97
|
+
resolve: shared.resolve,
|
|
98
|
+
plugins: shared.plugins,
|
|
99
|
+
esbuild: shared.esbuild,
|
|
100
|
+
build: {
|
|
101
|
+
outDir: serverDir,
|
|
102
|
+
emptyOutDir: true,
|
|
103
|
+
ssr: true,
|
|
104
|
+
rollupOptions: {
|
|
105
|
+
input: serverEntries,
|
|
106
|
+
output: {
|
|
107
|
+
format: 'esm',
|
|
108
|
+
entryFileNames: '[name].js',
|
|
109
|
+
chunkFileNames: 'assets/[name]-[hash].js',
|
|
110
|
+
manualChunks(id) {
|
|
111
|
+
// Force all Lit packages into a single shared chunk so SSR runtime
|
|
112
|
+
// and page modules use the exact same Lit class instances.
|
|
113
|
+
if (id.includes('/node_modules/lit/') ||
|
|
114
|
+
id.includes('/node_modules/lit-html/') ||
|
|
115
|
+
id.includes('/node_modules/lit-element/') ||
|
|
116
|
+
id.includes('/node_modules/@lit/reactive-element/')) {
|
|
117
|
+
return 'lit-shared';
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
external: [
|
|
122
|
+
/^node:/,
|
|
123
|
+
'os', 'fs', 'path', 'url', 'util', 'crypto', 'http', 'https', 'net',
|
|
124
|
+
'stream', 'zlib', 'events', 'buffer', 'querystring', 'child_process',
|
|
125
|
+
'worker_threads', 'cluster', 'dns', 'tls', 'assert', 'constants',
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
logLevel: 'warn',
|
|
130
|
+
ssr: {
|
|
131
|
+
noExternal: true,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
fs.mkdirSync(serverDir, { recursive: true });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
if (fs.existsSync(ssrEntryPath)) {
|
|
141
|
+
fs.unlinkSync(ssrEntryPath);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// --- Write manifest ---
|
|
145
|
+
const manifest = {
|
|
146
|
+
routes: pageEntries.map(e => {
|
|
147
|
+
const routeLayouts = getLayoutDirsForPage(e.filePath, pagesDir, layoutEntries);
|
|
148
|
+
return {
|
|
149
|
+
path: e.routePath,
|
|
150
|
+
module: e.hasLoader ? `pages/${e.name}.js` : '',
|
|
151
|
+
hasLoader: e.hasLoader,
|
|
152
|
+
...(routeLayouts.length > 0 ? { layouts: routeLayouts } : {}),
|
|
153
|
+
};
|
|
154
|
+
}),
|
|
155
|
+
apiRoutes: apiEntries.map(e => ({
|
|
156
|
+
path: `/api/${e.routePath}`,
|
|
157
|
+
module: `api/${e.name}.js`,
|
|
158
|
+
hasLoader: false,
|
|
159
|
+
})),
|
|
160
|
+
layouts: layoutEntries.map(e => ({
|
|
161
|
+
dir: e.dir,
|
|
162
|
+
module: e.hasLoader ? (e.dir ? `layouts/${e.dir}/_layout.js` : 'layouts/_layout.js') : '',
|
|
163
|
+
hasLoader: e.hasLoader,
|
|
164
|
+
})),
|
|
165
|
+
};
|
|
166
|
+
fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
167
|
+
console.log('[LumenJS] Build complete.');
|
|
168
|
+
console.log(` Output: ${outDir}`);
|
|
169
|
+
console.log(` Client assets: ${clientDir}`);
|
|
170
|
+
console.log(` Server modules: ${serverDir}`);
|
|
171
|
+
console.log(` Routes: ${pageEntries.length} pages, ${apiEntries.length} API routes, ${layoutEntries.length} layouts`);
|
|
172
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function renderErrorPage(status: number, title: string, message: string, detail?: string): string;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { escapeHtml } from '../shared/utils.js';
|
|
2
|
+
export function renderErrorPage(status, title, message, detail) {
|
|
3
|
+
const gradients = {
|
|
4
|
+
404: 'linear-gradient(135deg, #6366f1, #8b5cf6, #a855f7)',
|
|
5
|
+
500: 'linear-gradient(135deg, #ef4444, #f97316, #f59e0b)',
|
|
6
|
+
502: 'linear-gradient(135deg, #f97316, #ef4444)',
|
|
7
|
+
503: 'linear-gradient(135deg, #64748b, #475569)',
|
|
8
|
+
};
|
|
9
|
+
const gradient = gradients[status] || gradients[500];
|
|
10
|
+
const detailBlock = detail
|
|
11
|
+
? `<div style="margin-top:1.5rem;padding:.75rem 1rem;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;text-align:left">
|
|
12
|
+
<div style="font-size:.6875rem;font-weight:600;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.375rem">Details</div>
|
|
13
|
+
<pre style="margin:0;font-size:.75rem;color:#64748b;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,monospace">${escapeHtml(detail)}</pre>
|
|
14
|
+
</div>`
|
|
15
|
+
: '';
|
|
16
|
+
return `<!DOCTYPE html>
|
|
17
|
+
<html lang="en">
|
|
18
|
+
<head>
|
|
19
|
+
<meta charset="UTF-8">
|
|
20
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
21
|
+
<title>${status} — ${escapeHtml(title)}</title>
|
|
22
|
+
<style>
|
|
23
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
24
|
+
body {
|
|
25
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
26
|
+
min-height: 100vh;
|
|
27
|
+
display: flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
justify-content: center;
|
|
30
|
+
background: #fafbfc;
|
|
31
|
+
padding: 2rem;
|
|
32
|
+
}
|
|
33
|
+
.container { text-align: center; max-width: 440px; }
|
|
34
|
+
.status {
|
|
35
|
+
font-size: 5rem;
|
|
36
|
+
font-weight: 200;
|
|
37
|
+
letter-spacing: -2px;
|
|
38
|
+
line-height: 1;
|
|
39
|
+
color: #cbd5e1;
|
|
40
|
+
user-select: none;
|
|
41
|
+
}
|
|
42
|
+
h1 { font-size: 1rem; font-weight: 500; color: #334155; margin: 1.25rem 0 .5rem; }
|
|
43
|
+
.message { color: #94a3b8; font-size: .8125rem; line-height: 1.5; margin-bottom: 2rem; }
|
|
44
|
+
.btn {
|
|
45
|
+
display: inline-flex; align-items: center; gap: .375rem;
|
|
46
|
+
padding: .4375rem 1rem;
|
|
47
|
+
background: #f8fafc; color: #475569;
|
|
48
|
+
border: 1px solid #e2e8f0;
|
|
49
|
+
border-radius: 6px; font-size: .8125rem; font-weight: 400;
|
|
50
|
+
text-decoration: none; transition: all .15s;
|
|
51
|
+
cursor: pointer;
|
|
52
|
+
}
|
|
53
|
+
.btn:hover { background: #f1f5f9; border-color: #cbd5e1; }
|
|
54
|
+
.btn svg { flex-shrink: 0; }
|
|
55
|
+
.divider { width: 32px; height: 2px; background: #e2e8f0; border-radius: 1px; margin: 1.25rem auto; }
|
|
56
|
+
.footer { margin-top: 3rem; font-size: .6875rem; color: #e2e8f0; }
|
|
57
|
+
</style>
|
|
58
|
+
</head>
|
|
59
|
+
<body>
|
|
60
|
+
<div class="container">
|
|
61
|
+
<div class="status">${status}</div>
|
|
62
|
+
<div class="divider"></div>
|
|
63
|
+
<h1>${escapeHtml(title)}</h1>
|
|
64
|
+
<p class="message">${escapeHtml(message)}</p>
|
|
65
|
+
<a href="/" class="btn">
|
|
66
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
|
|
67
|
+
Back to home
|
|
68
|
+
</a>
|
|
69
|
+
${detailBlock}
|
|
70
|
+
<div class="footer">LumenJS</div>
|
|
71
|
+
</div>
|
|
72
|
+
</body>
|
|
73
|
+
</html>`;
|
|
74
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface PageEntry {
|
|
2
|
+
name: string;
|
|
3
|
+
filePath: string;
|
|
4
|
+
routePath: string;
|
|
5
|
+
hasLoader: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface LayoutEntry {
|
|
8
|
+
dir: string;
|
|
9
|
+
filePath: string;
|
|
10
|
+
hasLoader: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface ApiEntry {
|
|
13
|
+
name: string;
|
|
14
|
+
filePath: string;
|
|
15
|
+
routePath: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function scanPages(pagesDir: string): PageEntry[];
|
|
18
|
+
export declare function scanLayouts(pagesDir: string): LayoutEntry[];
|
|
19
|
+
export declare function scanApiRoutes(apiDir: string): ApiEntry[];
|
|
20
|
+
/** Get the layout directory chain for a given page file */
|
|
21
|
+
export declare function getLayoutDirsForPage(pageFilePath: string, pagesDir: string, layouts: LayoutEntry[]): string[];
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileHasLoader, filePathToRoute } from '../shared/utils.js';
|
|
4
|
+
export function scanPages(pagesDir) {
|
|
5
|
+
if (!fs.existsSync(pagesDir))
|
|
6
|
+
return [];
|
|
7
|
+
const entries = [];
|
|
8
|
+
walkDir(pagesDir, '', entries, pagesDir);
|
|
9
|
+
return entries;
|
|
10
|
+
}
|
|
11
|
+
export function scanLayouts(pagesDir) {
|
|
12
|
+
if (!fs.existsSync(pagesDir))
|
|
13
|
+
return [];
|
|
14
|
+
const entries = [];
|
|
15
|
+
walkForLayouts(pagesDir, '', entries);
|
|
16
|
+
return entries;
|
|
17
|
+
}
|
|
18
|
+
export function scanApiRoutes(apiDir) {
|
|
19
|
+
if (!fs.existsSync(apiDir))
|
|
20
|
+
return [];
|
|
21
|
+
const entries = [];
|
|
22
|
+
walkApiDir(apiDir, '', entries, apiDir);
|
|
23
|
+
return entries;
|
|
24
|
+
}
|
|
25
|
+
/** Get the layout directory chain for a given page file */
|
|
26
|
+
export function getLayoutDirsForPage(pageFilePath, pagesDir, layouts) {
|
|
27
|
+
const relativeToPages = path.relative(pagesDir, pageFilePath).replace(/\\/g, '/');
|
|
28
|
+
const dirParts = path.dirname(relativeToPages).split('/').filter(p => p && p !== '.');
|
|
29
|
+
const chain = [];
|
|
30
|
+
// Check root layout
|
|
31
|
+
if (layouts.some(l => l.dir === '')) {
|
|
32
|
+
chain.push('');
|
|
33
|
+
}
|
|
34
|
+
// Check each directory level
|
|
35
|
+
let currentDir = '';
|
|
36
|
+
for (const part of dirParts) {
|
|
37
|
+
currentDir = currentDir ? `${currentDir}/${part}` : part;
|
|
38
|
+
if (layouts.some(l => l.dir === currentDir)) {
|
|
39
|
+
chain.push(currentDir);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return chain;
|
|
43
|
+
}
|
|
44
|
+
function walkDir(baseDir, relativePath, entries, pagesDir) {
|
|
45
|
+
const fullDir = path.join(baseDir, relativePath);
|
|
46
|
+
const dirEntries = fs.readdirSync(fullDir, { withFileTypes: true });
|
|
47
|
+
for (const entry of dirEntries) {
|
|
48
|
+
const entryRelative = path.join(relativePath, entry.name);
|
|
49
|
+
if (entry.isDirectory()) {
|
|
50
|
+
walkDir(baseDir, entryRelative, entries, pagesDir);
|
|
51
|
+
}
|
|
52
|
+
else if (entry.isFile() && /\.(ts|js)$/.test(entry.name) && !entry.name.startsWith('_')) {
|
|
53
|
+
const filePath = path.join(pagesDir, entryRelative);
|
|
54
|
+
const name = entryRelative.replace(/\.(ts|js)$/, '').replace(/\\/g, '/');
|
|
55
|
+
const routePath = filePathToRoute(entryRelative);
|
|
56
|
+
const hasLoader = fileHasLoader(filePath);
|
|
57
|
+
entries.push({ name, filePath, routePath, hasLoader });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function walkForLayouts(baseDir, relativePath, entries) {
|
|
62
|
+
const fullDir = path.join(baseDir, relativePath);
|
|
63
|
+
const dirEntries = fs.readdirSync(fullDir, { withFileTypes: true });
|
|
64
|
+
for (const entry of dirEntries) {
|
|
65
|
+
if (entry.isFile() && /^_layout\.(ts|js)$/.test(entry.name)) {
|
|
66
|
+
const filePath = path.join(fullDir, entry.name);
|
|
67
|
+
const dir = relativePath.replace(/\\/g, '/');
|
|
68
|
+
entries.push({ dir, filePath, hasLoader: fileHasLoader(filePath) });
|
|
69
|
+
}
|
|
70
|
+
if (entry.isDirectory()) {
|
|
71
|
+
walkForLayouts(baseDir, path.join(relativePath, entry.name), entries);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function walkApiDir(baseDir, relativePath, entries, apiDir) {
|
|
76
|
+
const fullDir = path.join(baseDir, relativePath);
|
|
77
|
+
const dirEntries = fs.readdirSync(fullDir, { withFileTypes: true });
|
|
78
|
+
for (const entry of dirEntries) {
|
|
79
|
+
const entryRelative = path.join(relativePath, entry.name);
|
|
80
|
+
if (entry.isDirectory()) {
|
|
81
|
+
walkApiDir(baseDir, entryRelative, entries, apiDir);
|
|
82
|
+
}
|
|
83
|
+
else if (entry.isFile() && /\.(ts|js)$/.test(entry.name) && !entry.name.startsWith('_')) {
|
|
84
|
+
const filePath = path.join(apiDir, entryRelative);
|
|
85
|
+
const name = entryRelative.replace(/\.(ts|js)$/, '').replace(/\\/g, '/');
|
|
86
|
+
const routePath = entryRelative
|
|
87
|
+
.replace(/\.(ts|js)$/, '')
|
|
88
|
+
.replace(/\\/g, '/')
|
|
89
|
+
.replace(/\[([^\]]+)\]/g, ':$1');
|
|
90
|
+
entries.push({ name, filePath, routePath });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import type { BuildManifest } from '../shared/types.js';
|
|
3
|
+
export declare function handleApiRoute(manifest: BuildManifest, serverDir: string, pathname: string, queryString: string | undefined, method: string, req: http.IncomingMessage, res: http.ServerResponse): Promise<void>;
|