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 };
}