From 25f0305f9fa5594cebb009963fa082ac731e72cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 14:12:06 +0000 Subject: [PATCH] feat: add pckt and leaflet renderers following offprint pattern Agent-Logs-Url: https://github.com/chrisvander/at-astro-loader/sessions/418c6209-bbe7-4804-9782-a1cd3c845478 Co-authored-by: chrisvander <5359919+chrisvander@users.noreply.github.com> --- src/renderers/index.ts | 2 + src/renderers/leaflet.ts | 379 +++++++++++++++++++++++++++++++ src/renderers/pckt.ts | 392 +++++++++++++++++++++++++++++++++ src/renderers/standard-site.ts | 9 + 4 files changed, 782 insertions(+) create mode 100644 src/renderers/leaflet.ts create mode 100644 src/renderers/pckt.ts diff --git a/src/renderers/index.ts b/src/renderers/index.ts index 626525c..f8ac5dd 100644 --- a/src/renderers/index.ts +++ b/src/renderers/index.ts @@ -1 +1,3 @@ export * from "./standard-site" +export * as pckt from "./pckt" +export * as leaflet from "./leaflet" diff --git a/src/renderers/leaflet.ts b/src/renderers/leaflet.ts new file mode 100644 index 0000000..49a57d7 --- /dev/null +++ b/src/renderers/leaflet.ts @@ -0,0 +1,379 @@ +import { getBlobCidString, parseLexLink, type BlobRef } from "@atproto/lex" +import escapeHtml from "escape-html" +import katex from "katex" +import slugify from "slugify" +import { codeToHtml } from "shiki" +import { pub } from "../lexicons" +import type { + StandardSiteDocumentRendererOptions, + RenderedContent, + RendererFunction, +} from "./types" +import { mergeArr, utf8ByteToCodeUnitMap } from "./utils" + +type Facet = pub.leaflet.richtext.facet.Main + +function splitAndMerge(facets: Facet[]): Facet[] { + const points = new Set() + for (const f of facets) { + if (f.index.byteEnd <= f.index.byteStart) continue + points.add(f.index.byteStart) + points.add(f.index.byteEnd) + } + + const sorted = [...points].sort((a, b) => a - b) + const result: Facet[] = [] + for (let i = 0; i < sorted.length - 1; i++) { + const start = sorted[i] + const end = sorted[i + 1] + const active = facets.filter((f) => f.index.byteStart <= start && end <= f.index.byteEnd) + if (active.length === 0) continue + const merged = Array.from(new Set(active.flatMap((r) => r.features))) + result.push({ index: { byteStart: start, byteEnd: end }, features: merged }) + } + return result +} + +function applyFacetsToText(text: string, facets: Facet[]): { text: string; facet?: Facet }[] { + const byteMap = utf8ByteToCodeUnitMap(text) + const segments: { text: string; facet?: Facet }[] = [] + let cursor = 0 + for (const facet of facets) { + const start = byteMap[facet.index.byteStart] + const end = byteMap[facet.index.byteEnd] + if (start > cursor) segments.push({ text: text.slice(cursor, start) }) + segments.push({ text: text.slice(start, end), facet }) + cursor = end + } + if (cursor < text.length) segments.push({ text: text.slice(cursor) }) + return segments +} + +function applyFacet(html: string, features: Facet["features"]): string { + for (const f of features) { + if (f.$type === "pub.leaflet.richtext.facet#bold") html = `${html}` + if (f.$type === "pub.leaflet.richtext.facet#italic") html = `${html}` + if (f.$type === "pub.leaflet.richtext.facet#underline") html = `${html}` + if (f.$type === "pub.leaflet.richtext.facet#strikethrough") html = `${html}` + if (f.$type === "pub.leaflet.richtext.facet#code") html = `${html}` + if (f.$type === "pub.leaflet.richtext.facet#highlight") html = `${html}` + if (f.$type === "pub.leaflet.richtext.facet#link" && "uri" in f) + html = `${html}` + } + return html +} + +function renderRichTextHtml(plaintext: string, facets: Facet[] = []) { + const splitText = applyFacetsToText(plaintext, splitAndMerge(facets)) + return splitText + .map((st) => + st.facet ? applyFacet(escapeHtml(st.text), st.facet.features) : escapeHtml(st.text), + ) + .join("") +} + +function isRenderedContent(block: RenderedContent | undefined): block is RenderedContent { + return !!block +} + +function mergeMetadata(blocks: RenderedContent[]): RenderedContent["metadata"] { + return blocks + .map((b) => b.metadata) + .reduce( + (acc, curr) => ({ + ...acc, + ...curr, + imagePaths: mergeArr(acc?.imagePaths, curr?.imagePaths), + frontmatter: { ...acc?.frontmatter, ...curr?.frontmatter }, + headings: mergeArr(acc?.headings, curr?.headings), + }), + {}, + ) +} + +function blueskyPostUrl(uri: string) { + const match = /^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/]+)$/.exec(uri) + if (!match) return uri + return `https://bsky.app/profile/${encodeURIComponent(match[1])}/post/${encodeURIComponent(match[2])}` +} + +function resolveBlobSrc(blob: BlobRef | undefined, opts: StandardSiteDocumentRendererOptions) { + if (!blob) return undefined + if (!opts?.endpoint) throw new Error("Must have endpoint to resolve blob") + const cid = "$link" in blob ? parseLexLink(blob) : blob + return `${opts.endpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(opts.repoDid)}&cid=${encodeURIComponent(getBlobCidString(cid as BlobRef))}` +} + +/** + * Renders a single linearDocument block into HTML. + */ +async function renderLinearBlock( + block: pub.leaflet.pages.linearDocument.Block, + opts: StandardSiteDocumentRendererOptions, +): Promise { + const b = block.block + switch (b.$type) { + case "pub.leaflet.blocks.text": + return blockTextRenderer(b as pub.leaflet.blocks.text.Main, opts) + case "pub.leaflet.blocks.header": + return blockHeaderRenderer(b as pub.leaflet.blocks.header.Main, opts) + case "pub.leaflet.blocks.blockquote": + return blockBlockquoteRenderer(b as pub.leaflet.blocks.blockquote.Main, opts) + case "pub.leaflet.blocks.bskyPost": + return blockBskyPostRenderer(b as pub.leaflet.blocks.bskyPost.Main, opts) + case "pub.leaflet.blocks.button": + return blockButtonRenderer(b as pub.leaflet.blocks.button.Main, opts) + case "pub.leaflet.blocks.code": + return blockCodeRenderer(b as pub.leaflet.blocks.code.Main, opts) + case "pub.leaflet.blocks.horizontalRule": + return Promise.resolve({ html: "
", metadata: {} }) + case "pub.leaflet.blocks.iframe": + return blockIframeRenderer(b as pub.leaflet.blocks.iframe.Main, opts) + case "pub.leaflet.blocks.image": + return blockImageRenderer(b as pub.leaflet.blocks.image.Main, opts) + case "pub.leaflet.blocks.math": + return blockMathRenderer(b as pub.leaflet.blocks.math.Main, opts) + case "pub.leaflet.blocks.orderedList": + return blockOrderedListRenderer(b as pub.leaflet.blocks.orderedList.Main, opts) + case "pub.leaflet.blocks.unorderedList": + return blockUnorderedListRenderer(b as pub.leaflet.blocks.unorderedList.Main, opts) + case "pub.leaflet.blocks.website": + return blockWebsiteRenderer(b as pub.leaflet.blocks.website.Main, opts) + case "pub.leaflet.blocks.page": + return blockPageRenderer(b as pub.leaflet.blocks.page.Main, opts) + case "pub.leaflet.blocks.poll": + return blockPollRenderer(b as pub.leaflet.blocks.poll.Main, opts) + default: + return Promise.resolve(undefined) + } +} + +/** + * Renders the `pub.leaflet.content` lexicon. + * Iterates over pages and renders each linearDocument or canvas page. + */ +export const contentRenderer: RendererFunction< + pub.leaflet.content.Main, + StandardSiteDocumentRendererOptions +> = async (entry, opts) => { + const renderingPages = entry.pages.map((page): Promise => { + if (page.$type === "pub.leaflet.pages.linearDocument") { + return linearDocumentRenderer(page as pub.leaflet.pages.linearDocument.Main, opts) + } + // canvas pages are not linearly renderable to HTML + return Promise.resolve(undefined) + }) + const mappedPages = (await Promise.all(renderingPages)).filter(isRenderedContent) + return { + html: mappedPages.map((p) => p.html).join(""), + metadata: mergeMetadata(mappedPages), + } +} + +/** + * Renders a `pub.leaflet.pages.linearDocument` page into HTML. + */ +export const linearDocumentRenderer: RendererFunction< + pub.leaflet.pages.linearDocument.Main, + StandardSiteDocumentRendererOptions +> = async (entry, opts) => { + const blocks = ( + await Promise.all( + entry.blocks.map((block) => + renderLinearBlock(block as pub.leaflet.pages.linearDocument.Block, opts), + ), + ) + ).filter(isRenderedContent) + return { + html: blocks.map((b) => b.html).join(""), + metadata: mergeMetadata(blocks), + } +} + +export const blockTextRenderer: RendererFunction = async (entry) => { + const sizeClass = + entry.textSize === "small" + ? " class=\"text-small\"" + : entry.textSize === "large" + ? " class=\"text-large\"" + : "" + return { + html: `${renderRichTextHtml(entry.plaintext, entry.facets ?? [])}

`, + metadata: {}, + } +} + +export const blockHeaderRenderer: RendererFunction = async ( + entry, +) => { + const level = entry.level ?? 2 + if (level < 1 || level > 6) throw new Error("Invalid heading level") + const html = renderRichTextHtml(entry.plaintext, entry.facets ?? []) + return { + html: `${html}`, + metadata: { + headings: [{ depth: level, slug: slugify(entry.plaintext), text: html }], + }, + } +} + +export const blockBlockquoteRenderer: RendererFunction = async ( + entry, +) => ({ + html: `

${renderRichTextHtml(entry.plaintext, entry.facets ?? [])}

`, + metadata: {}, +}) + +export const blockBskyPostRenderer: RendererFunction< + pub.leaflet.blocks.bskyPost.Main +> = async (entry) => { + const uri = entry.postRef.uri + const href = blueskyPostUrl(uri) + return { + html: `
View Bluesky post
`, + metadata: {}, + } +} + +export const blockButtonRenderer: RendererFunction = async ( + entry, +) => ({ + html: `${escapeHtml(entry.text)}`, + metadata: {}, +}) + +export const blockCodeRenderer: RendererFunction< + pub.leaflet.blocks.code.Main, + StandardSiteDocumentRendererOptions +> = async (entry, opts) => { + const shikiConfig = opts?.shikiConfig + return { + html: await codeToHtml(entry.plaintext, { + transformers: [ + { + pre(this, el) { + el.properties.class = `astro-code ${el.properties.class as string}` + return el + }, + }, + ], + lang: entry.language ?? "text", + themes: + shikiConfig && "themes" in shikiConfig + ? { light: shikiConfig.themes.light, dark: shikiConfig.themes.dark } + : { light: shikiConfig?.theme ?? "github-light", dark: shikiConfig?.theme ?? "github-dark" }, + defaultColor: opts?.shikiConfig?.defaultColor, + }), + metadata: {}, + } +} + +export const blockIframeRenderer: RendererFunction< + pub.leaflet.blocks.iframe.Main, + StandardSiteDocumentRendererOptions +> = async (entry) => { + const heightAttr = entry.height ? ` height="${entry.height}"` : "" + return { + html: ``, + metadata: {}, + } +} + +export const blockImageRenderer: RendererFunction< + pub.leaflet.blocks.image.Main, + StandardSiteDocumentRendererOptions +> = async (entry, opts) => { + const src = resolveBlobSrc(entry.image, opts) + const aspectStyle = `aspect-ratio:${entry.aspectRatio.width}/${entry.aspectRatio.height}` + const fullBleedStyle = entry.fullBleed ? ";width:100%;max-width:none" : "" + const style = ` style="${aspectStyle}${fullBleedStyle}"` + + if (!src) return { html: ``, metadata: {} } + + return { + html: `${escapeHtml(entry.alt ?? `, + metadata: { imagePaths: [src] }, + } +} + +export const blockMathRenderer: RendererFunction = async ( + entry, +) => ({ + html: `${katex.renderToString(entry.tex, { displayMode: true, throwOnError: false })}`, + metadata: {}, +}) + +function renderLeafletListItems( + items: pub.leaflet.blocks.unorderedList.ListItem[], + tag: "ul" | "ol", + start?: number, +): string { + const itemsHtml = items + .map((item) => { + const content = item.content + let innerHtml = "" + if (content.$type === "pub.leaflet.blocks.text") { + const t = content as pub.leaflet.blocks.text.Main + innerHtml = renderRichTextHtml(t.plaintext, t.facets ?? []) + } else if (content.$type === "pub.leaflet.blocks.header") { + const h = content as pub.leaflet.blocks.header.Main + innerHtml = renderRichTextHtml(h.plaintext, h.facets ?? []) + } + + if (item.checked != null) { + const checked = item.checked ? " checked" : "" + return `
  • ${innerHtml}
  • ` + } + return `
  • ${innerHtml}
  • ` + }) + .join("") + const startAttr = tag === "ol" && start != null && start !== 1 ? ` start="${start}"` : "" + return `<${tag}${startAttr}>${itemsHtml}` +} + +export const blockUnorderedListRenderer: RendererFunction< + pub.leaflet.blocks.unorderedList.Main +> = async (entry) => ({ + html: renderLeafletListItems( + entry.children as pub.leaflet.blocks.unorderedList.ListItem[], + "ul", + ), + metadata: {}, +}) + +export const blockOrderedListRenderer: RendererFunction = + async (entry) => ({ + html: renderLeafletListItems( + entry.children as pub.leaflet.blocks.unorderedList.ListItem[], + "ol", + entry.startIndex, + ), + metadata: {}, + }) + +export const blockWebsiteRenderer: RendererFunction = async (entry, opts) => { + const title = escapeHtml(entry.title ?? entry.src) + const description = entry.description ? `

    ${escapeHtml(entry.description)}

    ` : "" + const previewSrc = resolveBlobSrc(entry.previewImage, opts) + const preview = previewSrc ? `` : "" + return { + html: ``, + metadata: {}, + } +} + +export const blockPageRenderer: RendererFunction = async ( + entry, +) => ({ + html: `Page: ${escapeHtml(entry.id)}`, + metadata: {}, +}) + +export const blockPollRenderer: RendererFunction = async ( + entry, +) => ({ + html: ``, + metadata: {}, +}) diff --git a/src/renderers/pckt.ts b/src/renderers/pckt.ts new file mode 100644 index 0000000..7c05b26 --- /dev/null +++ b/src/renderers/pckt.ts @@ -0,0 +1,392 @@ +import { getBlobCidString, parseLexLink, type BlobRef } from "@atproto/lex" +import escapeHtml from "escape-html" +import slugify from "slugify" +import { codeToHtml } from "shiki" +import { blog } from "../lexicons" +import type { + StandardSiteDocumentRendererOptions, + RenderedContent, + RendererFunction, +} from "./types" +import { mergeArr, utf8ByteToCodeUnitMap } from "./utils" + +type Facet = blog.pckt.richtext.facet.Main + +function splitAndMerge(facets: Facet[]): Facet[] { + const points = new Set() + for (const f of facets) { + if (f.index.byteEnd <= f.index.byteStart) continue + points.add(f.index.byteStart) + points.add(f.index.byteEnd) + } + + const sorted = [...points].sort((a, b) => a - b) + const result: Facet[] = [] + for (let i = 0; i < sorted.length - 1; i++) { + const start = sorted[i] + const end = sorted[i + 1] + const active = facets.filter((f) => f.index.byteStart <= start && end <= f.index.byteEnd) + if (active.length === 0) continue + const merged = Array.from(new Set(active.flatMap((r) => r.features))) + result.push({ index: { byteStart: start, byteEnd: end }, features: merged }) + } + return result +} + +function applyFacetsToText(text: string, facets: Facet[]): { text: string; facet?: Facet }[] { + const byteMap = utf8ByteToCodeUnitMap(text) + const segments: { text: string; facet?: Facet }[] = [] + let cursor = 0 + for (const facet of facets) { + const start = byteMap[facet.index.byteStart] + const end = byteMap[facet.index.byteEnd] + if (start > cursor) segments.push({ text: text.slice(cursor, start) }) + segments.push({ text: text.slice(start, end), facet }) + cursor = end + } + if (cursor < text.length) segments.push({ text: text.slice(cursor) }) + return segments +} + +function applyFacet(html: string, features: Facet["features"]): string { + for (const f of features) { + if (f.$type === "blog.pckt.richtext.facet#bold") html = `${html}` + if (f.$type === "blog.pckt.richtext.facet#italic") html = `${html}` + if (f.$type === "blog.pckt.richtext.facet#underline") html = `${html}` + if (f.$type === "blog.pckt.richtext.facet#strikethrough") html = `${html}` + if (f.$type === "blog.pckt.richtext.facet#code") html = `${html}` + if (f.$type === "blog.pckt.richtext.facet#highlight") html = `${html}` + if (f.$type === "blog.pckt.richtext.facet#link" && "uri" in f) + html = `${html}` + } + return html +} + +function renderRichTextHtml(plaintext: string, facets: Facet[] = []) { + const splitText = applyFacetsToText(plaintext, splitAndMerge(facets)) + return splitText + .map((st) => (st.facet ? applyFacet(escapeHtml(st.text), st.facet.features) : escapeHtml(st.text))) + .join("") +} + +function isRenderedContent(block: RenderedContent | undefined): block is RenderedContent { + return !!block +} + +function mergeMetadata(blocks: RenderedContent[]): RenderedContent["metadata"] { + return blocks + .map((b) => b.metadata) + .reduce( + (acc, curr) => ({ + ...acc, + ...curr, + imagePaths: mergeArr(acc?.imagePaths, curr?.imagePaths), + frontmatter: { ...acc?.frontmatter, ...curr?.frontmatter }, + headings: mergeArr(acc?.headings, curr?.headings), + }), + {}, + ) +} + +function blueskyPostUrl(uri: string) { + const match = /^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/]+)$/.exec(uri) + if (!match) return uri + return `https://bsky.app/profile/${encodeURIComponent(match[1])}/post/${encodeURIComponent(match[2])}` +} + +function resolveBlobSrc(blob: BlobRef | undefined, opts: StandardSiteDocumentRendererOptions) { + if (!blob) return undefined + if (!opts?.endpoint) throw new Error("Must have endpoint to resolve blob") + const cid = "$link" in blob ? parseLexLink(blob) : blob + return `${opts.endpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(opts.repoDid)}&cid=${encodeURIComponent(getBlobCidString(cid as BlobRef))}` +} + +/** + * Renders the `blog.pckt.content` lexicon. + * Handles both inline (items[]) and blob-based content. + */ +export const contentRenderer: RendererFunction< + blog.pckt.content.Main, + StandardSiteDocumentRendererOptions +> = async (entry, opts) => { + const items = entry.items ?? [] + // Items are typed as Unknown$TypedObject[] (pckt uses an open union with no explicit refs), + // so we cast via unknown to access the typed block APIs. + const renderingBlocks = items.map((i): Promise => { + const u = i as unknown + switch (i.$type) { + case "blog.pckt.block.text": + return blockTextRenderer(u as blog.pckt.block.text.Main, opts) + case "blog.pckt.block.heading": + return blockHeadingRenderer(u as blog.pckt.block.heading.Main, opts) + case "blog.pckt.block.blockquote": + return blockBlockquoteRenderer(u as blog.pckt.block.blockquote.Main, opts) + case "blog.pckt.block.blueskyEmbed": + return blockBlueskyEmbedRenderer(u as blog.pckt.block.blueskyEmbed.Main, opts) + case "blog.pckt.block.bulletList": + return blockBulletListRenderer(u as blog.pckt.block.bulletList.Main, opts) + case "blog.pckt.block.codeBlock": + return blockCodeBlockRenderer(u as blog.pckt.block.codeBlock.Main, opts) + case "blog.pckt.block.gallery": + return blockGalleryRenderer(u as blog.pckt.block.gallery.Main, opts) + case "blog.pckt.block.hardBreak": + return Promise.resolve({ html: "
    ", metadata: {} }) + case "blog.pckt.block.horizontalRule": + return Promise.resolve({ html: "
    ", metadata: {} }) + case "blog.pckt.block.iframe": + return blockIframeRenderer(u as blog.pckt.block.iframe.Main, opts) + case "blog.pckt.block.image": + return blockImageRenderer(u as blog.pckt.block.image.Main, opts) + case "blog.pckt.block.mention": + return blockMentionRenderer(u as blog.pckt.block.mention.Main, opts) + case "blog.pckt.block.orderedList": + return blockOrderedListRenderer(u as blog.pckt.block.orderedList.Main, opts) + case "blog.pckt.block.table": + return blockTableRenderer(u as blog.pckt.block.table.Main, opts) + case "blog.pckt.block.taskList": + return blockTaskListRenderer(u as blog.pckt.block.taskList.Main, opts) + case "blog.pckt.block.website": + return blockWebsiteRenderer(u as blog.pckt.block.website.Main, opts) + default: + return Promise.resolve(undefined) + } + }) + const mappedBlocks = (await Promise.all(renderingBlocks)).filter(isRenderedContent) + return { + html: mappedBlocks.map((b) => b.html).join(""), + metadata: mergeMetadata(mappedBlocks), + } +} + +export const blockTextRenderer: RendererFunction = async (entry) => ({ + html: `

    ${renderRichTextHtml(entry.plaintext, entry.facets ?? [])}

    `, + metadata: {}, +}) + +export const blockHeadingRenderer: RendererFunction = async ( + entry, +) => { + const level = entry.level ?? 2 + if (level < 1 || level > 6) throw new Error("Invalid heading level") + const html = renderRichTextHtml(entry.plaintext, entry.facets ?? []) + return { + html: `${html}`, + metadata: { + headings: [{ depth: level, slug: slugify(entry.plaintext), text: html }], + }, + } +} + +export const blockBlockquoteRenderer: RendererFunction< + blog.pckt.block.blockquote.Main, + StandardSiteDocumentRendererOptions +> = async (entry, opts) => { + const blocks = ( + await Promise.all( + entry.content.map((block) => { + if (block.$type === "blog.pckt.block.text") + return blockTextRenderer(block as blog.pckt.block.text.Main, opts) + return Promise.resolve(undefined) + }), + ) + ).filter(isRenderedContent) + return { + html: `
    ${blocks.map((b) => b.html).join("")}
    `, + metadata: mergeMetadata(blocks), + } +} + +export const blockBlueskyEmbedRenderer: RendererFunction< + blog.pckt.block.blueskyEmbed.Main +> = async (entry) => { + const href = blueskyPostUrl(entry.postRef.uri) + return { + html: `
    View Bluesky post
    `, + metadata: {}, + } +} + +function renderListItemsRecursive( + items: blog.pckt.block.listItem.Main[], + tag: "ul" | "ol", + start?: number, +): string { + const itemsHtml = items + .map((item) => { + const parts: string[] = [] + for (const block of item.content) { + if (block.$type === "blog.pckt.block.text") { + parts.push(renderRichTextHtml((block as blog.pckt.block.text.Main).plaintext, (block as blog.pckt.block.text.Main).facets ?? [])) + } else if (block.$type === "blog.pckt.block.bulletList") { + parts.push(renderListItemsRecursive((block as blog.pckt.block.bulletList.Main).content as blog.pckt.block.listItem.Main[], "ul")) + } else if (block.$type === "blog.pckt.block.orderedList") { + const ol = block as blog.pckt.block.orderedList.Main + parts.push(renderListItemsRecursive(ol.content as blog.pckt.block.listItem.Main[], "ol", ol.start)) + } + } + return `
  • ${parts.join("")}
  • ` + }) + .join("") + const startAttr = tag === "ol" && start != null && start !== 1 ? ` start="${start}"` : "" + return `<${tag}${startAttr}>${itemsHtml}` +} + +export const blockBulletListRenderer: RendererFunction = async ( + entry, +) => ({ + html: renderListItemsRecursive(entry.content as blog.pckt.block.listItem.Main[], "ul"), + metadata: {}, +}) + +export const blockOrderedListRenderer: RendererFunction = async ( + entry, +) => ({ + html: renderListItemsRecursive(entry.content as blog.pckt.block.listItem.Main[], "ol", entry.start), + metadata: {}, +}) + +export const blockTaskListRenderer: RendererFunction = async ( + entry, +) => { + const itemsHtml = (entry.content as blog.pckt.block.taskItem.Main[]) + .map((item) => { + const checked = item.checked ? " checked" : "" + const content = item.content + .filter((b) => b.$type === "blog.pckt.block.text") + .map((b) => renderRichTextHtml((b as blog.pckt.block.text.Main).plaintext, (b as blog.pckt.block.text.Main).facets ?? [])) + .join("") + return `
  • ${content}
  • ` + }) + .join("") + return { + html: `
      ${itemsHtml}
    `, + metadata: {}, + } +} + +export const blockCodeBlockRenderer: RendererFunction< + blog.pckt.block.codeBlock.Main, + StandardSiteDocumentRendererOptions +> = async (entry, opts) => { + const shikiConfig = opts?.shikiConfig + return { + html: await codeToHtml(entry.plaintext, { + transformers: [ + { + pre(this, el) { + el.properties.class = `astro-code ${el.properties.class as string}` + return el + }, + }, + ], + lang: entry.language ?? "text", + themes: + shikiConfig && "themes" in shikiConfig + ? { light: shikiConfig.themes.light, dark: shikiConfig.themes.dark } + : { light: shikiConfig?.theme ?? "github-light", dark: shikiConfig?.theme ?? "github-dark" }, + defaultColor: opts?.shikiConfig?.defaultColor, + }), + metadata: {}, + } +} + +export const blockImageRenderer: RendererFunction< + blog.pckt.block.image.Main, + StandardSiteDocumentRendererOptions +> = async (entry, opts) => { + const { attrs } = entry + const src = attrs.blob + ? resolveBlobSrc(attrs.blob, opts) + : attrs.src + ? attrs.src + : undefined + + const alignStyle = + attrs.align === "center" + ? "margin-left:auto;margin-right:auto" + : attrs.align === "right" + ? "margin-left:auto" + : undefined + const aspectStyle = attrs.aspectRatio + ? `aspect-ratio:${attrs.aspectRatio.width}/${attrs.aspectRatio.height}` + : undefined + + const styles = [alignStyle, aspectStyle].filter(Boolean) + const style = styles.length > 0 ? ` style="${styles.join(";")}"` : "" + + if (!src) return { html: ``, metadata: {} } + + const title = attrs.title ? ` title="${escapeHtml(attrs.title)}"` : "" + return { + html: `${escapeHtml(attrs.alt ?? `, + metadata: { imagePaths: [src] }, + } +} + +export const blockGalleryRenderer: RendererFunction< + blog.pckt.block.gallery.Main, + StandardSiteDocumentRendererOptions +> = async (entry) => ({ + html: `View Gallery`, + metadata: {}, +}) + +export const blockIframeRenderer: RendererFunction< + blog.pckt.block.iframe.Main, + StandardSiteDocumentRendererOptions +> = async (entry) => { + const heightAttr = entry.height ? ` height="${entry.height}"` : "" + return { + html: ``, + metadata: {}, + } +} + +export const blockWebsiteRenderer: RendererFunction< + blog.pckt.block.website.Main +> = async (entry) => { + const title = escapeHtml(entry.title ?? entry.src) + const description = entry.description ? `

    ${escapeHtml(entry.description)}

    ` : "" + const preview = entry.previewImage + ? `` + : "" + return { + html: ``, + metadata: {}, + } +} + +export const blockMentionRenderer: RendererFunction = async ( + entry, +) => ({ + html: `@${escapeHtml(entry.handle)}`, + metadata: {}, +}) + +export const blockTableRenderer: RendererFunction< + blog.pckt.block.table.Main, + StandardSiteDocumentRendererOptions +> = async (entry, _opts) => { + const rows = (entry.content as blog.pckt.block.tableRow.Main[]) + .map((row) => { + const cells = (row.content as (blog.pckt.block.tableCell.Main | blog.pckt.block.tableHeader.Main)[]) + .map((cell) => { + const isHeader = cell.$type === "blog.pckt.block.tableHeader" + const tag = isHeader ? "th" : "td" + const colspan = "colspan" in cell && cell.colspan ? ` colspan="${cell.colspan}"` : "" + const rowspan = "rowspan" in cell && cell.rowspan ? ` rowspan="${cell.rowspan}"` : "" + const content = cell.content + .filter((b) => b.$type === "blog.pckt.block.text") + .map((b) => renderRichTextHtml((b as blog.pckt.block.text.Main).plaintext, (b as blog.pckt.block.text.Main).facets ?? [])) + .join("") + return `<${tag}${colspan}${rowspan}>${content}` + }) + .join("") + return `${cells}` + }) + .join("") + return { + html: `${rows}
    `, + metadata: {}, + } +} diff --git a/src/renderers/standard-site.ts b/src/renderers/standard-site.ts index e3594cc..2c071db 100644 --- a/src/renderers/standard-site.ts +++ b/src/renderers/standard-site.ts @@ -1,6 +1,7 @@ import * as site from "../lexicons/site" import * as app from "../lexicons/app" import type { RendererFunction, StandardSiteRenderOpts } from "./types" +import type { blog, pub } from "../lexicons" /** * Renders `site.standard.document` lexicons. Supports: @@ -17,6 +18,14 @@ export function defineStandardSiteDocumentRenderer( return import("./offprint").then((m) => m.contentRenderer(content as unknown as app.offprint.content.Main, { ...opts, ...options }), ) + else if (content.$type === "blog.pckt.content") + return import("./pckt").then((m) => + m.contentRenderer(content as unknown as blog.pckt.content.Main, { ...opts, ...options }), + ) + else if (content.$type === "pub.leaflet.content") + return import("./leaflet").then((m) => + m.contentRenderer(content as unknown as pub.leaflet.content.Main, { ...opts, ...options }), + ) return } -- 2.51.2