summaryrefslogtreecommitdiffstats
path: root/src/deps/strapi.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/deps/strapi.tsx')
-rw-r--r--src/deps/strapi.tsx416
1 files changed, 416 insertions, 0 deletions
diff --git a/src/deps/strapi.tsx b/src/deps/strapi.tsx
new file mode 100644
index 0000000..de2e495
--- /dev/null
+++ b/src/deps/strapi.tsx
@@ -0,0 +1,416 @@
1import type { Pipeline } from "corvos";
2import type { StandardSchemaV1 } from "corvos/deps/standard_schema.ts";
3
4import { type } from "arktype";
5import { Page, PipelineStageError } from "corvos";
6import { Severity } from "corvos/core/error.ts";
7import { replaceExtension } from "corvos/core/utils/path.ts";
8
9import { ContentImage } from "$/ui/objects/ContentImage.tsx";
10
11export enum HeadingBlockLevel {
12 L1 = 1,
13 L2 = 2,
14 L3 = 3,
15 L4 = 4,
16 L5 = 5,
17 L6 = 6,
18}
19
20export const fileAttributeSchema = type({
21 "alternativeText?": "string | null",
22 "caption?": "string | null",
23 "createdAt": "string",
24 "documentId": "string",
25 "ext": "string",
26 "formats?": "Record<string, unknown> | null",
27 "hash": "string",
28 "height?": "number | null",
29 "mime": "string",
30 "name": "string",
31 "previewUrl?": "string | null",
32 "provider": "string",
33 "provider_metadata?": "unknown",
34 "size": "number",
35 "updatedAt": "string",
36 "url": "string",
37 "width?": "number | null",
38});
39
40export type FileAttribute = typeof fileAttributeSchema.infer;
41
42export const imageAttributeSchema = fileAttributeSchema.narrow(
43 (o): o is Omit<typeof o, "width" | "height"> & { width: number; height: number } =>
44 !!o.width && !!o.height,
45);
46
47export type ImageAttribute = typeof imageAttributeSchema.infer;
48
49const blocksAttributeScope = type
50 .scope({
51 codeBlockNode: {
52 "children": "defaultInlineNode[]",
53 "language?": "string",
54 "type": "'code'",
55 },
56
57 defaultInlineNode: "textInlineNode | linkInlineNode",
58
59 headingBlockNode: {
60 children: "defaultInlineNode[]",
61 level: "1 | 2 | 3 | 4 | 5 | 6",
62 type: "'heading'",
63 },
64
65 imageBlockNode: {
66 children: type({
67 text: "string",
68 type: "'text'",
69 }).array(),
70 image: fileAttributeSchema.omit("documentId"),
71 type: "'image'",
72 },
73
74 linkInlineNode: {
75 children: "textInlineNode[]",
76 type: "'link'",
77 url: "string",
78 },
79
80 listBlockNode: {
81 children: "(listItemInlineNode | listBlockNode)[]",
82 format: "'ordered' | 'unordered'",
83 type: "'list'",
84 },
85
86 listItemInlineNode: {
87 children: "defaultInlineNode[]",
88 type: "'list-item'",
89 },
90
91 node: "rootNode | nonTextInlineNode",
92
93 nonTextInlineNode: "linkInlineNode | listItemInlineNode",
94
95 paragraphBlockNode: {
96 children: "defaultInlineNode[]",
97 type: "'paragraph'",
98 },
99
100 quoteBlockNode: {
101 children: "defaultInlineNode[]",
102 type: "'quote'",
103 },
104
105 rootNode:
106 "paragraphBlockNode | quoteBlockNode | codeBlockNode | headingBlockNode | listBlockNode | imageBlockNode",
107
108 textInlineNode: {
109 "bold?": "boolean",
110 "code?": "boolean",
111 "italic?": "boolean",
112 "strikethrough?": "boolean",
113 "text": "string",
114 "type": "'text'",
115 "underline?": "boolean",
116 },
117 })
118 .export();
119
120export const defaultInlineNodeSchema = blocksAttributeScope.defaultInlineNode;
121
122export type DefaultInlineNode = typeof defaultInlineNodeSchema.infer;
123
124export const nodeSchema = blocksAttributeScope.node;
125
126export type Node = typeof nodeSchema.infer;
127
128export const strapiItemSchema = type({
129 "[string]": "unknown",
130 "documentId": "string",
131 "publishedAt": "string.date.parse | null",
132 "updatedAt": "string.date.parse",
133});
134
135export type StrapiItem = typeof strapiItemSchema.infer;
136
137export function walkBlocks(
138 content: (Node | DefaultInlineNode)[],
139 fn: (_: Node | DefaultInlineNode) => unknown,
140): void {
141 for (const node of content) {
142 fn(node);
143
144 switch (node.type) {
145 case "heading":
146 case "code":
147 case "quote":
148 case "paragraph":
149 case "list":
150 case "list-item":
151 case "link": {
152 return walkBlocks(node.children, fn);
153 }
154 }
155 }
156}
157
158function blocksNodeToTSX(node: Node | DefaultInlineNode) {
159 switch (true) {
160 case node.type === "heading": {
161 switch (node.level) {
162 case 1:
163 return <h1>{blocksToTSX(node.children)}</h1>;
164 case 2:
165 return <h2>{blocksToTSX(node.children)}</h2>;
166 case 3:
167 return <h3>{blocksToTSX(node.children)}</h3>;
168 case 4:
169 return <h4>{blocksToTSX(node.children)}</h4>;
170 case 5:
171 return <h5>{blocksToTSX(node.children)}</h5>;
172 default:
173 return <h6>{blocksToTSX(node.children)}</h6>;
174 }
175 }
176
177 case node.type === "image" && !!node.image.caption: {
178 return (
179 <figure>
180 <ContentImage src={node.image.url} alt={node.image.alternativeText ?? undefined} />
181 <figcaption>{node.image.caption}</figcaption>
182 </figure>
183 );
184 }
185
186 case node.type === "image": {
187 return <ContentImage src={node.image.url} alt={node.image.alternativeText ?? undefined} />;
188 }
189
190 case node.type === "code": {
191 return (
192 <pre>
193 <code>{blocksToTSX(node.children)}</code>
194 </pre>
195 );
196 }
197
198 case node.type === "quote": {
199 return <blockquote>{blocksToTSX(node.children)}</blockquote>;
200 }
201
202 case node.type === "paragraph" &&
203 node.children.length === 1 &&
204 node.children[0]?.type === "text" &&
205 node.children[0].text === "---": {
206 return <hr />;
207 }
208
209 case node.type === "paragraph": {
210 return <p>{blocksToTSX(node.children)}</p>;
211 }
212
213 case node.type === "list" && node.format === "ordered": {
214 return <ol>${blocksToTSX(node.children)}</ol>;
215 }
216
217 case node.type === "list": {
218 return <ul>${blocksToTSX(node.children)}</ul>;
219 }
220
221 case node.type === "list-item": {
222 return <li>${blocksToTSX(node.children)}</li>;
223 }
224
225 case node.type === "link": {
226 return <a href={node.url}>{blocksToTSX(node.children)}</a>;
227 }
228
229 case node.type === "text": {
230 return [
231 node.bold ? "strong" : undefined,
232 node.code ? "code" : undefined,
233 node.italic ? "em" : undefined,
234 node.strikethrough ? "del" : undefined,
235 node.underline ? "u" : undefined,
236 ].reduce((res: JSX.Element, Tag) => (Tag ? <Tag>{res}</Tag> : res), node.text);
237 }
238 }
239}
240
241export function blocksToTSX(
242 content: (Node | DefaultInlineNode)[],
243 filter: (node: Node | DefaultInlineNode) => boolean = () => true,
244): JSX.Element {
245 return content
246 .filter(filter)
247 .map(blocksNodeToTSX)
248 .filter((s) => s !== undefined);
249}
250
251export const strapiCollectionResponseSchema = type({
252 data: strapiItemSchema.array(),
253 meta: {
254 pagination: {
255 page: "number",
256 pageCount: "number",
257 pageSize: "number",
258 total: "number",
259 },
260 },
261});
262
263export const strapiItemResponseSchema = type({
264 data: strapiItemSchema,
265});
266
267async function* loadPaginated(url: URL | string, init?: RequestInit) {
268 let pageSize: number | undefined;
269 let pages = 1;
270
271 for (let i = 0; i < pages; ++i) {
272 const pageUrl = new URL(url);
273
274 if (pageSize) {
275 pageUrl.searchParams.set("pagination[page]", `${i + 1}`);
276 pageUrl.searchParams.set("pagination[pageSize]", `${pageSize}`);
277 }
278
279 const res = await fetch(pageUrl, init);
280 const json = await res.json();
281
282 const dec = strapiCollectionResponseSchema(json);
283
284 if (dec instanceof type.errors) {
285 throw new Error(dec.summary);
286 }
287
288 pages = dec.meta.pagination.pageCount;
289 pageSize = dec.meta.pagination.pageSize;
290
291 for (const item of dec.data) {
292 yield item;
293 }
294 }
295}
296
297export class StrapiLoader {
298 init: RequestInit;
299
300 constructor(
301 readonly host: string | URL,
302 token: string,
303 readonly publicHost = host,
304 ) {
305 this.init = {
306 headers: {
307 Authorization: `Bearer ${token}`,
308 },
309 keepalive: true,
310 };
311 }
312
313 async loadSingle<T>(
314 collection: string,
315 schema: StandardSchemaV1<unknown, T>,
316 query: Record<string, string> = {},
317 ) {
318 const url = new URL(`/api/${collection}`, this.host);
319 Object.entries(query).forEach(([key, value]) => {
320 url.searchParams.append(key, value);
321 });
322
323 const res = await fetch(url, this.init);
324 const json = await res.json();
325
326 const dec = strapiItemResponseSchema(json);
327
328 if (dec instanceof type.errors) {
329 throw new Error(`Schema mismatch in ${url}:\n${dec.summary}`);
330 }
331
332 const dec2 = await schema["~standard"].validate(dec.data);
333
334 if (dec2.issues) {
335 throw new Error(`Schema mismatch in ${url}:\n${dec2.issues.join("\n")}`);
336 }
337
338 return {
339 documentId: dec.data.documentId,
340 publishedAt: dec.data.publishedAt,
341 updatedAt: dec.data.updatedAt,
342 ...dec2.value,
343 };
344 }
345
346 async *loadCollection<T>(
347 collection: string,
348 schema: StandardSchemaV1<unknown, T>,
349 query: Record<string, string> = {},
350 ) {
351 const url = new URL(`/api/${collection}`, this.host);
352 Object.entries(query).forEach(([key, value]) => {
353 url.searchParams.append(key, value);
354 });
355
356 for await (const item of loadPaginated(url, this.init)) {
357 const dec = await schema["~standard"].validate(item);
358
359 if (dec.issues) {
360 yield new PipelineStageError(
361 Severity.ERROR,
362 `Schema mismatch in ${url}`,
363 dec.issues.join("\n"),
364 );
365 } else {
366 yield {
367 documentId: item.documentId,
368 publishedAt: item.publishedAt,
369 updatedAt: item.updatedAt,
370 ...dec.value,
371 };
372 }
373 }
374 }
375
376 readonly resolvedFileAttributeSchema = fileAttributeSchema.pipe(({ url, previewUrl, ...o }) => ({
377 ...o,
378 url: new URL(url, this.publicHost).href,
379 ...(previewUrl ? { previewUrl: new URL(previewUrl, this.publicHost).href } : {}),
380 }));
381
382 readonly resolvedImageAttributeSchema = imageAttributeSchema.pipe((o) => ({
383 ...o,
384 previewUrl: o.previewUrl ? new URL(o.previewUrl, this.publicHost).href : undefined,
385 url: new URL(o.url, this.publicHost).href,
386 }));
387}
388
389export function strapi() {
390 return <Loaders extends string, Out extends { ".strapi": [Node[], {}] }, Helpers extends {}>(
391 pipeline: Pipeline<Loaders, Out, Helpers>,
392 ) =>
393 pipeline.process("strapi", ".strapi", function* (page) {
394 yield new Page(
395 ".jsxelement",
396 blocksToTSX(page.content),
397 page.data as Out[".strapi"][1],
398 replaceExtension(page.url, ".jsx"),
399 page,
400 );
401 });
402}
403
404export const Blocks = ({
405 content,
406 filter = () => true,
407}: {
408 content: (Node | DefaultInlineNode)[];
409 filter?: (node: Node | DefaultInlineNode) => boolean;
410}) => blocksToTSX(content, filter);
411
412const strapiUrl = Deno.env.get("STRAPI_URL") ?? "";
413const strapiPublicUrl = Deno.env.get("STRAPI_PUBLIC_URL");
414const strapiToken = Deno.env.get("STRAPI_TOKEN") ?? "";
415
416export const strapiLoader = new StrapiLoader(strapiUrl, strapiToken, strapiPublicUrl);