@constela/start 1.0.0 → 1.2.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.
@@ -22,6 +22,14 @@ interface EscapeHandler {
22
22
  name: string;
23
23
  mount: (element: HTMLElement, ctx: EscapeContext) => () => void;
24
24
  }
25
+ /**
26
+ * Route context for the application
27
+ */
28
+ interface RouteContext {
29
+ params: Record<string, string>;
30
+ query: Record<string, string>;
31
+ path: string;
32
+ }
25
33
  /**
26
34
  * Options for initializing the client application
27
35
  */
@@ -29,6 +37,7 @@ interface InitClientOptions {
29
37
  program: CompiledProgram;
30
38
  container: HTMLElement;
31
39
  escapeHandlers?: EscapeHandler[];
40
+ route?: RouteContext;
32
41
  }
33
42
  /**
34
43
  * Initialize the client application with hydration and escape hatch support.
@@ -38,4 +47,4 @@ interface InitClientOptions {
38
47
  */
39
48
  declare function initClient(options: InitClientOptions): AppInstance;
40
49
 
41
- export { type EscapeContext, type EscapeHandler, type InitClientOptions, initClient };
50
+ export { type EscapeContext, type EscapeHandler, type InitClientOptions, type RouteContext, initClient };
@@ -1,8 +1,8 @@
1
1
  // src/runtime/entry-client.ts
2
2
  import { hydrateApp } from "@constela/runtime";
3
3
  function initClient(options) {
4
- const { program, container, escapeHandlers = [] } = options;
5
- const appInstance = hydrateApp({ program, container });
4
+ const { program, container, escapeHandlers = [], route } = options;
5
+ const appInstance = hydrateApp({ program, container, ...route && { route } });
6
6
  const escapeElements = container.querySelectorAll(
7
7
  "[data-constela-escape]"
8
8
  );
@@ -35,6 +35,23 @@ function initClient(options) {
35
35
  } catch {
36
36
  }
37
37
  }
38
+ if (program.state?.["theme"]) {
39
+ const updateThemeClass = (value) => {
40
+ if (value === "dark") {
41
+ document.documentElement.classList.add("dark");
42
+ } else {
43
+ document.documentElement.classList.remove("dark");
44
+ }
45
+ };
46
+ const currentTheme = appInstance.getState?.("theme");
47
+ if (currentTheme) {
48
+ updateThemeClass(currentTheme);
49
+ }
50
+ if (appInstance.subscribe) {
51
+ const unsubscribeTheme = appInstance.subscribe("theme", updateThemeClass);
52
+ cleanupFns.push(unsubscribeTheme);
53
+ }
54
+ }
38
55
  let destroyed = false;
