From 5c899b4a5c2c07398151960b2b86d238b49d5bf4 Mon Sep 17 00:00:00 2001 From: Volpeon Date: Sun, 26 Jul 2026 11:57:13 +0200 Subject: Better Strapi integration --- src/deps/strapi/blocks.tsx | 125 +++++++++++++++++++++++++++++++++++++++ src/deps/strapi/loader.ts | 116 ++++++++++++++++++++++++++++++++++++ src/deps/strapi/schema.ts | 143 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 384 insertions(+) create mode 100644 src/deps/strapi/blocks.tsx create mode 100644 src/deps/strapi/loader.ts create mode 100644 src/deps/strapi/schema.ts (limited to 'src/deps/strapi') 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 @@ +import { ContentImage } from "$/ui/objects/ContentImage.tsx"; + +import { type Node, type DefaultInlineNode } from "./schema.ts"; + +export function walkBlocks( + content: (Node | DefaultInlineNode)[], + fn: (_: Node | DefaultInlineNode) => unknown, +): void { + for (const node of content) { + fn(node); + + switch (node.type) { + case "heading": + case "code": + case "quote": + case "paragraph": + case "list": + case "list-item": + case "link": { + return walkBlocks(node.children, fn); + } + } + } +} + +function blocksNodeToTSX(node: Node | DefaultInlineNode) { + switch (true) { + case node.type === "heading": { + switch (node.level) { + case 1: + return

{blocksToTSX(node.children)}

; + case 2: + return

{blocksToTSX(node.children)}

; + case 3: + return

{blocksToTSX(node.children)}

; + case 4: + return

{blocksToTSX(node.children)}

; + case 5: + return
{blocksToTSX(node.children)}
; + default: + return
{blocksToTSX(node.children)}
; + } + } + + case node.type === "image" && !!node.image.caption: { + return ( +
+ +
{node.image.caption}
+
+ ); + } + + case node.type === "image": { + return ; + } + + case node.type === "code": { + return ( +
+					{blocksToTSX(node.children)}
+				
+ ); + } + + case node.type === "quote": { + return
{blocksToTSX(node.children)}
; + } + + case node.type === "paragraph" && + node.children.length === 1 && + node.children[0]?.type === "text" && + node.children[0].text === "---": { + return
; + } + + case node.type === "paragraph": { + return

{blocksToTSX(node.children)}

; + } + + case node.type === "list" && node.format === "ordered": { + return
    ${blocksToTSX(node.children)}
; + } + + case node.type === "list": { + return ; + } + + case node.type === "list-item": { + return
  • ${blocksToTSX(node.children)}
  • ; + } + + case node.type === "link": { + return {blocksToTSX(node.children)}; + } + + case node.type === "text": { + return [ + node.bold ? "strong" : undefined, + node.code ? "code" : undefined, + node.italic ? "em" : undefined, + node.strikethrough ? "del" : undefined, + node.underline ? "u" : undefined, + ].reduce((res: JSX.Element, Tag) => (Tag ? {res} : res), node.text); + } + } +} + +export function blocksToTSX( + content: (Node | DefaultInlineNode)[], + filter: (node: Node | DefaultInlineNode) => boolean = () => true, +): JSX.Element { + return content + .filter(filter) + .map(blocksNodeToTSX) + .filter((s) => s !== undefined); +} + +export const Blocks = ({ + content, + filter = () => true, +}: { + content: (Node | DefaultInlineNode)[]; + filter?: (node: Node | DefaultInlineNode) => boolean; +}) => 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 @@ +import type { StandardSchemaV1 } from "corvos/deps/standard_schema.ts"; + +import { type } from "arktype"; +import { PipelineStageError } from "corvos"; +import { Severity } from "corvos/core/error.ts"; + +import { strapiCollectionResponseSchema, strapiItemResponseSchema } from "./schema.ts"; + +async function* loadPaginated(url: URL | string, init?: RequestInit) { + let pageSize: number | undefined; + let pages = 1; + + for (let i = 0; i < pages; ++i) { + const pageUrl = new URL(url); + + if (pageSize) { + pageUrl.searchParams.set("pagination[page]", `${i + 1}`); + pageUrl.searchParams.set("pagination[pageSize]", `${pageSize}`); + } + + const res = await fetch(pageUrl, init); + const json = await res.json(); + + const dec = strapiCollectionResponseSchema(json); + + if (dec instanceof type.errors) { + throw new Error(dec.summary); + } + + pages = dec.meta.pagination.pageCount; + pageSize = dec.meta.pagination.pageSize; + + for (const item of dec.data) { + yield item; + } + } +} + +export class StrapiLoader { + init: RequestInit; + + constructor( + readonly host: string | URL, + token: string, + ) { + this.init = { + headers: { + Authorization: `Bearer ${token}`, + }, + keepalive: true, + }; + } + + async loadSingle( + collection: string, + schema: StandardSchemaV1, + query: Record = {}, + ) { + const url = new URL(`/api/${collection}`, this.host); + Object.entries(query).forEach(([key, value]) => { + url.searchParams.append(key, value); + }); + + const res = await fetch(url, this.init); + const json = await res.json(); + + const dec = strapiItemResponseSchema(json); + + if (dec instanceof type.errors) { + throw new Error(`Schema mismatch in ${url}:\n${dec.summary}`); + } + + const dec2 = await schema["~standard"].validate(dec.data); + + if (dec2.issues) { + throw new Error(`Schema mismatch in ${url}:\n${dec2.issues.join("\n")}`); + } + + return { + documentId: dec.data.documentId, + publishedAt: dec.data.publishedAt, + updatedAt: dec.data.updatedAt, + ...dec2.value, + }; + } + + async *loadCollection( + collection: string, + schema: StandardSchemaV1, + query: Record = {}, + ) { + const url = new URL(`/api/${collection}`, this.host); + Object.entries(query).forEach(([key, value]) => { + url.searchParams.append(key, value); + }); + + for await (const item of loadPaginated(url, this.init)) { + const dec = await schema["~standard"].validate(item); + + if (dec.issues) { + yield new PipelineStageError( + Severity.ERROR, + `Schema mismatch in ${url}`, + dec.issues.join("\n"), + ); + } else { + yield { + documentId: item.documentId, + publishedAt: item.publishedAt, + updatedAt: item.updatedAt, + ...dec.value, + }; + } + } + } +} 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 @@ +import { type } from "arktype"; + +export enum HeadingBlockLevel { + L1 = 1, + L2 = 2, + L3 = 3, + L4 = 4, + L5 = 5, + L6 = 6, +} + +export const fileAttributeSchema = type({ + "alternativeText?": "string | null", + "caption?": "string | null", + "createdAt": "string", + "documentId": "string", + "ext": "string", + "formats?": "Record | null", + "hash": "string", + "height?": "number | null", + "mime": "string", + "name": "string", + "previewUrl?": "string | null", + "provider": "string", + "provider_metadata?": "unknown", + "size": "number", + "updatedAt": "string", + "url": "string", + "width?": "number | null", +}); + +export type FileAttribute = typeof fileAttributeSchema.infer; + +export const imageAttributeSchema = fileAttributeSchema.narrow( + (o): o is Omit & { width: number; height: number } => + !!o.width && !!o.height, +); + +export type ImageAttribute = typeof imageAttributeSchema.infer; + +const blocksAttributeScope = type + .scope({ + codeBlockNode: { + "children": "defaultInlineNode[]", + "language?": "string", + "type": "'code'", + }, + + defaultInlineNode: "textInlineNode | linkInlineNode", + + headingBlockNode: { + children: "defaultInlineNode[]", + level: "1 | 2 | 3 | 4 | 5 | 6", + type: "'heading'", + }, + + imageBlockNode: { + children: type({ + text: "string", + type: "'text'", + }).array(), + image: fileAttributeSchema.omit("documentId"), + type: "'image'", + }, + + linkInlineNode: { + children: "textInlineNode[]", + type: "'link'", + url: "string", + }, + + listBlockNode: { + children: "(listItemInlineNode | listBlockNode)[]", + format: "'ordered' | 'unordered'", + type: "'list'", + }, + + listItemInlineNode: { + children: "defaultInlineNode[]", + type: "'list-item'", + }, + + node: "rootNode | nonTextInlineNode", + + nonTextInlineNode: "linkInlineNode | listItemInlineNode", + + paragraphBlockNode: { + children: "defaultInlineNode[]", + type: "'paragraph'", + }, + + quoteBlockNode: { + children: "defaultInlineNode[]", + type: "'quote'", + }, + + rootNode: + "paragraphBlockNode | quoteBlockNode | codeBlockNode | headingBlockNode | listBlockNode | imageBlockNode", + + textInlineNode: { + "bold?": "boolean", + "code?": "boolean", + "italic?": "boolean", + "strikethrough?": "boolean", + "text": "string", + "type": "'text'", + "underline?": "boolean", + }, + }) + .export(); + +export const defaultInlineNodeSchema = blocksAttributeScope.defaultInlineNode; + +export type DefaultInlineNode = typeof defaultInlineNodeSchema.infer; + +export const nodeSchema = blocksAttributeScope.node; + +export type Node = typeof nodeSchema.infer; + +export const strapiItemSchema = type({ + "[string]": "unknown", + "documentId": "string", + "publishedAt": "string.date.parse | null", + "updatedAt": "string.date.parse", +}); + +export type StrapiItem = typeof strapiItemSchema.infer; + +export const strapiCollectionResponseSchema = type({ + data: strapiItemSchema.array(), + meta: { + pagination: { + page: "number", + pageCount: "number", + pageSize: "number", + total: "number", + }, + }, +}); + +export const strapiItemResponseSchema = type({ + data: strapiItemSchema, +}); -- cgit v1.3.1