UNPKG

6.92 kBPlain TextView Raw
1import { NotFoundError } from "./errors";
2import { type Awaitable } from "./types";
3
4import { createDebugger } from "./lib/createDebugger";
5import { pathToFilePath } from "./lib/pathToFilePath";
6
7/* eslint-disable @typescript-eslint/no-unused-vars */
8
9import type { AkteApp } from "./AkteApp";
10
11/* eslint-enable @typescript-eslint/no-unused-vars */
12
13type Path<
14 TParams extends string[],
15 TPrefix extends string = string,
16> = TParams extends []
17 ? ""
18 : TParams extends [string]
19 ? `${TPrefix}:${TParams[0]}${string}`
20 : TParams extends readonly [string, ...infer Rest extends string[]]
21 ? Path<Rest, `${TPrefix}:${TParams[0]}${string}`>
22 : string;
23
24/**
25 * A function responsible for fetching the data required to render a given file
26 * at the provided path. Used for optimization like server side rendering or
27 * serverless.
28 */
29export type FilesDataFn<
30 TGlobalData,
31 TParams extends string[],
32 TData,
33> = (context: {
34 /** Path to get data for. */
35 path: string;
36
37 /** Path parameters if any. */
38 params: Record<TParams[number], string>;
39
40 /** Akte app global data. */
41 globalData: TGlobalData;
42}) => Awaitable<TData>;
43
44/** A function responsible for fetching all the data required to render files. */
45export type FilesBulkDataFn<TGlobalData, TData> = (context: {
46 /** Akte app global data. */
47 globalData: TGlobalData;
48}) => Awaitable<Record<string, TData>>;
49
50export type FilesDefinition<TGlobalData, TParams extends string[], TData> = {
51 /**
52 * Path pattern for the Akte files.
53 *
54 * @example
55 * "/";
56 * "/foo";
57 * "/bar.json";
58 * "/posts/:slug";
59 * "/posts/:taxonomy/:slug";
60 * "/pages/**";
61 * "/assets/**.json";
62 */
63 path: Path<TParams>;
64
65 /**
66 * A function responsible for fetching the data required to render a given
67 * file. Used for optimization like server side rendering or serverless.
68 *
69 * Throwing a {@link NotFoundError} makes the file at path to be treated as a
70 * 404, any other error makes it treated as a 500.
71 */
72 data?: FilesDataFn<TGlobalData, TParams, TData>;
73
74 /** A function responsible for fetching all the data required to render files. */
75 bulkData?: FilesBulkDataFn<TGlobalData, TData>;
76
77 /**
78 * A function responsible for rendering the file.
79 *
80 * @param context - Resolved file path, app global data, and data to render
81 * the file.
82 * @returns Rendered file.
83 */
84 render: (context: {
85 /** Path to render. */
86 path: string;
87
88 /** Akte app global data. */
89 globalData: TGlobalData;
90
91 /** File data for path. */
92 data: TData;
93 }) => Awaitable<string>;
94};
95
96const debug = createDebugger("akte:files");
97const debugRender = createDebugger("akte:files:render");
98const debugCache = createDebugger("akte:files:cache");
99
100/** An Akte files, managing its data cascade and rendering process. */
101export class AkteFiles<
102 TGlobalData = unknown,
103 TParams extends string[] = string[],
104 // eslint-disable-next-line @typescript-eslint/no-explicit-any
105 TData = any,
106> {
107 protected definition: FilesDefinition<TGlobalData, TParams, TData>;
108
109 /** Path pattern of this Akte files. */
110 get path(): string {
111 return this.definition.path;
112 }
113
114 constructor(definition: FilesDefinition<TGlobalData, TParams, TData>) {
115 this.definition = definition;
116
117 debug("created %o", this.path);
118 }
119
120 /** @internal Prefer {@link AkteApp.render} or use at your own risks. */
121 async render(args: {
122 path: string;
123 params: Record<TParams[number], string>;
124 globalData: TGlobalData;
125 }): Promise<string> {
126 const data = await this.getDataPromise(args);
127
128 return this.definition.render({
129 path: args.path,
130 globalData: args.globalData,
131 data,
132 });
133 }
134
135 /** @internal Prefer {@link AkteApp.renderAll} or use at your own risks. */
136 async renderAll(args: {
137 globalData: TGlobalData;
138 }): Promise<Record<string, string>> {
139 if (!this.definition.bulkData) {
140 debugRender("no files to render %o", this.path);
141
142 return {};
143 }
144
145 debugRender("rendering files... %o", this.path);
146
147 const bulkData = await this.getBulkDataPromise(args);
148
149 const render = async (
150 path: string,
151 data: TData,
152 ): Promise<[string, string]> => {
153 const content = await this.definition.render({
154 path,
155 globalData: args.globalData,
156 data,
157 });
158
159 debugRender("rendered %o", path);
160
161 return [pathToFilePath(path), content];
162 };
163
164 const promises: Awaitable<[string, string]>[] = [];
165 for (const path in bulkData) {
166 const data = bulkData[path];
167
168 promises.push(render(path, data));
169 }
170
171 const fileEntries = await Promise.all(Object.values(promises));
172
173 debugRender(
174 `rendered %o ${fileEntries.length > 1 ? "files" : "file"} %o`,
175 fileEntries.length,
176 this.path,
177 );
178
179 return Object.fromEntries(fileEntries);
180 }
181
182 /** @internal Prefer {@link AkteApp.clearCache} or use at your own risks. */
183 clearCache(): void {
184 this._dataPromiseMap = new Map();
185 this._bulkDataPromise = undefined;
186 }
187
188 private _dataPromiseMap: Map<string, Awaitable<TData>> = new Map();
189
190 protected getDataPromise: FilesDataFn<TGlobalData, TParams, TData> = (
191 context,
192 ) => {
193 const maybePromise = this._dataPromiseMap.get(context.path);
194 if (maybePromise) {
195 debugCache("using cached data %o", context.path);
196
197 return maybePromise;
198 }
199
200 debugCache("retrieving data... %o", context.path);
201
202 let promise: Awaitable<TData>;
203 if (this.definition.data) {
204 promise = this.definition.data(context);
205 } else if (this.definition.bulkData) {
206 const dataFromBulkData = async (path: string): Promise<TData> => {
207 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
208 const bulkData = await this.definition.bulkData!({
209 globalData: context.globalData,
210 });
211
212 if (path in bulkData) {
213 return bulkData[path];
214 }
215
216 throw new NotFoundError(path);
217 };
218
219 promise = dataFromBulkData(context.path);
220 } else {
221 throw new Error(
222 `Cannot render file for path \`${context.path}\`, no \`data\` or \`bulkData\` function available`,
223 );
224 }
225
226 if (promise instanceof Promise) {
227 promise
228 .then(() => {
229 debugCache("retrieved data %o", context.path);
230 })
231 .catch(() => {});
232 } else {
233 debugCache("retrieved data %o", context.path);
234 }
235
236 this._dataPromiseMap.set(context.path, promise);
237
238 return promise;
239 };
240
241 private _bulkDataPromise: Awaitable<Record<string, TData>> | undefined;
242
243 protected getBulkDataPromise: FilesBulkDataFn<TGlobalData, TData> = (
244 context,
245 ) => {
246 if (!this._bulkDataPromise) {
247 debugCache("retrieving bulk data... %o", this.path);
248
249 const bulkDataPromise =
250 this.definition.bulkData?.(context) || ({} as Record<string, TData>);
251
252 if (bulkDataPromise instanceof Promise) {
253 bulkDataPromise.then(() => {
254 debugCache("retrieved bulk data %o", this.path);
255 });
256 } else {
257 debugCache("retrieved bulk data %o", this.path);
258 }
259
260 this._bulkDataPromise = bulkDataPromise;
261 } else {
262 debugCache("using cached bulk data %o", this.path);
263 }
264
265 return this._bulkDataPromise;
266 };
267}