diff options
Diffstat (limited to 'src/deps/strapi')
| -rw-r--r-- | src/deps/strapi/blocks.tsx | 125 | ||||
| -rw-r--r-- | src/deps/strapi/loader.ts | 116 | ||||
| -rw-r--r-- | src/deps/strapi/schema.ts | 143 |
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 @@ | |||
| 1 | import { ContentImage } from "$/ui/objects/ContentImage.tsx"; | ||
| 2 | |||
| 3 | import { type Node, type DefaultInlineNode } from "./schema.ts"; | ||
| 4 | |||
| 5 | export 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 | |||
| 26 | function 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 | |||
| 109 | export 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 | |||
| 119 | export 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 @@ | |||
| 1 | import type { StandardSchemaV1 } from "corvos/deps/standard_schema.ts"; | ||
| 2 | |||
| 3 | import { type } from "arktype"; | ||
| 4 | import { PipelineStageError } from "corvos"; | ||
| 5 | import { Severity } from "corvos/core/error.ts"; | ||
| 6 | |||
| 7 | import { strapiCollectionResponseSchema, strapiItemResponseSchema } from "./schema.ts"; | ||
| 8 | |||
| 9 | async 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 | |||
| 39 | export 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 @@ | |||
| 1 | import { type } from "arktype"; | ||
| 2 | |||
| 3 | export enum HeadingBlockLevel { | ||
| 4 | L1 = 1, | ||
| 5 | L2 = 2, | ||
| 6 | L3 = 3, | ||
| 7 | L4 = 4, | ||
| 8 | L5 = 5, | ||
| 9 | L6 = 6, | ||
| 10 | } | ||
| 11 | |||
| 12 | export 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 | |||
| 32 | export type FileAttribute = typeof fileAttributeSchema.infer; | ||
| 33 | |||
| 34 | export const imageAttributeSchema = fileAttributeSchema.narrow( | ||
| 35 | (o): o is Omit<typeof o, "width" | "height"> & { width: number; height: number } => | ||
| 36 | !!o.width && !!o.height, | ||
| 37 | ); | ||
| 38 | |||
| 39 | export type ImageAttribute = typeof imageAttributeSchema.infer; | ||
| 40 | |||
| 41 | const 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 | |||
| 112 | export const defaultInlineNodeSchema = blocksAttributeScope.defaultInlineNode; | ||
| 113 | |||
| 114 | export type DefaultInlineNode = typeof defaultInlineNodeSchema.infer; | ||
| 115 | |||
| 116 | export const nodeSchema = blocksAttributeScope.node; | ||
| 117 | |||
| 118 | export type Node = typeof nodeSchema.infer; | ||
| 119 | |||
| 120 | export const strapiItemSchema = type({ | ||
| 121 | "[string]": "unknown", | ||
| 122 | "documentId": "string", | ||
| 123 | "publishedAt": "string.date.parse | null", | ||
| 124 | "updatedAt": "string.date.parse", | ||
| 125 | }); | ||
| 126 | |||
| 127 | export type StrapiItem = typeof strapiItemSchema.infer; | ||
| 128 | |||
| 129 | export 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 | |||
| 141 | export const strapiItemResponseSchema = type({ | ||
| 142 | data: strapiItemSchema, | ||
| 143 | }); | ||
