UNPKG

10.7 kBPlain TextView Raw
1import { dirname, join, resolve } from "node:path";
2import { mkdir, writeFile } from "node:fs/promises";
3
4import { type MatchedRoute, type RadixRouter, createRouter } from "radix3";
5
6import type { AkteFiles } from "./AkteFiles";
7import type { Awaitable, GlobalDataFn } from "./types";
8import { NotFoundError } from "./errors";
9import { runCLI } from "./runCLI";
10import { akteWelcome } from "./akteWelcome";
11
12import { __PRODUCTION__ } from "./lib/__PRODUCTION__";
13import { createDebugger } from "./lib/createDebugger";
14import { pathToRouterPath } from "./lib/pathToRouterPath";
15import { isCLI } from "./lib/isCLI";
16
17/* eslint-disable @typescript-eslint/no-unused-vars, unused-imports/no-unused-imports */
18
19import type { defineAkteFile } from "./defineAkteFile";
20import type { defineAkteFiles } from "./defineAkteFiles";
21
22/* eslint-enable @typescript-eslint/no-unused-vars, unused-imports/no-unused-imports */
23
24/** Akte app configuration object. */
25export type Config<TGlobalData> = {
26 /**
27 * Akte files this config is responsible for.
28 *
29 * Create them with {@link defineAkteFile} and {@link defineAkteFiles}.
30 */
31 files: AkteFiles<TGlobalData>[];
32
33 /** Configuration related to Akte build process. */
34 build?: {
35 /**
36 * Output directory for Akte build command.
37 *
38 * @remarks
39 * This directory is overriden by the Akte Vite plugin when running Akte
40 * through Vite.
41 * @defaultValue `"dist"` for Akte build command, `".akte"` for Akte Vite plugin.
42 */
43 outDir?: string;
44 };
45 // Most global data will eventually be objects we use this
46 // assumption to make mandatory or not the `globalData` method
47} & (TGlobalData extends Record<string | number | symbol, unknown>
48 ? {
49 /**
50 * Global data retrieval function.
51 *
52 * The return value of this function is then shared with each Akte file.
53 */
54 globalData: GlobalDataFn<TGlobalData>;
55 }
56 : {
57 /**
58 * Global data retrieval function.
59 *
60 * The return value of this function is then shared with each Akte file.
61 */
62 globalData?: GlobalDataFn<TGlobalData>;
63 });
64
65const debug = createDebugger("akte:app");
66const debugWrite = createDebugger("akte:app:write");
67const debugRender = createDebugger("akte:app:render");
68const debugRouter = createDebugger("akte:app:router");
69const debugCache = createDebugger("akte:app:cache");
70
71/** An Akte app, ready to be interacted with. */
72export class AkteApp<TGlobalData = unknown> {
73 protected config: Config<TGlobalData>;
74
75 /**
76 * Readonly array of {@link AkteFiles} registered within the app.
77 *
78 * @experimental Programmatic API might still change not following SemVer.
79 */
80 get files(): AkteFiles<TGlobalData>[] {
81 return this.config.files;
82 }
83
84 constructor(config: Config<TGlobalData>) {
85 if (!__PRODUCTION__) {
86 if (config.files.length === 0 && akteWelcome) {
87 config.files.push(akteWelcome);
88 }
89 }
90
91 this.config = config;
92
93 debug("defined with %o files", this.config.files.length);
94
95 if (isCLI) {
96 runCLI(this as AkteApp);
97 }
98 }
99
100 /**
101 * Looks up the Akte file responsible for rendering the path.
102 *
103 * @param path - Path to lookup, e.g. "/foo"
104 * @returns A match featuring the path, the path parameters if any, and the
105 * Akte file.
106 * @throws {@link NotFoundError} When no Akte file is found for handling
107 * looked up path.
108 * @experimental Programmatic API might still change not following SemVer.
109 */
110 lookup(path: string): MatchedRoute<{
111 file: AkteFiles<TGlobalData>;
112 }> & { path: string } {
113 const pathWithExtension = pathToRouterPath(path);
114 debugRouter("looking up %o (%o)", path, pathWithExtension);
115
116 const maybeMatch = this.getRouter().lookup(pathWithExtension);
117
118 if (!maybeMatch || !maybeMatch.file) {
119 debugRouter("not found %o", path);
120 throw new NotFoundError(path);
121 }
122
123 return {
124 ...maybeMatch,
125 path,
126 };
127 }
128
129 /**
130 * Renders a match from {@link lookup}.
131 *
132 * @param match - Match to render.
133 * @returns Rendered file.
134 * @throws {@link NotFoundError} When the Akte file could not render the match
135 * (404), with an optional `cause` attached to it for uncaught errors (500)
136 * @experimental Programmatic API might still change not following SemVer.
137 */
138 async render(
139 match: MatchedRoute<{
140 file: AkteFiles<TGlobalData>;
141 }> & { path: string; globalData?: TGlobalData; data?: unknown },
142 ): Promise<string> {
143 debugRender("rendering %o...", match.path);
144
145 const params: Record<string, string> = match.params || {};
146 const globalData = match.globalData || (await this.getGlobalData());
147
148 try {
149 const content = await match.file.render({
150 path: match.path,
151 params,
152 globalData,
153 data: match.data,
154 });
155
156 debugRender("rendered %o", match.path);
157
158 return content;
159 } catch (error) {
160 if (error instanceof NotFoundError) {
161 throw error;
162 }
163
164 debugRender("could not render %o", match.path);
165
166 throw new NotFoundError(match.path, { cause: error });
167 }
168 }
169
170 /**
171 * Renders all Akte files.
172 *
173 * @returns Rendered files map.
174 * @experimental Programmatic API might still change not following SemVer.
175 */
176 async renderAll(): Promise<Record<string, string>> {
177 debugRender("rendering all files...");
178
179 const globalData = await this.getGlobalData();
180
181 const renderAll = async (
182 akteFiles: AkteFiles<TGlobalData>,
183 ): Promise<Record<string, string>> => {
184 try {
185 const files = await akteFiles.renderAll({ globalData });
186
187 return files;
188 } catch (error) {
189 debug.error("Akte → Failed to build %o\n", akteFiles.path);
190
191 throw error;
192 }
193 };
194
195 const promises: Promise<Record<string, string>>[] = [];
196 for (const akteFiles of this.config.files) {
197 promises.push(renderAll(akteFiles));
198 }
199
200 const rawFilesArray = await Promise.all(promises);
201
202 const files: Record<string, string> = {};
203 for (const rawFiles of rawFilesArray) {
204 for (const path in rawFiles) {
205 if (path in files) {
206 debug.warn(
207 " Multiple files built %o, only the first one is preserved",
208 path,
209 );
210 continue;
211 }
212
213 files[path] = rawFiles[path];
214 }
215 }
216
217 const rendered = Object.keys(files).length;
218 debugRender(
219 `done, %o ${rendered > 1 ? "files" : "file"} rendered`,
220 rendered,
221 );
222
223 return files;
224 }
225
226 /**
227 * Writes a map of rendered Akte files to the specified `outDir`, or the app
228 * specified one (defaults to `"dist"`).
229 *
230 * @param args - A map of rendered Akte files, and an optional `outDir`
231 * @experimental Programmatic API might still change not following SemVer.
232 */
233 async writeAll(args: {
234 outDir?: string;
235 files: Record<string, string>;
236 }): Promise<void> {
237 debugWrite("writing all files...");
238 const outDir = args.outDir ?? this.config.build?.outDir ?? "dist";
239 const outDirPath = resolve(outDir);
240
241 const controller = new AbortController();
242
243 const write = async (path: string, content: string): Promise<void> => {
244 const filePath = join(outDirPath, path);
245 const fileDir = dirname(filePath);
246
247 try {
248 await mkdir(fileDir, { recursive: true });
249 await writeFile(filePath, content, {
250 encoding: "utf-8",
251 signal: controller.signal,
252 });
253 } catch (error) {
254 if (controller.signal.aborted) {
255 return;
256 }
257
258 controller.abort();
259
260 debug.error("Akte → Failed to write %o\n", path);
261
262 throw error;
263 }
264
265 debugWrite("%o", path);
266 debugWrite.log(" %o", path);
267 };
268
269 const promises: Promise<void>[] = [];
270 for (const path in args.files) {
271 promises.push(write(path, args.files[path]));
272 }
273
274 await Promise.all(promises);
275
276 debugWrite(
277 `done, %o ${promises.length > 1 ? "files" : "file"} written`,
278 promises.length,
279 );
280 }
281
282 /**
283 * Build (renders and write) all Akte files to the specified `outDir`, or the
284 * app specified one (defaults to `"dist"`).
285 *
286 * @param args - An optional `outDir`
287 * @returns Built files array.
288 * @experimental Programmatic API might still change not following SemVer.
289 */
290 async buildAll(args?: { outDir?: string }): Promise<string[]> {
291 const files = await this.renderAll();
292 await this.writeAll({ ...args, files });
293
294 return Object.keys(files);
295 }
296
297 /**
298 * Akte caches all `globalData`, `data`, `bulkData` calls for performance.
299 * This method can be used to clear the cache.
300 *
301 * @param alsoClearFileCache - Also clear cache on all registered Akte files.
302 * @experimental Programmatic API might still change not following SemVer.
303 */
304 clearCache(alsoClearFileCache = false): void {
305 debugCache("clearing...");
306
307 this._globalDataCache = undefined;
308 this._router = undefined;
309
310 if (alsoClearFileCache) {
311 for (const file of this.config.files) {
312 file.clearCache();
313 }
314 }
315
316 debugCache("cleared");
317 }
318
319 /**
320 * Readonly cache of the app's definition `globalData` method.
321 *
322 * @experimental Programmatic API might still change not following SemVer.
323 */
324 get globalDataCache(): Awaitable<TGlobalData> | undefined {
325 return this._globalDataCache;
326 }
327
328 private _globalDataCache: Awaitable<TGlobalData> | undefined;
329
330 /**
331 * Retrieves data from the app's definition `globalData` method.
332 *
333 * @param context - Context to get global data with.
334 * @returns Retrieved global data.
335 * @remark Returned global data may come from cache.
336 * @experimental Programmatic API might still change not following SemVer.
337 */
338 getGlobalData(): Awaitable<TGlobalData> {
339 if (!this._globalDataCache) {
340 debugCache("retrieving global data...");
341 const globalDataPromise =
342 this.config.globalData?.() ?? (undefined as TGlobalData);
343
344 if (globalDataPromise instanceof Promise) {
345 globalDataPromise.then(() => {
346 debugCache("retrieved global data");
347 });
348 } else {
349 debugCache("retrieved global data");
350 }
351
352 this._globalDataCache = globalDataPromise;
353 } else {
354 debugCache("using cached global data");
355 }
356
357 return this._globalDataCache;
358 }
359
360 private _router:
361 | RadixRouter<{
362 file: AkteFiles<TGlobalData>;
363 }>
364 | undefined;
365
366 protected getRouter(): RadixRouter<{
367 file: AkteFiles<TGlobalData>;
368 }> {
369 if (!this._router) {
370 debugCache("creating router...");
371 const router = createRouter<{ file: AkteFiles<TGlobalData> }>();
372
373 for (const file of this.config.files) {
374 const path = pathToRouterPath(file.path);
375 router.insert(pathToRouterPath(file.path), { file });
376 debugRouter("registered %o", path);
377 if (file.path.endsWith("/**")) {
378 const catchAllPath = pathToRouterPath(
379 file.path.replace(/\/\*\*$/, ""),
380 );
381 router.insert(catchAllPath, {
382 file,
383 });
384 debugRouter("registered %o", catchAllPath);
385 debugCache(pathToRouterPath(file.path.replace(/\/\*\*$/, "")));
386 }
387 }
388
389 this._router = router;
390 debugCache("created router");
391 } else {
392 debugCache("using cached router");
393 }
394
395 return this._router;
396 }
397}