import { parseHTML } from 'linkedom'; import type { BlobRef } from './types.js'; import type { Logger } from './logger.js'; import { resolveUrl } from './assets.js'; import { inlineToTextBlock, type TextBlock } from './facets.js'; export type OffprintBlock = | OffprintTextBlock | OffprintHeadingBlock | OffprintBlockquoteBlock | OffprintCodeBlock | OffprintBulletListBlock | OffprintOrderedListBlock | OffprintHorizontalRuleBlock | OffprintImageBlock | OffprintImageGridBlock | OffprintWebEmbedBlock | OffprintWebBookmarkBlock; export interface OffprintTextBlock { $type: 'app.offprint.block.text'; plaintext: string; facets?: unknown[]; textAlign?: 'left' | 'center' | 'right' | 'justify'; } export interface OffprintHeadingBlock { $type: 'app.offprint.block.heading'; level: 1 | 2 | 3; plaintext: string; facets?: unknown[]; textAlign?: 'left' | 'center' | 'right'; } export interface OffprintCodeBlock { $type: 'app.offprint.block.codeBlock'; code: string; language?: string; showLineNumbers?: boolean; } export interface OffprintImageBlock { $type: 'app.offprint.block.image'; image: BlobRef; alt?: string; caption?: string; width?: string; alignment?: 'left' | 'center' | 'right'; aspectRatio?: { width: number; height: number }; } export interface OffprintGridImage { alt?: string; blob: BlobRef; aspectRatio?: { width: number; height: number }; } export interface OffprintImageGridBlock { $type: 'app.offprint.block.imageGrid'; images: OffprintGridImage[]; caption?: string; gridRows?: 1 | 2; aspectRatio?: 'landscape' | 'portrait' | 'square' | 'mosaic'; } export interface OffprintListItem { content: TextBlock; children?: OffprintListItem[]; } export interface OffprintBulletListBlock { $type: 'app.offprint.block.bulletList'; children: OffprintListItem[]; } export interface OffprintOrderedListBlock { $type: 'app.offprint.block.orderedList'; start?: number; children: OffprintListItem[]; } export interface OffprintBlockquoteBlock { $type: 'app.offprint.block.blockquote'; content: (OffprintTextBlock | OffprintHeadingBlock)[]; } export interface OffprintHorizontalRuleBlock { $type: 'app.offprint.block.horizontalRule'; } export interface OffprintWebEmbedBlock { $type: 'app.offprint.block.webEmbed'; href: string; title?: string; width?: string; } export interface OffprintWebBookmarkBlock { $type: 'app.offprint.block.webBookmark'; href: string; title: string; } export function collectImageUrls(html: string, base: string): string[] { const { document } = parseHTML(`${html}`); const urls = new Set(); for (const img of Array.from(document.querySelectorAll('img')) as Element[]) { const src = img.getAttribute('src'); if (src) urls.add(resolveUrl(base, src)); } return Array.from(urls); } function getClassList(el: Element): string[] { return (el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean); } function hasClass(el: Element, name: string): boolean { return getClassList(el).includes(name); } function imgBlock(img: Element, assetMap: Map, log: Logger, base: string): OffprintImageBlock | undefined { const src = img.getAttribute('src'); if (!src) return undefined; const resolved = resolveUrl(base, src); const blob = assetMap.get(resolved); if (!blob) { log.warn(`no blob for image ${resolved}`); return undefined; } const widthAttr = img.getAttribute('width'); const heightAttr = img.getAttribute('height'); const aspectRatio = widthAttr && heightAttr ? { width: Number(widthAttr), height: Number(heightAttr) } : undefined; const alt = img.getAttribute('alt') ?? undefined; return { $type: 'app.offprint.block.image', image: blob, alt, aspectRatio, }; } function parseList(listEl: Element, assetMap: Map, log: Logger, base: string): OffprintBulletListBlock | OffprintOrderedListBlock { const isOrdered = listEl.tagName.toLowerCase() === 'ol'; const children: OffprintListItem[] = []; for (const li of Array.from(listEl.children) as Element[]) { if (li.tagName.toLowerCase() !== 'li') continue; // The direct inline content of a list item, then nested lists const content = inlineToTextBlock(li, log); const nestedLists = (Array.from(li.children) as Element[]).filter((c) => ['ul', 'ol'].includes(c.tagName.toLowerCase())); const item: OffprintListItem = { content: { plaintext: content.plaintext, facets: content.facets }, children: nestedLists.length > 0 ? nestedLists.map((n) => parseListItem(n, assetMap, log, base)) : undefined, }; children.push(item); } if (isOrdered) { const start = listEl.getAttribute('start'); return { $type: 'app.offprint.block.orderedList', start: start ? Number(start) : undefined, children, }; } return { $type: 'app.offprint.block.bulletList', children }; } function parseListItem(listEl: Element, assetMap: Map, log: Logger, base: string): OffprintListItem { const parsed = parseList(listEl, assetMap, log, base) as OffprintBulletListBlock | OffprintOrderedListBlock; // A nested list is turned into a single item whose content is empty and children are the real list items return { content: { plaintext: '' }, children: parsed.children, }; } function parseBlockquote(el: Element, assetMap: Map, log: Logger, base: string): OffprintBlockquoteBlock | OffprintWebEmbedBlock | undefined { // Bluesky embeds expose the AT URI on data-bluesky-uri const blueskyUri = el.getAttribute('data-bluesky-uri'); if (blueskyUri) { const title = el.textContent?.trim().slice(0, 300) ?? 'Embedded post'; return { $type: 'app.offprint.block.webEmbed', href: blueskyUri, title, }; } const content: (OffprintTextBlock | OffprintHeadingBlock)[] = []; for (const child of Array.from(el.children) as Element[]) { const block = convertElement(child, assetMap, log, base, { inBlockquote: true }); for (const b of Array.isArray(block) ? block : [block]) { if (b && (b.$type === 'app.offprint.block.text' || b.$type === 'app.offprint.block.heading')) { content.push(b); } } } return { $type: 'app.offprint.block.blockquote', content }; } function parseGallery(figure: Element, assetMap: Map, log: Logger, base: string): OffprintBlock[] { const images: OffprintGridImage[] = []; for (const img of Array.from(figure.querySelectorAll('img')) as Element[]) { const src = img.getAttribute('src'); if (!src) continue; const resolved = resolveUrl(base, src); const blob = assetMap.get(resolved); if (!blob) { log.warn(`gallery image missing blob: ${resolved}`); continue; } const widthAttr = img.getAttribute('width'); const heightAttr = img.getAttribute('height'); const aspectRatio = widthAttr && heightAttr ? { width: Number(widthAttr), height: Number(heightAttr) } : undefined; images.push({ alt: img.getAttribute('alt') ?? undefined, blob, aspectRatio }); } const captionEl = figure.querySelector('figcaption'); const caption = captionEl ? inlineToTextBlock(captionEl, log).plaintext || undefined : undefined; if (images.length === 0) return []; if (images.length === 1) { return [{ $type: 'app.offprint.block.image', image: images[0].blob, alt: images[0].alt, caption, aspectRatio: images[0].aspectRatio, }]; } const blocks: OffprintBlock[] = []; for (let i = 0; i < images.length; i += 6) { const chunk = images.slice(i, i + 6); if (chunk.length === 1) { blocks.push({ $type: 'app.offprint.block.image', image: chunk[0].blob, alt: chunk[0].alt, aspectRatio: chunk[0].aspectRatio, }); } else { blocks.push({ $type: 'app.offprint.block.imageGrid', images: chunk, caption: i === 0 ? caption : undefined, gridRows: chunk.length <= 2 ? 1 : 2, }); } } return blocks; } function parseFigure(figure: Element, assetMap: Map, log: Logger, base: string): OffprintBlock[] { if (hasClass(figure, 'kg-gallery-card')) { return parseGallery(figure, assetMap, log, base); } const img = figure.querySelector('img'); if (img) { const block = imgBlock(img, assetMap, log, base); if (!block) return []; const captionEl = figure.querySelector('figcaption'); if (captionEl) { block.caption = inlineToTextBlock(captionEl, log).plaintext || undefined; } return [block]; } return []; } interface ConvertContext { inBlockquote?: boolean; } function convertElement(el: Element, assetMap: Map, log: Logger, base: string, ctx: ConvertContext = {}): OffprintBlock | OffprintBlock[] | undefined { const tag = el.tagName.toLowerCase(); switch (tag) { case 'script': case 'style': case 'noscript': return undefined; case 'p': { const text = inlineToTextBlock(el, log); if (!text.plaintext.trim() && (!text.facets || text.facets.length === 0)) return undefined; return { $type: 'app.offprint.block.text', ...text }; } case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': { const level = Math.min(3, Number(tag[1])) as 1 | 2 | 3; const text = inlineToTextBlock(el, log); return { $type: 'app.offprint.block.heading', level, ...text }; } case 'blockquote': return parseBlockquote(el, assetMap, log, base); case 'pre': { const code = el.querySelector('code'); let language: string | undefined; if (code) { const cls = code.getAttribute('class') ?? ''; const match = cls.match(/language-(\w+)/); if (match) language = match[1]; } return { $type: 'app.offprint.block.codeBlock', code: (code ?? el).textContent ?? '', language }; } case 'ul': case 'ol': return parseList(el, assetMap, log, base); case 'hr': return { $type: 'app.offprint.block.horizontalRule' }; case 'figure': return parseFigure(el, assetMap, log, base); case 'img': return imgBlock(el, assetMap, log, base); case 'iframe': { const src = el.getAttribute('src'); if (src) { return { $type: 'app.offprint.block.webEmbed', href: src }; } return undefined; } case 'div': case 'section': case 'article': case 'main': { const results: OffprintBlock[] = []; for (const child of Array.from(el.children) as Element[]) { const converted = convertElement(child, assetMap, log, base, ctx); if (converted) results.push(...(Array.isArray(converted) ? converted : [converted])); } return results; } default: // Unknown block-level element: try to extract text log.warn(`unsupported element <${tag}>, falling back to text`); const text = inlineToTextBlock(el, log); if (!text.plaintext.trim()) return undefined; return { $type: 'app.offprint.block.text', ...text }; } } export function convertHtmlToOffprint( html: string, base: string, assetMap: Map, log: Logger ): { items: OffprintBlock[] } { const { document } = parseHTML(`${html}`); const items: OffprintBlock[] = []; for (const child of Array.from(document.body?.children ?? []) as Element[]) { const converted = convertElement(child, assetMap, log, base); if (!converted) continue; items.push(...(Array.isArray(converted) ? converted : [converted])); } return { items }; }