summaryrefslogtreecommitdiffstats
path: root/src/deps/strapi
diff options
context:
space:
mode:
authorVolpeon <git@volpeon.ink>2026-07-26 11:57:13 +0200
committerVolpeon <git@volpeon.ink>2026-07-26 11:57:13 +0200
commit5c899b4a5c2c07398151960b2b86d238b49d5bf4 (patch)
treeccc743f27ba234a25396cb24b6124c139d13b027 /src/deps/strapi
parentUse Corvos 0.1.4 (diff)
downloadcorvos-website-master.tar.gz
corvos-website-master.tar.bz2
corvos-website-master.zip
Better Strapi integrationHEADmaster
Diffstat (limited to 'src/deps/strapi')
-rw-r--r--src/deps/strapi/blocks.tsx125
-rw-r--r--src/deps/strapi/loader.ts116
-rw-r--r--src/deps/strapi/schema.ts143
3 files changed, 384 insertions, 0 deletions
diff --git a/src/deps/strapi/blocks.tsx b/src/deps/strapi/blocks.tsx
new file mode 100644
index 0000000..bb60f71
--- /dev/null
+++ b/src/deps/strapi/blocks.tsx
@@ -0,0 +1,125 @@
1import { ContentImage } from "$/ui/objects/ContentImage.tsx";
2
3import { type Node, type DefaultInlineNode } from "./schema.ts";
4
5export function walkBlocks(
6 content: (Node | DefaultInlineNode)[],
7 fn: (_: Node | DefaultInlineNode) => unknown,
8): void {
9 for (const node of content) {
10 fn(node);
11
12 switch (node.type) {
13 case "heading":
14 case "code":
15 case "quote":
16 case "paragraph":
17 case "list":
18 case "list-item":
19 case "link": {
20 return walkBlocks(node.children, fn);
21 }
22 }
23 }
24}
25
26function blocksNodeToTSX(node: Node | DefaultInlineNode) {
27 switch (true) {
28 case node.type === "heading": {
29 switch (node.level) {
30 case 1:
31 return <h1>{blocksToTSX(node.children)}</h1>;
32 case 2:
33 return <h2>{blocksToTSX(node.children)}</h2>;
34 case 3:
35 return <h3>{blocksToTSX(node.children)}</h3>;
36 case 4:
37 return <h4>{blocksToTSX(node.children)}</h4>;
38 case 5:
39 return <h5>{blocksToTSX(node.children)}</h5>;
40 default:
41 return <h6>{blocksToTSX(node.children)}</h6>;
42 }
43 }
44
45 case node.type === "image" && !!node.image.caption: {
46 return (
47 <figure>
48 <ContentImage src={node.image.url} alt={node.image.alternativeText ?? undefined} />
49 <figcaption>{node.image.caption}</figcaption>
50 </figure>
51 );
52 }
53
54 case node.type === "image": {
55 return <ContentImage src={node.image.url} alt={node.image.alternativeText ?? undefined} />;
56 }
57
58 case node.type === "code": {
59 return (
60 <pre>
61 <code>{blocksToTSX(node.children)}</code>
62 </pre>
63 );
64 }
65
66 case node.type === "quote": {
67 return <blockquote>{blocksToTSX(node.children)}</blockquote>;
68 }
69
70 case node.type === "paragraph" &&
71 node.children.length === 1 &&
72 node.children[0]?.type === "text" &&
73 node.children[0].text === "---": {
74 return <hr />;
75 }
76
77 case node.type === "paragraph": {
78 return <p>{blocksToTSX(node.children)}</p>;
79 }
80
81 case node.type === "list" && node.format === "ordered": {
82 return <ol>${blocksToTSX(node.children)}</ol>;
83 }
84
85 case node.type === "list": {
86 return <ul>${blocksToTSX(node.children)}</ul>;
87 }
88
89 case node.type === "list-item": {
90 return <li>${blocksToTSX(node.children)}</li>;
91 }
92
93 case node.type === "link": {
94 return <a href={node.url}>{blocksToTSX(node.children)}</a>;
95 }
96
97 case node.type === "text": {
98 return [
99 node.bold ? "strong" : undefined,
100 node.code ? "code" : undefined,
101 node.italic ? "em" : undefined,
102 node.strikethrough ? "del" : undefined,
103 node.underline ? "u" : undefined,
104 ].reduce((res: JSX.Element, Tag) => (Tag ? <Tag>{res}</Tag> : res), node.text);
105 }
106 }
107}
108
109export function blocksToTSX(
110 content: (Node | DefaultInlineNode)[],
111 filter: (node: Node | DefaultInlineNode) => boolean = () => true,
112): JSX.Element {
113 return content
114 .filter(filter)
115 .map(blocksNodeToTSX)
116 .filter((s) => s !== undefined);
117}
118
119export const Blocks = ({
120 content,
121 filter = () => true,
122}: {
123 content: (Node | DefaultInlineNode)[];
124 filter?: (node: Node | DefaultInlineNode) => boolean;
125}) => blocksToTSX(content, filter);
diff --git a/src/deps/strapi/loader.ts b/src/deps/strapi/loader.ts
new file mode 100644
index 0000000..c3b8e85
--- /dev/null
+++ b/src/deps/strapi/loader.ts
@@ -0,0 +1,116 @@
1import type { StandardSchemaV1 } from "corvos/deps/standard_schema.ts";
2
3import { type } from "arktype";
4import { PipelineStageError } from "corvos";
5import { Severity } from "corvos/core/error.ts";
6
7import { strapiCollectionResponseSchema, strapiItemResponseSchema } from "./schema.ts";
8
9async function* loadPaginated(url: URL | string, init?: RequestInit) {
10 let pageSize: number | undefined;
11 let pages = 1;
12
13 for (let i = 0; i < pages; ++i) {
14 const pageUrl = new URL(url);
15
16 if (pageSize) {
17 pageUrl.searchParams.set("pagination[page]", `${i + 1}`);
18 pageUrl.searchParams.set("pagination[pageSize]", `${pageSize}`);
19 }
20
21 const res = await fetch(pageUrl, init);
22 const json = await res.json();
23
24 const dec = strapiCollectionResponseSchema(json);
25
26 if (dec instanceof type.errors) {
27 throw new Error(dec.summary);
28 }
29
30 pages = dec.meta.pagination.pageCount;
31 pageSize = dec.meta.pagination.pageSize;
32
33 for (const item of dec.data) {
34 yield item;
35 }
36 }
37}
38
39export class StrapiLoader {
40 init: RequestInit;
41
42 constructor(
43 readonly host: string | URL,
44 token: string,
45 ) {
46 this.init = {
47 headers: {
48 Authorization: `Bearer ${token}`,
49 },
50 keepalive: true,
51 };
52 }
53
54 async loadSingle<T>(
55 collection: string,
56 schema: StandardSchemaV1<unknown, T>,
57 query: Record<string, string> = {},
58 ) {
59 const url = new URL(`/api/${collection}`, this.host);
60 Object.entries(query).forEach(([key, value]) => {
61 url.searchParams.append(key, value);
62 });
63
64 const res = await fetch(url, this.init);
65 const json = await res.json();
66
67 const dec = strapiItemResponseSchema(json);
68
69 if (dec instanceof type.errors) {
70 throw new Error(`Schema mismatch in ${url}:\n${dec.summary}`);
71 }
72
73 const dec2 = await schema["~standard"].validate(dec.data);
74
75 if (dec2.issues) {
76 throw new Error(`Schema mismatch in ${url}:\n${dec2.issues.join("\n")}`);
77 }
78
79 return {
80 documentId: dec.data.documentId,
81 publishedAt: dec.data.publishedAt,
82 updatedAt: dec.data.updatedAt,
83 ...dec2.value,
84 };
85 }
86
87 async *loadCollection<T>(
88 collection: string,
89 schema: StandardSchemaV1<unknown, T>,
90 query: Record<string, string> = {},
91 ) {
92 const url = new URL(`/api/${collection}`, this.host);
93 Object.entries(query).forEach(([key, value]) => {
94 url.searchParams.append(key, value);
95 });
96
97 for await (const item of loadPaginated(url, this.init)) {
98 const dec = await schema["~standard"].validate(item);
99
100 if (dec.issues) {
101 yield new PipelineStageError(
102 Severity.ERROR,
103 `Schema mismatch in ${url}`,
104 dec.issues.join("\n"),
105 );
106 } else {
107 yield {
108 documentId: item.documentId,
109 publishedAt: item.publishedAt,
110 updatedAt: item.updatedAt,
111 ...dec.value,
112 };
113 }
114 }
115 }
116}
diff --git a/src/deps/strapi/schema.ts b/src/deps/strapi/schema.ts
new file mode 100644
index 0000000..6161680
--- /dev/null
+++ b/src/deps/strapi/schema.ts
@@ -0,0 +1,143 @@
1import { type } from "arktype";
2
3export enum HeadingBlockLevel {
4 L1 = 1,
5 L2 = 2,
6 L3 = 3,
7 L4 = 4,
8 L5 = 5,
9 L6 = 6,
10}
11
12export const fileAttributeSchema = type({
13 "alternativeText?": "string | null",
14 "caption?": "string | null",
15 "createdAt": "string",
16 "documentId": "string",
17 "ext": "string",
18 "formats?": "Record<string, unknown> | null",
19 "hash": "string",
20 "height?": "number | null",
21 "mime": "string",
22 "name": "string",
23 "previewUrl?": "string | null",
24 "provider": "string",
25 "provider_metadata?": "unknown",
26 "size": "number",
27 "updatedAt": "string",
28 "url": "string",
29 "width?": "number | null",
30});
31
32export type FileAttribute = typeof fileAttributeSchema.infer;
33
34export const imageAttributeSchema = fileAttributeSchema.narrow(
35 (o): o is Omit<typeof o, "width" | "height"> & { width: number; height: number } =>
36 !!o.width && !!o.height,
37);
38
39export type ImageAttribute = typeof imageAttributeSchema.infer;
40
41const blocksAttributeScope = type
42 .scope({
43 codeBlockNode: {
44 "children": "defaultInlineNode[]",
45 "language?": "string",
46 "type": "'code'",
47 },
48
49 defaultInlineNode: "textInlineNode | linkInlineNode",
50
51 headingBlockNode: {
52 children: "defaultInlineNode[]",
53 level: "1 | 2 | 3 | 4 | 5 | 6",
54 type: "'heading'",
55 },
56
57 imageBlockNode: {
58 children: type({
59 text: "string",
60 type: "'text'",
61 }).array(),
62 image: fileAttributeSchema.omit("documentId"),
63 type: "'image'",
64 },
65
66 linkInlineNode: {
67 children: "textInlineNode[]",
68 type: "'link'",
69 url: "string",
70 },
71
72 listBlockNode: {
73 children: "(listItemInlineNode | listBlockNode)[]",
74 format: "'ordered' | 'unordered'",
75 type: "'list'",
76 },
77
78 listItemInlineNode: {
79 children: "defaultInlineNode[]",
80 type: "'list-item'",
81 },
82
83 node: "rootNode | nonTextInlineNode",
84
85 nonTextInlineNode: "linkInlineNode | listItemInlineNode",
86
87 paragraphBlockNode: {
88 children: "defaultInlineNode[]",
89 type: "'paragraph'",
90 },
91
92 quoteBlockNode: {
93 children: "defaultInlineNode[]",
94 type: "'quote'",
95 },
96
97 rootNode:
98 "paragraphBlockNode | quoteBlockNode | codeBlockNode | headingBlockNode | listBlockNode | imageBlockNode",
99
100 textInlineNode: {
101 "bold?": "boolean",
102 "code?": "boolean",
103 "italic?": "boolean",
104 "strikethrough?": "boolean",
105 "text": "string",
106 "type": "'text'",
107 "underline?": "boolean",
108 },
109 })
110 .export();
111
112export const defaultInlineNodeSchema = blocksAttributeScope.defaultInlineNode;
113
114export type DefaultInlineNode = typeof defaultInlineNodeSchema.infer;
115
116export const nodeSchema = blocksAttributeScope.node;
117
118export type Node = typeof nodeSchema.infer;
119
120export const strapiItemSchema = type({
121 "[string]": "unknown",
122 "documentId": "string",
123 "publishedAt": "string.date.parse | null",
124 "updatedAt": "string.date.parse",
125});
126
127export type StrapiItem = typeof strapiItemSchema.infer;
128
129export const strapiCollectionResponseSchema = type({
130 data: strapiItemSchema.array(),
131 meta: {
132 pagination: {
133 page: "number",
134 pageCount: "number",
135 pageSize: "number",
136 total: "number",
137 },
138 },
139});
140
141export const strapiItemResponseSchema = type({
142 data: strapiItemSchema,
143});