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, ".jsxelement"), 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);