From c1e28224aa2e1450031669d9a7e359b63e04687d Mon Sep 17 00:00:00 2001 From: Volpeon Date: Wed, 22 Jul 2026 19:40:59 +0200 Subject: Init --- src/deps/strapi.tsx | 416 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 416 insertions(+) create mode 100644 src/deps/strapi.tsx (limited to 'src/deps') 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 @@ +import type { Pipeline } from "corvos"; +import type { StandardSchemaV1 } from "corvos/deps/standard_schema.ts"; + +import { type } from "arktype"; +import { Page, PipelineStageError } from "corvos"; +import { Severity } from "corvos/core/error.ts"; +import { replaceExtension } from "corvos/core/utils/path.ts"; + +import { ContentImage } from "$/ui/objects/ContentImage.tsx"; + +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 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
    ${blocksToTSX(node.children)}
; + } + + 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 strapiCollectionResponseSchema = type({ + data: strapiItemSchema.array(), + meta: { + pagination: { + page: "number", + pageCount: "number", + pageSize: "number", + total: "number", + }, + }, +}); + +export const strapiItemResponseSchema = type({ + data: strapiItemSchema, +}); + +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, + readonly publicHost = host, + ) { + 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, + }; + } + } + } + + readonly resolvedFileAttributeSchema = fileAttributeSchema.pipe(({ url, previewUrl, ...o }) => ({ + ...o, + url: new URL(url, this.publicHost).href, + ...(previewUrl ? { previewUrl: new URL(previewUrl, this.publicHost).href } : {}), + })); + + readonly resolvedImageAttributeSchema = imageAttributeSchema.pipe((o) => ({ + ...o, + previewUrl: o.previewUrl ? new URL(o.previewUrl, this.publicHost).href : undefined, + url: new URL(o.url, this.publicHost).href, + })); +} + +export function strapi() { + return ( + pipeline: Pipeline, + ) => + pipeline.process("strapi", ".strapi", function* (page) { + yield new Page( + ".jsxelement", + blocksToTSX(page.content), + page.data as Out[".strapi"][1], + replaceExtension(page.url, ".jsx"), + page, + ); + }); +} + +export const Blocks = ({ + content, + filter = () => true, +}: { + content: (Node | DefaultInlineNode)[]; + filter?: (node: Node | DefaultInlineNode) => boolean; +}) => blocksToTSX(content, filter); + +const strapiUrl = Deno.env.get("STRAPI_URL") ?? ""; +const strapiPublicUrl = Deno.env.get("STRAPI_PUBLIC_URL"); +const strapiToken = Deno.env.get("STRAPI_TOKEN") ?? ""; + +export const strapiLoader = new StrapiLoader(strapiUrl, strapiToken, strapiPublicUrl); -- cgit v1.3.1