39
56
  return {
40
57
  destroy() {
@@ -10,14 +10,35 @@ interface SSRContext {
10
10
  params: Record<string, string>;
11
11
  query: URLSearchParams;
12
12
  }
13
+ interface WrapHtmlOptions {
14
+ theme?: 'dark' | 'light';
15
+ /** Import map entries for resolving bare module specifiers */
16
+ importMap?: Record<string, string>;
17
+ /** Path to bundled runtime for production builds. When provided, replaces @constela/runtime imports and excludes importmap. */
18
+ runtimePath?: string;
19
+ }
20
+ interface WidgetConfig {
21
+ /** The DOM element ID where the widget should be mounted */
22
+ id: string;
23
+ /** The compiled program for the widget */
24
+ program: CompiledProgram;
25
+ }
13
26
  /**
14
27
  * Renders a CompiledProgram to HTML string using @constela/server's renderToString.
15
28
  *
16
29
  * @param program - The compiled program to render
17
- * @param _ctx - SSR context (reserved for future use)
30
+ * @param ctx - SSR context including route params
18
31
  * @returns Promise that resolves to HTML string
19
32
  */
20
- declare function renderPage(program: CompiledProgram, _ctx: SSRContext): Promise<string>;
33
+ declare function renderPage(program: CompiledProgram, ctx: SSRContext): Promise<string>;
34
+ /**
35
+ * Route context for hydration
36
+ */
37
+ interface HydrationRouteContext {
38
+ params: Record<string, string>;
39
+ query: Record<string, string>;
40
+ path: string;
41
+ }
21
42
  /**
22
43
  * Generates a hydration script for client-side initialization.
23
44
  *
@@ -25,11 +46,14 @@ declare function renderPage(program: CompiledProgram, _ctx: SSRContext): Promise
25
46
  * - Imports hydrateApp from @constela/runtime
26
47
  * - Serializes the program data
27
48
  * - Calls hydrateApp with the program and container element
49
+ * - Optionally mounts widgets using createApp
28
50
  *
29
51
  * @param program - The compiled program to hydrate
52
+ * @param widgets - Optional array of widget configurations to mount after hydration
53
+ * @param route - Optional route context for dynamic routes
30
54
  * @returns JavaScript module code as string
31
55
  */
32
- declare function generateHydrationScript(program: CompiledProgram): string;
56
+ declare function generateHydrationScript(program: CompiledProgram, widgets?: WidgetConfig[], route?: HydrationRouteContext): string;
33
57
  /**
34
58
  * Wraps rendered content in a complete HTML document.
35
59
  *
@@ -44,8 +68,9 @@ declare function generateHydrationScript(program: CompiledProgram): string;
44
68
  * @param content - The rendered HTML content
45
69
  * @param hydrationScript - The hydration script code
46
70
  * @param head - Optional additional head content
71
+ * @param options - Optional configuration including theme
47
72
  * @returns Complete HTML document string
48
73
  */
49
- declare function wrapHtml(content: string, hydrationScript: string, head?: string): string;
74
+ declare function wrapHtml(content: string, hydrationScript: string, head?: string, options?: WrapHtmlOptions): string;
50
75
 
51
- export { type SSRContext, generateHydrationScript, renderPage, wrapHtml };
76
+ export { type HydrationRouteContext, type SSRContext, type WidgetConfig, type WrapHtmlOptions, generateHydrationScript, renderPage, wrapHtml };
@@ -2,7 +2,7 @@ import {
2
2
  generateHydrationScript,
3
3
  renderPage,
4
4
  wrapHtml
5
- } from "../chunk-QLDID7EZ.js";
5
+ } from "../chunk-PUTC5BCP.js";
6
6
  export {
7
7
  generateHydrationScript,
8
8
  renderPage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constela/start",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Meta-framework for Constela applications",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -31,6 +31,7 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "commander": "^12.0.0",
34
+ "esbuild": "^0.24.0",
34
35
  "fast-glob": "^3.3.0",
35
36
  "gray-matter": "^4.0.0",
36
37
  "hono": "^4.0.0",
@@ -39,11 +40,11 @@
39
40
  "remark-parse": "^11.0.0",
40
41
  "unified": "^11.0.0",
41
42
  "vite": "^6.0.0",
42
- "@constela/compiler": "0.6.1",
43
+ "@constela/compiler": "0.7.0",
44
+ "@constela/server": "3.0.0",
45
+ "@constela/router": "8.0.0",
43
46
  "@constela/core": "0.7.0",
44
- "@constela/router": "6.0.0",
45
- "@constela/server": "2.0.0",
46
- "@constela/runtime": "0.9.1"
47
+ "@constela/runtime": "0.10.0"
47
48
  },
48
49
  "devDependencies": {
49
50
  "@types/mdast": "^4.0.4",
@@ -1,399 +0,0 @@
1
- // src/router/file-router.ts
2
- import fg from "fast-glob";
3
- import { existsSync, statSync } from "fs";
4
- import { join } from "path";
5
- function filePathToPattern(filePath, _routesDir) {
6
- let normalized = filePath.replace(/\\/g, "/");
7
- normalized = normalized.replace(/\.(ts|tsx|js|jsx)$/, "");
8
- const segments = normalized.split("/");
9
- const processedSegments = segments.map((segment) => {
10
- if (segment.startsWith("[...") && segment.endsWith("]")) {
11
- return "*";
12
- }
13
- if (segment.startsWith("[") && segment.endsWith("]")) {
14
- const paramName = segment.slice(1, -1);
15
- return `:${paramName}`;
16
- }
17
- return segment;
18
- });
19
- if (processedSegments.at(-1) === "index") {
20
- processedSegments.pop();
21
- }
22
- const path = "/" + processedSegments.join("/");
23
- if (path === "/") {
24
- return "/";
25
- }
26
- return path.endsWith("/") ? path.slice(0, -1) : path;
27
- }
28
- function extractParams(filePath) {
29
- const params = [];
30
- const normalized = filePath.replace(/\\/g, "/");
31
- const regex = /\[(?:\.\.\.)?([^\]]+)\]/g;
32
- let match;
33
- while ((match = regex.exec(normalized)) !== null) {
34
- const paramName = match[1];
35
- if (paramName !== void 0) {
36
- params.push(paramName);
37
- }
38
- }
39
- return params;
40
- }
41
- function determineRouteType(filePath) {
42
- const normalized = filePath.replace(/\\/g, "/");
43
- const fileName = normalized.split("/").pop() ?? "";
44
- if (fileName.startsWith("_middleware.")) {
45
- return "middleware";
46
- }
47
- if (normalized.startsWith("api/") || normalized.includes("/api/")) {
48
- return "api";
49
- }
50
- return "page";
51
- }
52
- function shouldIncludeRoute(filePath) {
53
- const normalized = filePath.replace(/\\/g, "/");
54
- const fileName = normalized.split("/").pop() ?? "";
55
- if (fileName.endsWith(".d.ts")) {
56
- return false;
57
- }
58
- if (fileName.startsWith("_") && !fileName.startsWith("_middleware.")) {
59
- return false;
60
- }
61
- return true;
62
- }
63
- async function scanRoutes(routesDir) {
64
- if (!existsSync(routesDir)) {
65
- throw new Error(`Routes directory does not exist: ${routesDir}`);
66
- }
67
- const stats = statSync(routesDir);
68
- if (!stats.isDirectory()) {
69
- throw new Error(`Routes path is not a directory: ${routesDir}`);
70
- }
71
- const files = await fg("**/*.{ts,tsx,js,jsx}", {
72
- cwd: routesDir,
73
- ignore: ["node_modules/**", "**/*.d.ts"],
74
- onlyFiles: true,
75
- followSymbolicLinks: false
76
- });
77
- const routes = [];
78
- for (const filePath of files) {
79
- if (!shouldIncludeRoute(filePath)) {
80
- continue;
81
- }
82
- const route = {
83
- file: join(routesDir, filePath),
84
- pattern: filePathToPattern(filePath, routesDir),
85
- type: determineRouteType(filePath),
86
- params: extractParams(filePath)
87
- };
88
- routes.push(route);
89
- }
90
- routes.sort((a, b) => {
91
- return compareRoutes(a, b);
92
- });
93
- return routes;
94
- }
95
- function compareRoutes(a, b) {
96
- if (a.type === "middleware" && b.type !== "middleware") return -1;
97
- if (b.type === "middleware" && a.type !== "middleware") return 1;
98
- const segmentsA = a.pattern.split("/").filter(Boolean);
99
- const segmentsB = b.pattern.split("/").filter(Boolean);
100
- const minLen = Math.min(segmentsA.length, segmentsB.length);
101
- for (let i = 0; i < minLen; i++) {
102
- const segA = segmentsA[i] ?? "";
103
- const segB = segmentsB[i] ?? "";
104
- const typeA = getSegmentType(segA);
105
- const typeB = getSegmentType(segB);
106
- if (typeA !== typeB) {
107
- return typeA - typeB;
108
- }
109
- if (typeA === 0 && segA !== segB) {
110
- return segA.localeCompare(segB);
111
- }
112
- }
113
- if (segmentsA.length !== segmentsB.length) {
114
- return segmentsA.length - segmentsB.length;
115
- }
116
- return a.pattern.localeCompare(b.pattern);
117
- }
118
- function getSegmentType(segment) {
119
- if (segment === "*") return 2;
120
- if (segment.startsWith(":")) return 1;
121
- return 0;
122
- }
123
-
124
- // src/static/index.ts
125
- import { extname, join as join2, normalize, resolve } from "path";
126
- import { existsSync as existsSync2, statSync as statSync2 } from "fs";
127
- var MIME_TYPES = {
128
- // Images
129
- ".ico": "image/x-icon",
130
- ".png": "image/png",
131
- ".jpg": "image/jpeg",
132
- ".jpeg": "image/jpeg",
133
- ".gif": "image/gif",
134
- ".svg": "image/svg+xml",
135
- ".webp": "image/webp",
136
- // Fonts
137
- ".woff": "font/woff",
138
- ".woff2": "font/woff2",
139
- ".ttf": "font/ttf",
140
- ".otf": "font/otf",
141
- ".eot": "application/vnd.ms-fontobject",
142
- // Web assets
143
- ".css": "text/css",
144
- ".js": "text/javascript",
145
- ".json": "application/json",
146
- ".txt": "text/plain",
147
- ".html": "text/html",
148
- ".xml": "application/xml",
149
- // Other
150
- ".pdf": "application/pdf",
151
- ".mp3": "audio/mpeg",
152
- ".mp4": "video/mp4",
153
- ".webm": "video/webm",
154
- ".map": "application/json"
155
- };
156
- var DEFAULT_MIME_TYPE = "application/octet-stream";
157
- function isPathSafe(pathname) {
158
- if (!pathname) {
159
- return false;
160
- }
161
- if (!pathname.startsWith("/")) {
162
- return false;
163
- }
164
- if (pathname.includes("\\")) {
165
- return false;
166
- }
167
- if (pathname.includes("//")) {
168
- return false;
169
- }
170
- if (pathname.includes("\0") || pathname.includes("%00")) {
171
- return false;
172
- }
173
- let decoded;
174
- try {
175
- decoded = pathname;
176
- let prevDecoded = "";
177
- while (decoded !== prevDecoded) {
178
- prevDecoded = decoded;
179
- decoded = decodeURIComponent(decoded);
180
- }
181
- } catch {
182
- return false;
183
- }
184
- if (decoded.includes("\0")) {
185
- return false;
186
- }
187
- if (decoded.includes("..")) {
188
- return false;
189
- }
190
- const segments = decoded.split("/").filter(Boolean);
191
- for (const segment of segments) {
192
- if (segment.startsWith(".")) {
193
- return false;
194
- }
195
- }
196
- return true;
197
- }
198
- function getMimeType(filePath) {
199
- const ext = extname(filePath).toLowerCase();
200
- return MIME_TYPES[ext] ?? DEFAULT_MIME_TYPE;
201
- }
202
- function hasObviousAttackPattern(pathname) {
203
- if (!pathname) {
204
- return true;
205
- }
206
- if (!pathname.startsWith("/")) {
207
- return true;
208
- }
209
- if (pathname.includes("\\")) {
210
- return true;
211
- }
212
- if (pathname.includes("//")) {
213
- return true;
214
- }
215
- if (pathname.includes("\0") || pathname.includes("%00")) {
216
- return true;
217
- }
218
- let decoded;
219
- try {
220
- decoded = pathname;
221
- let prevDecoded = "";
222
- while (decoded !== prevDecoded) {
223
- prevDecoded = decoded;
224
- decoded = decodeURIComponent(decoded);
225
- }
226
- } catch {
227
- return true;
228
- }
229
- if (decoded.includes("\0")) {
230
- return true;
231
- }
232
- const segments = decoded.split("/").filter(Boolean);
233
- for (const segment of segments) {
234
- if (segment.startsWith(".") && segment !== "..") {
235
- return true;
236
- }
237
- }
238
- if (decoded.startsWith("/..")) {
239
- return true;
240
- }
241
- return false;
242
- }
243
- function resolveStaticFile(pathname, publicDir) {
244
- if (hasObviousAttackPattern(pathname)) {
245
- return {
246
- exists: false,
247
- filePath: null,
248
- mimeType: null,
249
- error: "path_traversal"
250
- };
251
- }
252
- let decodedPathname;
253
- try {
254
- decodedPathname = decodeURIComponent(pathname);
255
- } catch {
256
- return {
257
- exists: false,
258
- filePath: null,
259
- mimeType: null,
260
- error: "path_traversal"
261
- };
262
- }
263
- const relativePath = decodedPathname.slice(1);
264
- const resolvedPath = normalize(join2(publicDir, relativePath));
265
- const absolutePublicDir = resolve(publicDir);
266
- const publicDirWithSep = absolutePublicDir.endsWith("/") ? absolutePublicDir : absolutePublicDir + "/";
267
- if (!resolvedPath.startsWith(publicDirWithSep) && resolvedPath !== absolutePublicDir) {
268
- return {
269
- exists: false,
270
- filePath: null,
271
- mimeType: null,
272
- error: "outside_public"
273
- };
274
- }
275
- const mimeType = getMimeType(resolvedPath);
276
- let exists = false;
277
- if (existsSync2(resolvedPath)) {
278
- try {
279
- const stats = statSync2(resolvedPath);
280
- exists = stats.isFile();
281
- } catch {
282
- exists = false;
283
- }
284
- }
285
- return {
286
- exists,
287
- filePath: resolvedPath,
288
- mimeType
289
- };
290
- }
291
-
292
- // src/dev/server.ts
293
- import { createServer } from "http";
294
- import { createReadStream } from "fs";
295
- import { join as join3 } from "path";
296
- var DEFAULT_PORT = 3e3;
297
- var DEFAULT_HOST = "localhost";
298
- var DEFAULT_PUBLIC_DIR = "public";
299
- async function createDevServer(options = {}) {
300
- const {
301
- port = DEFAULT_PORT,
302
- host = DEFAULT_HOST,
303
- routesDir: _routesDir = "src/routes",
304
- publicDir = join3(process.cwd(), DEFAULT_PUBLIC_DIR)
305
- } = options;
306
- let httpServer = null;
307
- let actualPort = port;
308
- const devServer = {
309
- get port() {
310
- return actualPort;
311
- },
312
- async listen() {
313
- return new Promise((resolve2, reject) => {
314
- httpServer = createServer((req, res) => {
315
- const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
316
- const pathname = url.pathname;
317
- const staticResult = resolveStaticFile(pathname, publicDir);
318
- if (staticResult.error === "path_traversal" || staticResult.error === "outside_public") {
319
- res.writeHead(403, { "Content-Type": "text/plain" });
320
- res.end("Forbidden");
321
- return;
322
- }
323
- if (staticResult.exists && staticResult.filePath && staticResult.mimeType) {
324
- res.writeHead(200, { "Content-Type": staticResult.mimeType });
325
- const stream = createReadStream(staticResult.filePath);
326
- stream.pipe(res);
327
- stream.on("error", () => {
328
- if (!res.headersSent) {
329
- res.writeHead(500, { "Content-Type": "text/plain" });
330
- }
331
- res.end("Internal Server Error");
332
- });
333
- return;
334
- }
335
- if (staticResult.filePath && !staticResult.exists) {
336
- res.writeHead(404, { "Content-Type": "text/plain" });
337
- res.end("Not Found");
338
- return;
339
- }
340
- res.writeHead(200, { "Content-Type": "text/html" });
341
- res.end("<html><body>Constela Dev Server</body></html>");
342
- });
343
- httpServer.on("error", (err) => {
344
- reject(err);
345
- });
346
- httpServer.listen(port, host, () => {
347
- const address = httpServer?.address();
348
- if (address) {
349
- actualPort = address.port;
350
- }
351
- resolve2();
352
- });
353
- });
354
- },
355
- async close() {
356
- return new Promise((resolve2, reject) => {
357
- if (!httpServer) {
358
- resolve2();
359
- return;
360
- }
361
- httpServer.close((err) => {
362
- if (err) {
363
- reject(err);
364
- } else {
365
- httpServer = null;
366
- resolve2();
367
- }
368
- });
369
- });
370
- }
371
- };
372
- return devServer;
373
- }
374
-
375
- // src/build/index.ts
376
- async function build(options) {
377
- const outDir = options?.outDir ?? "dist";
378
- const routesDir = options?.routesDir ?? "src/routes";
379
- let routes = [];
380
- try {
381
- const scannedRoutes = await scanRoutes(routesDir);
382
- routes = scannedRoutes.map((r) => r.pattern);
383
- } catch {
384
- }
385
- return {
386
- outDir,
387
- routes
388
- };
389
- }
390
-
391
- export {
392
- filePathToPattern,
393
- scanRoutes,
394
- isPathSafe,
395
- getMimeType,
396
- resolveStaticFile,
397
- createDevServer,
398
- build
399
- };
@@ -1,49 +0,0 @@
1
- // src/runtime/entry-server.ts
2
- import { renderToString } from "@constela/server";
3
- async function renderPage(program, _ctx) {
4
- return await renderToString(program);
5
- }
6
- function escapeJsonForScript(json) {
7
- return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
8
- }
9
- function serializeProgram(program) {
10
- const serializable = {
11
- ...program,
12
- // Convert Map to Object if actions is a Map
13
- actions: program.actions instanceof Map ? Object.fromEntries(program.actions.entries()) : program.actions
14
- };
15
- return JSON.stringify(serializable);
16
- }
17
- function generateHydrationScript(program) {
18
- const serializedProgram = escapeJsonForScript(serializeProgram(program));
19
- return `import { hydrateApp } from '@constela/runtime';
20
-
21
- const program = ${serializedProgram};
22
-
23
- hydrateApp({
24
- program,
25
- container: document.getElementById('app')
26
- });`;
27
- }
28
- function wrapHtml(content, hydrationScript, head) {
29
- return `<!DOCTYPE html>
30
- <html>
31
- <head>
32
- <meta charset="utf-8">
33
- <meta name="viewport" content="width=device-width, initial-scale=1">
34
- ${head ?? ""}
35
- </head>
36
- <body>
37
- <div id="app">${content}</div>
38
- <script type="module">
39
- ${hydrationScript}
40
- </script>
41
- </body>
42
- </html>`;
43
- }
44
-
45
- export {
46
- renderPage,
47
- generateHydrationScript,
48
- wrapHtml
49
- };