UNPKG

9.85 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 */
18
19import type { defineAkteFile } from "./defineAkteFile";
20import type { defineAkteFiles } from "./defineAkteFiles";
21
22/* eslint-enable @typescript-eslint/no-unused-vars */
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 constructor(config: Config<TGlobalData>) {
76 if (!__PRODUCTION__) {
77 if (config.files.length === 0 && akteWelcome) {
78 config.files.push(akteWelcome);
79 }
80 }
81
82 this.config = config;
83
84 debug("created with %o files", this.config.files.length);
85
86 if (isCLI) {
87 runCLI(this as AkteApp);
88 }
89 }
90
91 /**
92 * Looks up the Akte file responsible for rendering the path.
93 *
94 * @param path - Path to lookup, e.g. "/foo"
95 * @returns A match featuring the path, the path parameters if any, and the
96 * Akte file.
97 * @throws {@link NotFoundError} When no Akte file is found for handling
98 * looked up path.
99 * @experimental Programmatic API might still change not following SemVer.
100 */
101 lookup(path: string): MatchedRoute<{
102 file: AkteFiles<TGlobalData>;
103 }> & { path: string } {
104 const pathWithExtension = pathToRouterPath(path);
105 debugRouter("looking up %o (%o)", path, pathWithExtension);
106
107 const maybeMatch = this.getRouter().lookup(pathWithExtension);
108
109 if (!maybeMatch || !maybeMatch.file) {
110 debugRouter("not found %o", path);
111 throw new NotFoundError(path);
112 }
113
114 return {
115 ...maybeMatch,
116 path,
117 };
118 }
119
120 /**
121 * Renders a match from {@link lookup}.
122 *
123 * @param match - Match to render.
124 * @returns Rendered file.
125 * @throws {@link NotFoundError} When the Akte file could not render the match
126 * (404), with an optional `cause` attached to it for uncaught errors (500)
127 * @experimental Programmatic API might still change not following SemVer.
128 */
129 async render(
130 match: MatchedRoute<{
131 file: AkteFiles<TGlobalData>;
132 }> & { path: string },
133 ): Promise<string> {
134 debugRender("rendering %o...", match.path);
135
136 const params: Record<string, string> = match.params || {};
137 const globalData = await this.getGlobalDataPromise();
138
139 try {
140 const content = await match.file.render({
141 path: match.path,
142 params,
143 globalData,
144 });
145
146 debugRender("rendered %o", match.path);
147
148 return content;
149 } catch (error) {
150 if (error instanceof NotFoundError) {
151 throw error;
152 }
153
154 debugRender("could not render %o", match.path);
155
156 throw new NotFoundError(match.path, { cause: error });
157 }
158 }
159
160 /**
161 * Renders all Akte files.
162 *
163 * @returns Rendered files map.
164 * @experimental Programmatic API might still change not following SemVer.
165 */
166 async renderAll(): Promise<Record<string, string>> {
167 debugRender("rendering all files...");
168
169 const globalData = await this.getGlobalDataPromise();
170
171 const renderAll = async (
172 akteFiles: AkteFiles<TGlobalData>,
173 ): Promise<Record<string, string>> => {
174 try {
175 const files = await akteFiles.renderAll({ globalData });
176
177 return files;
178 } catch (error) {
179 debug.error("Akte → Failed to build %o\n", akteFiles.path);
180
181 throw error;
182 }
183 };
184
185 const promises: Promise<Record<string, string>>[] = [];
186 for (const akteFiles of this.config.files) {
187 promises.push(renderAll(akteFiles));
188 }
189
190 const rawFilesArray = await Promise.all(promises);
191
192 const files: Record<string, string> = {};
193 for (const rawFiles of rawFilesArray) {
194 for (const path in rawFiles) {
195 if (path in files) {
196 debug.warn(
197 " Multiple files built %o, only the first one is preserved",
198 path,
199 );
200 continue;
201 }
202
203 files[path] = rawFiles[path];
204 }
205 }
206
207 const rendered = Object.keys(files).length;
208 debugRender(
209 `done, %o ${rendered > 1 ? "files" : "file"} rendered`,
210 rendered,
211 );
212
213 return files;
214 }
215
216 /**
217 * Writes a map of rendered Akte files to the specified `outDir`, or the app
218 * specified one (defaults to `"dist"`).
219 *
220 * @param args - A map of rendered Akte files, and an optional `outDir`
221 * @experimental Programmatic API might still change not following SemVer.
222 */
223 async writeAll(args: {
224 outDir?: string;
225 files: Record<string, string>;
226 }): Promise<void> {
227 debugWrite("writing all files...");
228 const outDir = args.outDir ?? this.config.build?.outDir ?? "dist";
229 const outDirPath = resolve(outDir);
230
231 const controller = new AbortController();
232
233 const write = async (path: string, content: string): Promise<void> => {
234 const filePath = join(outDirPath, path);
235 const fileDir = dirname(filePath);
236
237 try {
238 await mkdir(fileDir, { recursive: true });
239 await writeFile(filePath, content, {
240 encoding: "utf-8",
241 signal: controller.signal,
242 });
243 } catch (error) {
244 if (controller.signal.aborted) {
245 return;
246 }
247
248 controller.abort();
249
250 debug.error("Akte → Failed to write %o\n", path);
251
252 throw error;
253 }
254
255 debugWrite("%o", path);
256 debugWrite.log(" %o", path);
257 };
258
259 const promises: Promise<void>[] = [];
260 for (const path in args.files) {
261 promises.push(write(path, args.files[path]));
262 }
263
264 await Promise.all(promises);
265
266 debugWrite(
267 `done, %o ${promises.length > 1 ? "files" : "file"} written`,
268 promises.length,
269 );
270 }
271
272 /**
273 * Build (renders and write) all Akte files to the specified `outDir`, or the
274 * app specified one (defaults to `"dist"`).
275 *
276 * @param args - An optional `outDir`
277 * @returns Built files array.
278 * @experimental Programmatic API might still change not following SemVer.
279 */
280 async buildAll(args?: { outDir?: string }): Promise<string[]> {
281 const files = await this.renderAll();
282 await this.writeAll({ ...args, files });
283
284 return Object.keys(files);
285 }
286
287 /**
288 * Akte caches all `globalData`, `data`, `bulkData` calls for performance.
289 * This method can be used to clear the cache.
290 *
291 * @param alsoClearFileCache - Also clear cache on all registered Akte files.
292 * @experimental Programmatic API might still change not following SemVer.
293 */
294 clearCache(alsoClearFileCache = false): void {
295 debugCache("clearing...");
296
297 this._globalDataPromise = undefined;
298 this._router = undefined;
299
300 if (alsoClearFileCache) {
301 for (const file of this.config.files) {
302 file.clearCache();
303 }
304 }
305
306 debugCache("cleared");
307 }
308
309 private _globalDataPromise: Awaitable<TGlobalData> | undefined;
310 protected getGlobalDataPromise(): Awaitable<TGlobalData> {
311 if (!this._globalDataPromise) {
312 debugCache("retrieving global data...");
313 const globalDataPromise =
314 this.config.globalData?.() ?? (undefined as TGlobalData);
315
316 if (globalDataPromise instanceof Promise) {
317 globalDataPromise.then(() => {
318 debugCache("retrieved global data");
319 });
320 } else {
321 debugCache("retrieved global data");
322 }
323
324 this._globalDataPromise = globalDataPromise;
325 } else {
326 debugCache("using cached global data");
327 }
328
329 return this._globalDataPromise;
330 }
331
332 private _router:
333 | RadixRouter<{
334 file: AkteFiles<TGlobalData>;
335 }>
336 | undefined;
337
338 protected getRouter(): RadixRouter<{
339 file: AkteFiles<TGlobalData>;
340 }> {
341 if (!this._router) {
342 debugCache("creating router...");
343 const router = createRouter<{ file: AkteFiles<TGlobalData> }>();
344
345 for (const file of this.config.files) {
346 const path = pathToRouterPath(file.path);
347 router.insert(pathToRouterPath(file.path), { file });
348 debugRouter("registered %o", path);
349 if (file.path.endsWith("/**")) {
350 const catchAllPath = pathToRouterPath(
351 file.path.replace(/\/\*\*$/, ""),
352 );
353 router.insert(catchAllPath, {
354 file,
355 });
356 debugRouter("registered %o", catchAllPath);
357 debugCache(pathToRouterPath(file.path.replace(/\/\*\*$/, "")));
358 }
359 }
360
361 this._router = router;
362 debugCache("created router");
363 } else {
364 debugCache("using cached router");
365 }
366
367 return this._router;
368 }
369}