UNPKG

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