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.ts | 110 ++++++++++++ src/deps/strapi.tsx | 416 --------------------------------------------- src/deps/strapi/blocks.tsx | 125 ++++++++++++++ src/deps/strapi/loader.ts | 116 +++++++++++++ src/deps/strapi/schema.ts | 143 ++++++++++++++++ 5 files changed, 494 insertions(+), 416 deletions(-) create mode 100644 src/deps/strapi.ts delete mode 100644 src/deps/strapi.tsx 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') diff --git a/src/deps/strapi.ts b/src/deps/strapi.ts new file mode 100644 index 0000000..32e49b0 --- /dev/null +++ b/src/deps/strapi.ts @@ -0,0 +1,110 @@ +import type { Pipeline, PipelineStageError } from "corvos"; +import type { StandardSchemaV1, StandardTypedV1 } from "corvos/deps/standard_schema.ts"; + +import { Page } from "corvos"; +import { replaceExtension } from "corvos/core/utils/path.ts"; + +import type { Node } from "./strapi/schema.ts"; + +import { blocksToTSX } from "./strapi/blocks.tsx"; +import { StrapiLoader } from "./strapi/loader.ts"; + +export type { Node } from "./strapi/schema.ts"; +export { nodeSchema, fileAttributeSchema, imageAttributeSchema } from "./strapi/schema.ts"; +export { Blocks } from "./strapi/blocks.tsx"; + +export interface StrapiContent { + type: Type; + schema: StandardSchemaV1; + query?: Record; +} + +type ContentSingle> = { + [Key in keyof T]: T[Key]["type"] extends "single" ? Key : never; +}[keyof T]; + +type ContentCollection> = { + [Key in keyof T]: T[Key]["type"] extends "collection" ? Key : never; +}[keyof T]; + +export interface Options> { + url: string; + token: string; + content: Content; +} + +export function strapi>(options: Options) { + const loader = new StrapiLoader(options.url, options.token); + + function load & string>( + key: Key, + query?: Record, + ): Promise< + { + documentId: string; + publishedAt: Date | null; + updatedAt: Date; + } & StandardTypedV1.InferOutput + >; + function load & string>( + key: Key, + query?: Record, + ): AsyncGenerator< + | PipelineStageError + | ({ + documentId: string; + publishedAt: Date | null; + updatedAt: Date; + } & StandardTypedV1.InferOutput), + void, + unknown + >; + function load( + key: Key, + query?: Record, + ): + | Promise< + { + documentId: string; + publishedAt: Date | null; + updatedAt: Date; + } & StandardTypedV1.InferOutput + > + | AsyncGenerator< + | PipelineStageError + | ({ + documentId: string; + publishedAt: Date | null; + updatedAt: Date; + } & StandardTypedV1.InferOutput), + void, + unknown + > { + if (options.content[key]!.type === "single") { + return loader.loadSingle(key, options.content[key]!.schema, { + ...options.content[key]!.query, + ...query, + }); + } else { + return loader.loadCollection(key, options.content[key]!.schema, { + ...options.content[key]!.query, + ...query, + }); + } + } + + 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, + ); + }) + .helper("strapi", load); +} diff --git a/src/deps/strapi.tsx b/src/deps/strapi.tsx deleted file mode 100644 index 127dffc..0000000 --- a/src/deps/strapi.tsx +++ /dev/null @@ -1,416 +0,0 @@ -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); 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
      ${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 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