@constela/start 0.3.1 → 0.4.1
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/dist/index.d.ts +214 -3
- package/dist/index.js +484 -24
- package/package.json +6 -5
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CompiledProgram } from '@constela/compiler';
|
|
2
|
+
import { LayoutProgram, Program, StaticPathsDefinition, DataSource } from '@constela/core';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Scanned route from file system
|
|
@@ -38,11 +39,22 @@ interface MiddlewareContext {
|
|
|
38
39
|
}
|
|
39
40
|
type MiddlewareNext = () => Promise<Response>;
|
|
40
41
|
type Middleware = (ctx: MiddlewareContext, next: MiddlewareNext) => Promise<Response> | Response;
|
|
42
|
+
/**
|
|
43
|
+
* Function-form page export that receives route params.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* // pages/users/[id].ts
|
|
47
|
+
* export default async function(params: Record<string, string>) {
|
|
48
|
+
* const user = await fetchUser(params.id);
|
|
49
|
+
* return createUserPage(user);
|
|
50
|
+
* }
|
|
51
|
+
*/
|
|
52
|
+
type PageExportFunction = (params: Record<string, string>) => Promise<CompiledProgram> | CompiledProgram;
|
|
41
53
|
/**
|
|
42
54
|
* Page module with Constela program
|
|
43
55
|
*/
|
|
44
56
|
interface PageModule {
|
|
45
|
-
default: CompiledProgram;
|
|
57
|
+
default: CompiledProgram | PageExportFunction;
|
|
46
58
|
getStaticPaths?: () => Promise<StaticPathsResult> | StaticPathsResult;
|
|
47
59
|
}
|
|
48
60
|
interface StaticPathsResult {
|
|
@@ -185,14 +197,30 @@ interface BuildResult {
|
|
|
185
197
|
*/
|
|
186
198
|
declare function build(options?: BuildOptions): Promise<BuildResult>;
|
|
187
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Provider function for static paths when getStaticPaths is not available in the module.
|
|
202
|
+
* Used for dependency injection in tests.
|
|
203
|
+
*/
|
|
204
|
+
type StaticPathsProvider = (pattern: string) => StaticPathsResult | null | Promise<StaticPathsResult | null>;
|
|
205
|
+
/**
|
|
206
|
+
* Options for generateStaticPages function
|
|
207
|
+
*/
|
|
208
|
+
interface GenerateStaticPagesOptions {
|
|
209
|
+
/**
|
|
210
|
+
* Optional provider for static paths when module doesn't export getStaticPaths.
|
|
211
|
+
* Primarily used for testing purposes.
|
|
212
|
+
*/
|
|
213
|
+
staticPathsProvider?: StaticPathsProvider;
|
|
214
|
+
}
|
|
188
215
|
/**
|
|
189
216
|
* Generate static pages for SSG routes
|
|
190
217
|
*
|
|
191
218
|
* @param routes - Array of scanned routes
|
|
192
219
|
* @param outDir - Output directory for generated HTML files
|
|
220
|
+
* @param options - Optional configuration including staticPathsProvider for DI
|
|
193
221
|
* @returns Array of generated file paths
|
|
194
222
|
*/
|
|
195
|
-
declare function generateStaticPages(routes: ScannedRoute[], outDir: string): Promise<string[]>;
|
|
223
|
+
declare function generateStaticPages(routes: ScannedRoute[], outDir: string, options?: GenerateStaticPagesOptions): Promise<string[]>;
|
|
196
224
|
|
|
197
225
|
/**
|
|
198
226
|
* Create handler function from API module
|
|
@@ -227,4 +255,187 @@ interface EdgeAdapter {
|
|
|
227
255
|
*/
|
|
228
256
|
declare function createAdapter(options: AdapterOptions): EdgeAdapter;
|
|
229
257
|
|
|
230
|
-
|
|
258
|
+
/**
|
|
259
|
+
* Type guard to check if a page export is a function (dynamic) or a static program
|
|
260
|
+
*
|
|
261
|
+
* @param exported - The default export from a page module
|
|
262
|
+
* @returns true if the export is a function, false if it's a static CompiledProgram
|
|
263
|
+
*/
|
|
264
|
+
declare function isPageExportFunction(exported: CompiledProgram | PageExportFunction): exported is PageExportFunction;
|
|
265
|
+
/**
|
|
266
|
+
* Resolve a page export to a CompiledProgram
|
|
267
|
+
*
|
|
268
|
+
* If the export is a function, it will be called with the route params.
|
|
269
|
+
* If the export is a static CompiledProgram, it will be returned as-is.
|
|
270
|
+
*
|
|
271
|
+
* @param pageDefault - The default export from a page module (function or static program)
|
|
272
|
+
* @param params - Route parameters extracted from the URL
|
|
273
|
+
* @param expectedParams - Optional array of expected parameter names for validation
|
|
274
|
+
* @returns Promise resolving to a CompiledProgram
|
|
275
|
+
* @throws Error if expectedParams is provided and a required param is missing
|
|
276
|
+
*/
|
|
277
|
+
declare function resolvePageExport(pageDefault: CompiledProgram | PageExportFunction, params: Record<string, string>, expectedParams?: string[]): Promise<CompiledProgram>;
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Layout Resolver - Scans and resolves layout files for Constela Start
|
|
281
|
+
*
|
|
282
|
+
* This module provides functionality to:
|
|
283
|
+
* - Scan layout directories for layout files
|
|
284
|
+
* - Resolve layout by name
|
|
285
|
+
* - Load and validate layout programs
|
|
286
|
+
* - Compose layouts with page content
|
|
287
|
+
*/
|
|
288
|
+
|
|
289
|
+
interface ScannedLayout {
|
|
290
|
+
name: string;
|
|
291
|
+
file: string;
|
|
292
|
+
}
|
|
293
|
+
interface LayoutInfo {
|
|
294
|
+
name: string;
|
|
295
|
+
file: string;
|
|
296
|
+
program?: LayoutProgram;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Scans a directory for layout files
|
|
300
|
+
*
|
|
301
|
+
* @param layoutsDir - Directory to scan for layouts
|
|
302
|
+
* @returns Array of scanned layouts with name and file path
|
|
303
|
+
* @throws Error if directory doesn't exist or is not a directory
|
|
304
|
+
*/
|
|
305
|
+
declare function scanLayouts(layoutsDir: string): Promise<ScannedLayout[]>;
|
|
306
|
+
/**
|
|
307
|
+
* Resolves a layout by name from scanned layouts
|
|
308
|
+
*
|
|
309
|
+
* @param layoutName - Name of the layout to resolve
|
|
310
|
+
* @param layouts - Array of scanned layouts
|
|
311
|
+
* @returns The matching layout or undefined
|
|
312
|
+
*/
|
|
313
|
+
declare function resolveLayout(layoutName: string, layouts: ScannedLayout[]): ScannedLayout | undefined;
|
|
314
|
+
/**
|
|
315
|
+
* Loads and validates a layout file
|
|
316
|
+
*
|
|
317
|
+
* @param layoutFile - Path to the layout file
|
|
318
|
+
* @returns The loaded LayoutProgram
|
|
319
|
+
* @throws Error if file cannot be loaded or is not a valid layout
|
|
320
|
+
*/
|
|
321
|
+
declare function loadLayout(layoutFile: string): Promise<LayoutProgram>;
|
|
322
|
+
/**
|
|
323
|
+
* LayoutResolver - Manages layout scanning, resolution, and composition
|
|
324
|
+
*/
|
|
325
|
+
declare class LayoutResolver {
|
|
326
|
+
private layoutsDir;
|
|
327
|
+
private layouts;
|
|
328
|
+
private loadedLayouts;
|
|
329
|
+
private initialized;
|
|
330
|
+
constructor(layoutsDir: string);
|
|
331
|
+
/**
|
|
332
|
+
* Initialize the resolver by scanning the layouts directory
|
|
333
|
+
*/
|
|
334
|
+
initialize(): Promise<void>;
|
|
335
|
+
/**
|
|
336
|
+
* Check if a layout exists
|
|
337
|
+
*/
|
|
338
|
+
hasLayout(name: string): boolean;
|
|
339
|
+
/**
|
|
340
|
+
* Get a layout by name
|
|
341
|
+
*
|
|
342
|
+
* @param name - Layout name
|
|
343
|
+
* @returns The layout program or undefined if not found
|
|
344
|
+
*/
|
|
345
|
+
getLayout(name: string): Promise<LayoutProgram | undefined>;
|
|
346
|
+
/**
|
|
347
|
+
* Compose a page with its layout
|
|
348
|
+
*
|
|
349
|
+
* @param page - Page program to compose
|
|
350
|
+
* @returns Composed program (or original if no layout)
|
|
351
|
+
* @throws Error if specified layout is not found
|
|
352
|
+
*/
|
|
353
|
+
composeWithLayout(page: Program): Promise<Program>;
|
|
354
|
+
/**
|
|
355
|
+
* Get all scanned layouts
|
|
356
|
+
*/
|
|
357
|
+
getAll(): ScannedLayout[];
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Data Loader for Build-time Content Loading
|
|
362
|
+
*
|
|
363
|
+
* This module provides utilities for loading data sources at build time:
|
|
364
|
+
* - Glob patterns for multiple files
|
|
365
|
+
* - Single file loading
|
|
366
|
+
* - API fetching
|
|
367
|
+
* - MDX/YAML/CSV transformations
|
|
368
|
+
* - Static path generation for SSG
|
|
369
|
+
*/
|
|
370
|
+
|
|
371
|
+
interface GlobResult {
|
|
372
|
+
file: string;
|
|
373
|
+
raw: string;
|
|
374
|
+
frontmatter?: Record<string, unknown>;
|
|
375
|
+
content?: string;
|
|
376
|
+
}
|
|
377
|
+
interface StaticPath {
|
|
378
|
+
params: Record<string, string>;
|
|
379
|
+
data?: unknown;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Transform MDX content - parse frontmatter and content
|
|
383
|
+
*/
|
|
384
|
+
declare function transformMdx(content: string): {
|
|
385
|
+
frontmatter: Record<string, unknown>;
|
|
386
|
+
content: string;
|
|
387
|
+
};
|
|
388
|
+
/**
|
|
389
|
+
* Transform YAML content
|
|
390
|
+
*/
|
|
391
|
+
declare function transformYaml(content: string): Record<string, unknown>;
|
|
392
|
+
/**
|
|
393
|
+
* Transform CSV content to array of objects
|
|
394
|
+
*/
|
|
395
|
+
declare function transformCsv(content: string): Record<string, string>[];
|
|
396
|
+
/**
|
|
397
|
+
* Load files matching a glob pattern
|
|
398
|
+
*/
|
|
399
|
+
declare function loadGlob(baseDir: string, pattern: string, transform?: string): Promise<GlobResult[]>;
|
|
400
|
+
/**
|
|
401
|
+
* Load a single file
|
|
402
|
+
*/
|
|
403
|
+
declare function loadFile(baseDir: string, filePath: string, transform?: string): Promise<unknown>;
|
|
404
|
+
/**
|
|
405
|
+
* Load data from API endpoint
|
|
406
|
+
*/
|
|
407
|
+
declare function loadApi(url: string, transform?: string): Promise<unknown>;
|
|
408
|
+
/**
|
|
409
|
+
* Generate static paths from data using params expression
|
|
410
|
+
*/
|
|
411
|
+
declare function generateStaticPaths(data: unknown[], staticPathsDef: StaticPathsDefinition): Promise<StaticPath[]>;
|
|
412
|
+
/**
|
|
413
|
+
* DataLoader class for managing data source loading with caching
|
|
414
|
+
*/
|
|
415
|
+
declare class DataLoader {
|
|
416
|
+
private cache;
|
|
417
|
+
private projectRoot;
|
|
418
|
+
constructor(projectRoot: string);
|
|
419
|
+
/**
|
|
420
|
+
* Load a single data source
|
|
421
|
+
*/
|
|
422
|
+
loadDataSource(name: string, dataSource: DataSource): Promise<unknown>;
|
|
423
|
+
/**
|
|
424
|
+
* Load all data sources
|
|
425
|
+
*/
|
|
426
|
+
loadAllDataSources(dataSources: Record<string, DataSource>): Promise<Record<string, unknown>>;
|
|
427
|
+
/**
|
|
428
|
+
* Clear cache for a specific data source or all caches
|
|
429
|
+
*/
|
|
430
|
+
clearCache(name?: string): void;
|
|
431
|
+
/**
|
|
432
|
+
* Clear all cache entries
|
|
433
|
+
*/
|
|
434
|
+
clearAllCache(): void;
|
|
435
|
+
/**
|
|
436
|
+
* Get the current cache size
|
|
437
|
+
*/
|
|
438
|
+
getCacheSize(): number;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export { type APIContext, type APIModule, type BuildOptions, type ConstelaConfig, DataLoader, type DevServerOptions, type GenerateStaticPagesOptions, type GlobResult, type LayoutInfo, LayoutResolver, type Middleware, type MiddlewareContext, type MiddlewareNext, type PageExportFunction, type PageModule, type ScannedLayout, type ScannedRoute, type StaticFileResult, type StaticPath, type StaticPathsProvider, type StaticPathsResult, build, createAPIHandler, createAdapter, createDevServer, createMiddlewareChain, filePathToPattern, generateStaticPages, generateStaticPaths, getMimeType, isPageExportFunction, isPathSafe, loadApi, loadFile, loadGlob, loadLayout, resolveLayout, resolvePageExport, resolveStaticFile, scanLayouts, scanRoutes, transformCsv, transformMdx, transformYaml };
|
package/dist/index.js
CHANGED
|
@@ -16,6 +16,26 @@ import {
|
|
|
16
16
|
// src/build/ssg.ts
|
|
17
17
|
import { mkdir, writeFile } from "fs/promises";
|
|
18
18
|
import { join, dirname } from "path";
|
|
19
|
+
|
|
20
|
+
// src/utils/resolve-page.ts
|
|
21
|
+
function isPageExportFunction(exported) {
|
|
22
|
+
return typeof exported === "function";
|
|
23
|
+
}
|
|
24
|
+
async function resolvePageExport(pageDefault, params, expectedParams) {
|
|
25
|
+
if (expectedParams) {
|
|
26
|
+
for (const key of expectedParams) {
|
|
27
|
+
if (!(key in params)) {
|
|
28
|
+
throw new Error(`Missing required route param: ${key}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (isPageExportFunction(pageDefault)) {
|
|
33
|
+
return await pageDefault(params);
|
|
34
|
+
}
|
|
35
|
+
return pageDefault;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/build/ssg.ts
|
|
19
39
|
var defaultProgram = {
|
|
20
40
|
version: "1.0",
|
|
21
41
|
state: {},
|
|
@@ -27,18 +47,6 @@ var defaultProgram = {
|
|
|
27
47
|
children: [{ kind: "text", value: { expr: "lit", value: "" } }]
|
|
28
48
|
}
|
|
29
49
|
};
|
|
30
|
-
var testStaticPaths = {
|
|
31
|
-
"/users/:id": {
|
|
32
|
-
paths: [{ params: { id: "1" } }, { params: { id: "2" } }]
|
|
33
|
-
},
|
|
34
|
-
"/posts/:slug": {
|
|
35
|
-
paths: [{ params: { slug: "hello-world" } }]
|
|
36
|
-
},
|
|
37
|
-
"/posts/:year/:month": {
|
|
38
|
-
paths: [{ params: { year: "2024", month: "01" } }]
|
|
39
|
-
}
|
|
40
|
-
};
|
|
41
|
-
var consumedPatterns = /* @__PURE__ */ new Set();
|
|
42
50
|
function getOutputPath(pattern, outDir) {
|
|
43
51
|
if (pattern === "/") {
|
|
44
52
|
return join(outDir, "index.html");
|
|
@@ -61,15 +69,13 @@ async function tryLoadModule(filePath) {
|
|
|
61
69
|
return null;
|
|
62
70
|
}
|
|
63
71
|
}
|
|
64
|
-
async function getStaticPathsForRoute(route, module) {
|
|
72
|
+
async function getStaticPathsForRoute(route, module, staticPathsProvider) {
|
|
65
73
|
if (module?.getStaticPaths) {
|
|
66
74
|
const result = await module.getStaticPaths();
|
|
67
75
|
return result;
|
|
68
76
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
consumedPatterns.add(route.pattern);
|
|
72
|
-
return testData;
|
|
77
|
+
if (staticPathsProvider) {
|
|
78
|
+
return await staticPathsProvider(route.pattern);
|
|
73
79
|
}
|
|
74
80
|
return null;
|
|
75
81
|
}
|
|
@@ -88,19 +94,21 @@ async function generateSinglePage(pattern, outDir, program, params = {}) {
|
|
|
88
94
|
await writeFile(outputPath, html, "utf-8");
|
|
89
95
|
return outputPath;
|
|
90
96
|
}
|
|
91
|
-
async function generateStaticPages(routes, outDir) {
|
|
97
|
+
async function generateStaticPages(routes, outDir, options = {}) {
|
|
98
|
+
const { staticPathsProvider } = options;
|
|
92
99
|
const generatedPaths = [];
|
|
93
100
|
const pageRoutes = routes.filter((r) => r.type === "page");
|
|
94
101
|
for (const route of pageRoutes) {
|
|
95
102
|
const isDynamic = route.params.length > 0;
|
|
96
103
|
const module = await tryLoadModule(route.file);
|
|
97
|
-
const
|
|
104
|
+
const pageExport = module?.default ?? defaultProgram;
|
|
98
105
|
if (isDynamic) {
|
|
99
|
-
const staticPaths = await getStaticPathsForRoute(route, module);
|
|
106
|
+
const staticPaths = await getStaticPathsForRoute(route, module, staticPathsProvider);
|
|
100
107
|
if (!staticPaths) {
|
|
101
108
|
continue;
|
|
102
109
|
}
|
|
103
110
|
for (const pathData of staticPaths.paths) {
|
|
111
|
+
const program = await resolvePageExport(pageExport, pathData.params, route.params);
|
|
104
112
|
const resolvedPattern = resolvePattern(route.pattern, pathData.params);
|
|
105
113
|
const filePath = await generateSinglePage(
|
|
106
114
|
resolvedPattern,
|
|
@@ -111,6 +119,7 @@ async function generateStaticPages(routes, outDir) {
|
|
|
111
119
|
generatedPaths.push(filePath);
|
|
112
120
|
}
|
|
113
121
|
} else {
|
|
122
|
+
const program = await resolvePageExport(pageExport, {});
|
|
114
123
|
const filePath = await generateSinglePage(route.pattern, outDir, program);
|
|
115
124
|
generatedPaths.push(filePath);
|
|
116
125
|
}
|
|
@@ -222,7 +231,7 @@ function createErrorResponse(error) {
|
|
|
222
231
|
}
|
|
223
232
|
function createAdapter(options) {
|
|
224
233
|
const { routes, loadModule = defaultLoadModule } = options;
|
|
225
|
-
async function
|
|
234
|
+
async function fetch2(request) {
|
|
226
235
|
try {
|
|
227
236
|
const url = new URL(request.url);
|
|
228
237
|
let pathname = url.pathname;
|
|
@@ -257,7 +266,7 @@ function createAdapter(options) {
|
|
|
257
266
|
return await handler(ctx);
|
|
258
267
|
} else {
|
|
259
268
|
const pageModule = module;
|
|
260
|
-
const program = pageModule.default;
|
|
269
|
+
const program = await resolvePageExport(pageModule.default, matchedParams, matchedRoute.params);
|
|
261
270
|
const content = await renderPage(program, {
|
|
262
271
|
url: request.url,
|
|
263
272
|
params: matchedParams,
|
|
@@ -274,9 +283,448 @@ function createAdapter(options) {
|
|
|
274
283
|
return createErrorResponse(error);
|
|
275
284
|
}
|
|
276
285
|
}
|
|
277
|
-
return { fetch };
|
|
286
|
+
return { fetch: fetch2 };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// src/layout/resolver.ts
|
|
290
|
+
import { existsSync, statSync } from "fs";
|
|
291
|
+
import { join as join2, basename } from "path";
|
|
292
|
+
import fg from "fast-glob";
|
|
293
|
+
import { isLayoutProgram } from "@constela/core";
|
|
294
|
+
async function scanLayouts(layoutsDir) {
|
|
295
|
+
if (!existsSync(layoutsDir)) {
|
|
296
|
+
throw new Error(`Layouts directory does not exist: ${layoutsDir}`);
|
|
297
|
+
}
|
|
298
|
+
const stat = statSync(layoutsDir);
|
|
299
|
+
if (!stat.isDirectory()) {
|
|
300
|
+
throw new Error(`Path is not a directory: ${layoutsDir}`);
|
|
301
|
+
}
|
|
302
|
+
const files = await fg(["**/*.ts", "**/*.tsx"], {
|
|
303
|
+
cwd: layoutsDir,
|
|
304
|
+
ignore: ["**/_*", "**/*.d.ts"]
|
|
305
|
+
});
|
|
306
|
+
const layouts = files.filter((file) => {
|
|
307
|
+
const name = basename(file);
|
|
308
|
+
if (name.startsWith("_")) return false;
|
|
309
|
+
if (name.endsWith(".d.ts")) return false;
|
|
310
|
+
if (!name.endsWith(".ts") && !name.endsWith(".tsx")) return false;
|
|
311
|
+
return true;
|
|
312
|
+
}).map((file) => {
|
|
313
|
+
const name = basename(file).replace(/\.(tsx?|ts)$/, "");
|
|
314
|
+
return {
|
|
315
|
+
name,
|
|
316
|
+
file: join2(layoutsDir, file)
|
|
317
|
+
};
|
|
318
|
+
});
|
|
319
|
+
return layouts;
|
|
320
|
+
}
|
|
321
|
+
function resolveLayout(layoutName, layouts) {
|
|
322
|
+
return layouts.find((l) => l.name === layoutName);
|
|
323
|
+
}
|
|
324
|
+
async function loadLayout(layoutFile) {
|
|
325
|
+
try {
|
|
326
|
+
const module = await import(layoutFile);
|
|
327
|
+
const exported = module.default || module;
|
|
328
|
+
if (!isLayoutProgram(exported)) {
|
|
329
|
+
throw new Error(`File is not a valid layout: ${layoutFile}`);
|
|
330
|
+
}
|
|
331
|
+
return exported;
|
|
332
|
+
} catch (error) {
|
|
333
|
+
if (error instanceof Error && error.message.includes("not a valid layout")) {
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
throw new Error(`Failed to load layout: ${layoutFile}`);
|
|
337
|
+
}
|
|
278
338
|
}
|
|
339
|
+
var LayoutResolver = class {
|
|
340
|
+
layoutsDir;
|
|
341
|
+
layouts = [];
|
|
342
|
+
loadedLayouts = /* @__PURE__ */ new Map();
|
|
343
|
+
initialized = false;
|
|
344
|
+
constructor(layoutsDir) {
|
|
345
|
+
this.layoutsDir = layoutsDir;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Initialize the resolver by scanning the layouts directory
|
|
349
|
+
*/
|
|
350
|
+
async initialize() {
|
|
351
|
+
try {
|
|
352
|
+
this.layouts = await scanLayouts(this.layoutsDir);
|
|
353
|
+
this.initialized = true;
|
|
354
|
+
} catch {
|
|
355
|
+
this.layouts = [];
|
|
356
|
+
this.initialized = true;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Check if a layout exists
|
|
361
|
+
*/
|
|
362
|
+
hasLayout(name) {
|
|
363
|
+
return this.layouts.some((l) => l.name === name);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Get a layout by name
|
|
367
|
+
*
|
|
368
|
+
* @param name - Layout name
|
|
369
|
+
* @returns The layout program or undefined if not found
|
|
370
|
+
*/
|
|
371
|
+
async getLayout(name) {
|
|
372
|
+
const cached = this.loadedLayouts.get(name);
|
|
373
|
+
if (cached) {
|
|
374
|
+
return cached;
|
|
375
|
+
}
|
|
376
|
+
const scanned = resolveLayout(name, this.layouts);
|
|
377
|
+
if (!scanned) {
|
|
378
|
+
return void 0;
|
|
379
|
+
}
|
|
380
|
+
const layout = await loadLayout(scanned.file);
|
|
381
|
+
this.loadedLayouts.set(name, layout);
|
|
382
|
+
return layout;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Compose a page with its layout
|
|
386
|
+
*
|
|
387
|
+
* @param page - Page program to compose
|
|
388
|
+
* @returns Composed program (or original if no layout)
|
|
389
|
+
* @throws Error if specified layout is not found
|
|
390
|
+
*/
|
|
391
|
+
async composeWithLayout(page) {
|
|
392
|
+
const layoutName = page.route?.layout;
|
|
393
|
+
if (!layoutName) {
|
|
394
|
+
return page;
|
|
395
|
+
}
|
|
396
|
+
if (!this.hasLayout(layoutName)) {
|
|
397
|
+
const available = this.layouts.map((l) => l.name).join(", ");
|
|
398
|
+
throw new Error(
|
|
399
|
+
`Layout '${layoutName}' not found. Available layouts: ${available || "none"}`
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
const layout = await this.getLayout(layoutName);
|
|
403
|
+
if (!layout) {
|
|
404
|
+
throw new Error(`Layout '${layoutName}' not found`);
|
|
405
|
+
}
|
|
406
|
+
return page;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Get all scanned layouts
|
|
410
|
+
*/
|
|
411
|
+
getAll() {
|
|
412
|
+
return [...this.layouts];
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
// src/data/loader.ts
|
|
417
|
+
import { existsSync as existsSync2, readFileSync } from "fs";
|
|
418
|
+
import { join as join3 } from "path";
|
|
419
|
+
import fg2 from "fast-glob";
|
|
420
|
+
function parseYaml(content) {
|
|
421
|
+
const result = {};
|
|
422
|
+
const lines = content.split("\n");
|
|
423
|
+
const stack = [{ indent: -2, obj: result }];
|
|
424
|
+
for (let i = 0; i < lines.length; i++) {
|
|
425
|
+
const line = lines[i];
|
|
426
|
+
if (!line || line.trim() === "" || line.trim().startsWith("#")) continue;
|
|
427
|
+
const arrayMatch = line.match(/^(\s*)-\s*(.*)$/);
|
|
428
|
+
if (arrayMatch) {
|
|
429
|
+
const [, indentStr2, rest] = arrayMatch;
|
|
430
|
+
const indent2 = indentStr2?.length ?? 0;
|
|
431
|
+
while (stack.length > 1 && indent2 <= stack[stack.length - 1].indent) {
|
|
432
|
+
stack.pop();
|
|
433
|
+
}
|
|
434
|
+
const parent = stack[stack.length - 1];
|
|
435
|
+
const key2 = parent.key;
|
|
436
|
+
if (key2) {
|
|
437
|
+
if (!Array.isArray(parent.obj[key2])) {
|
|
438
|
+
parent.obj[key2] = [];
|
|
439
|
+
}
|
|
440
|
+
const arr = parent.obj[key2];
|
|
441
|
+
const objMatch = rest?.match(/^([\w-]+):\s*(.*)$/);
|
|
442
|
+
if (objMatch) {
|
|
443
|
+
const [, k, v] = objMatch;
|
|
444
|
+
const newObj = {};
|
|
445
|
+
if (v?.trim()) {
|
|
446
|
+
newObj[k] = parseValue(v);
|
|
447
|
+
}
|
|
448
|
+
arr.push(newObj);
|
|
449
|
+
stack.push({ indent: indent2, obj: newObj, key: k, isArray: true });
|
|
450
|
+
} else if (rest?.trim()) {
|
|
451
|
+
arr.push(parseValue(rest.trim()));
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
const match = line.match(/^(\s*)([\w-]+):\s*(.*)$/);
|
|
457
|
+
if (!match) continue;
|
|
458
|
+
const [, indentStr, key, value] = match;
|
|
459
|
+
const indent = indentStr?.length ?? 0;
|
|
460
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
461
|
+
stack.pop();
|
|
462
|
+
}
|
|
463
|
+
let targetObj;
|
|
464
|
+
const currentTop = stack[stack.length - 1];
|
|
465
|
+
if (currentTop.isArray) {
|
|
466
|
+
targetObj = currentTop.obj;
|
|
467
|
+
} else if (currentTop.key) {
|
|
468
|
+
if (!currentTop.obj[currentTop.key]) {
|
|
469
|
+
currentTop.obj[currentTop.key] = {};
|
|
470
|
+
}
|
|
471
|
+
targetObj = currentTop.obj[currentTop.key];
|
|
472
|
+
} else {
|
|
473
|
+
targetObj = currentTop.obj;
|
|
474
|
+
}
|
|
475
|
+
if (value?.trim() === "" || value === void 0) {
|
|
476
|
+
const newObj = {};
|
|
477
|
+
targetObj[key] = newObj;
|
|
478
|
+
stack.push({ indent, obj: targetObj, key });
|
|
479
|
+
} else {
|
|
480
|
+
targetObj[key] = parseValue(value);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return result;
|
|
484
|
+
}
|
|
485
|
+
function parseValue(value) {
|
|
486
|
+
const trimmed = value.trim();
|
|
487
|
+
if (trimmed === "true") return true;
|
|
488
|
+
if (trimmed === "false") return false;
|
|
489
|
+
if (trimmed === "null" || trimmed === "~") return null;
|
|
490
|
+
if (/^-?\d+$/.test(trimmed)) return parseInt(trimmed, 10);
|
|
491
|
+
if (/^-?\d+\.\d+$/.test(trimmed)) return parseFloat(trimmed);
|
|
492
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
|
493
|
+
return trimmed.slice(1, -1);
|
|
494
|
+
}
|
|
495
|
+
return trimmed;
|
|
496
|
+
}
|
|
497
|
+
function transformMdx(content) {
|
|
498
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
499
|
+
if (!match) {
|
|
500
|
+
return { frontmatter: {}, content: content.trim() };
|
|
501
|
+
}
|
|
502
|
+
const frontmatter = parseYaml(match[1]);
|
|
503
|
+
return { frontmatter, content: match[2].trim() };
|
|
504
|
+
}
|
|
505
|
+
function transformYaml(content) {
|
|
506
|
+
return parseYaml(content);
|
|
507
|
+
}
|
|
508
|
+
function transformCsv(content) {
|
|
509
|
+
const lines = content.trim().split("\n");
|
|
510
|
+
if (lines.length === 0) return [];
|
|
511
|
+
const headerLine = lines[0];
|
|
512
|
+
const headers = parseCSVLine(headerLine);
|
|
513
|
+
const result = [];
|
|
514
|
+
for (let i = 1; i < lines.length; i++) {
|
|
515
|
+
const line = lines[i];
|
|
516
|
+
if (line.trim() === "") continue;
|
|
517
|
+
const values = parseCSVLine(line);
|
|
518
|
+
const row = {};
|
|
519
|
+
for (let j = 0; j < headers.length; j++) {
|
|
520
|
+
row[headers[j].trim()] = (values[j] ?? "").trim();
|
|
521
|
+
}
|
|
522
|
+
result.push(row);
|
|
523
|
+
}
|
|
524
|
+
return result;
|
|
525
|
+
}
|
|
526
|
+
function parseCSVLine(line) {
|
|
527
|
+
const result = [];
|
|
528
|
+
let current = "";
|
|
529
|
+
let inQuotes = false;
|
|
530
|
+
for (let i = 0; i < line.length; i++) {
|
|
531
|
+
const char = line[i];
|
|
532
|
+
if (char === '"') {
|
|
533
|
+
inQuotes = !inQuotes;
|
|
534
|
+
} else if (char === "," && !inQuotes) {
|
|
535
|
+
result.push(current);
|
|
536
|
+
current = "";
|
|
537
|
+
} else {
|
|
538
|
+
current += char;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
result.push(current);
|
|
542
|
+
return result;
|
|
543
|
+
}
|
|
544
|
+
function applyTransform(content, transform, filename) {
|
|
545
|
+
if (!transform) {
|
|
546
|
+
if (filename.endsWith(".json")) {
|
|
547
|
+
return JSON.parse(content);
|
|
548
|
+
}
|
|
549
|
+
return content;
|
|
550
|
+
}
|
|
551
|
+
switch (transform) {
|
|
552
|
+
case "mdx":
|
|
553
|
+
return transformMdx(content);
|
|
554
|
+
case "yaml":
|
|
555
|
+
return transformYaml(content);
|
|
556
|
+
case "csv":
|
|
557
|
+
return transformCsv(content);
|
|
558
|
+
default:
|
|
559
|
+
return content;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
async function loadGlob(baseDir, pattern, transform) {
|
|
563
|
+
const files = await fg2(pattern, { cwd: baseDir });
|
|
564
|
+
const results = [];
|
|
565
|
+
for (const file of files) {
|
|
566
|
+
const fullPath = join3(baseDir, file);
|
|
567
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
568
|
+
if (transform === "mdx") {
|
|
569
|
+
const transformed = transformMdx(content);
|
|
570
|
+
results.push({
|
|
571
|
+
file,
|
|
572
|
+
raw: content,
|
|
573
|
+
frontmatter: transformed.frontmatter,
|
|
574
|
+
content: transformed.content
|
|
575
|
+
});
|
|
576
|
+
} else {
|
|
577
|
+
results.push({
|
|
578
|
+
file,
|
|
579
|
+
raw: content
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return results;
|
|
584
|
+
}
|
|
585
|
+
async function loadFile(baseDir, filePath, transform) {
|
|
586
|
+
const fullPath = join3(baseDir, filePath);
|
|
587
|
+
if (!existsSync2(fullPath)) {
|
|
588
|
+
throw new Error(`File not found: ${fullPath}`);
|
|
589
|
+
}
|
|
590
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
591
|
+
return applyTransform(content, transform, filePath);
|
|
592
|
+
}
|
|
593
|
+
async function loadApi(url, transform) {
|
|
594
|
+
try {
|
|
595
|
+
const response = await fetch(url);
|
|
596
|
+
if (!response.ok) {
|
|
597
|
+
throw new Error(`api request failed: ${response.status} ${response.statusText}`);
|
|
598
|
+
}
|
|
599
|
+
if (transform === "csv") {
|
|
600
|
+
const text = await response.text();
|
|
601
|
+
return transformCsv(text);
|
|
602
|
+
}
|
|
603
|
+
return await response.json();
|
|
604
|
+
} catch (error) {
|
|
605
|
+
if (error instanceof Error && error.message.includes("api request failed")) {
|
|
606
|
+
throw error;
|
|
607
|
+
}
|
|
608
|
+
throw new Error(`Network error: ${error.message}`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
function evaluateParamExpression(expr, item) {
|
|
612
|
+
switch (expr.expr) {
|
|
613
|
+
case "lit":
|
|
614
|
+
return String(expr.value);
|
|
615
|
+
case "var":
|
|
616
|
+
if (expr.name === "item") {
|
|
617
|
+
if (expr.path) {
|
|
618
|
+
return getNestedValue(item, expr.path);
|
|
619
|
+
}
|
|
620
|
+
return String(item);
|
|
621
|
+
}
|
|
622
|
+
return "";
|
|
623
|
+
case "get":
|
|
624
|
+
if (expr.base.expr === "var" && expr.base.name === "item") {
|
|
625
|
+
return getNestedValue(item, expr.path);
|
|
626
|
+
}
|
|
627
|
+
return "";
|
|
628
|
+
default:
|
|
629
|
+
return "";
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
function getNestedValue(obj, path) {
|
|
633
|
+
const parts = path.split(".");
|
|
634
|
+
let current = obj;
|
|
635
|
+
for (const part of parts) {
|
|
636
|
+
if (current === null || current === void 0) return "";
|
|
637
|
+
if (typeof current !== "object") return "";
|
|
638
|
+
current = current[part];
|
|
639
|
+
}
|
|
640
|
+
return current !== void 0 && current !== null ? String(current) : "";
|
|
641
|
+
}
|
|
642
|
+
async function generateStaticPaths(data, staticPathsDef) {
|
|
643
|
+
const paths = [];
|
|
644
|
+
for (const item of data) {
|
|
645
|
+
const params = {};
|
|
646
|
+
for (const [paramName, paramExpr] of Object.entries(staticPathsDef.params)) {
|
|
647
|
+
params[paramName] = evaluateParamExpression(paramExpr, item);
|
|
648
|
+
}
|
|
649
|
+
paths.push({ params, data: item });
|
|
650
|
+
}
|
|
651
|
+
return paths;
|
|
652
|
+
}
|
|
653
|
+
var DataLoader = class {
|
|
654
|
+
cache = /* @__PURE__ */ new Map();
|
|
655
|
+
projectRoot;
|
|
656
|
+
constructor(projectRoot) {
|
|
657
|
+
this.projectRoot = projectRoot;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Load a single data source
|
|
661
|
+
*/
|
|
662
|
+
async loadDataSource(name, dataSource) {
|
|
663
|
+
if (this.cache.has(name)) {
|
|
664
|
+
return this.cache.get(name);
|
|
665
|
+
}
|
|
666
|
+
let data;
|
|
667
|
+
switch (dataSource.type) {
|
|
668
|
+
case "glob":
|
|
669
|
+
if (!dataSource.pattern) {
|
|
670
|
+
throw new Error(`Glob data source '${name}' requires pattern`);
|
|
671
|
+
}
|
|
672
|
+
data = await loadGlob(this.projectRoot, dataSource.pattern, dataSource.transform);
|
|
673
|
+
break;
|
|
674
|
+
case "file":
|
|
675
|
+
if (!dataSource.path) {
|
|
676
|
+
throw new Error(`File data source '${name}' requires path`);
|
|
677
|
+
}
|
|
678
|
+
data = await loadFile(this.projectRoot, dataSource.path, dataSource.transform);
|
|
679
|
+
break;
|
|
680
|
+
case "api":
|
|
681
|
+
if (!dataSource.url) {
|
|
682
|
+
throw new Error(`API data source '${name}' requires url`);
|
|
683
|
+
}
|
|
684
|
+
data = await loadApi(dataSource.url, dataSource.transform);
|
|
685
|
+
break;
|
|
686
|
+
default:
|
|
687
|
+
throw new Error(`Unknown data source type: ${dataSource.type}`);
|
|
688
|
+
}
|
|
689
|
+
this.cache.set(name, data);
|
|
690
|
+
return data;
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Load all data sources
|
|
694
|
+
*/
|
|
695
|
+
async loadAllDataSources(dataSources) {
|
|
696
|
+
const result = {};
|
|
697
|
+
for (const [name, source] of Object.entries(dataSources)) {
|
|
698
|
+
result[name] = await this.loadDataSource(name, source);
|
|
699
|
+
}
|
|
700
|
+
return result;
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Clear cache for a specific data source or all caches
|
|
704
|
+
*/
|
|
705
|
+
clearCache(name) {
|
|
706
|
+
if (name) {
|
|
707
|
+
this.cache.delete(name);
|
|
708
|
+
} else {
|
|
709
|
+
this.cache.clear();
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Clear all cache entries
|
|
714
|
+
*/
|
|
715
|
+
clearAllCache() {
|
|
716
|
+
this.cache.clear();
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Get the current cache size
|
|
720
|
+
*/
|
|
721
|
+
getCacheSize() {
|
|
722
|
+
return this.cache.size;
|
|
723
|
+
}
|
|
724
|
+
};
|
|
279
725
|
export {
|
|
726
|
+
DataLoader,
|
|
727
|
+
LayoutResolver,
|
|
280
728
|
build,
|
|
281
729
|
createAPIHandler,
|
|
282
730
|
createAdapter,
|
|
@@ -284,8 +732,20 @@ export {
|
|
|
284
732
|
createMiddlewareChain,
|
|
285
733
|
filePathToPattern,
|
|
286
734
|
generateStaticPages,
|
|
735
|
+
generateStaticPaths,
|
|
287
736
|
getMimeType,
|
|
737
|
+
isPageExportFunction,
|
|
288
738
|
isPathSafe,
|
|
739
|
+
loadApi,
|
|
740
|
+
loadFile,
|
|
741
|
+
loadGlob,
|
|
742
|
+
loadLayout,
|
|
743
|
+
resolveLayout,
|
|
744
|
+
resolvePageExport,
|
|
289
745
|
resolveStaticFile,
|
|
290
|
-
|
|
746
|
+
scanLayouts,
|
|
747
|
+
scanRoutes,
|
|
748
|
+
transformCsv,
|
|
749
|
+
transformMdx,
|
|
750
|
+
transformYaml
|
|
291
751
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@constela/start",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Meta-framework for Constela applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -39,10 +39,11 @@
|
|
|
39
39
|
"remark-mdx": "^3.0.0",
|
|
40
40
|
"remark-gfm": "^4.0.0",
|
|
41
41
|
"gray-matter": "^4.0.0",
|
|
42
|
-
"@constela/
|
|
43
|
-
"@constela/
|
|
44
|
-
"@constela/server": "0.
|
|
45
|
-
"@constela/
|
|
42
|
+
"@constela/core": "0.6.0",
|
|
43
|
+
"@constela/router": "6.0.0",
|
|
44
|
+
"@constela/server": "2.0.0",
|
|
45
|
+
"@constela/runtime": "0.9.0",
|
|
46
|
+
"@constela/compiler": "0.6.0"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"typescript": "^5.3.0",
|