import { WORDS_DOCUMENT_VERSION, createHeading, getInlineText, createLink, createList, createListItem, createParagraph, createQuote, createText, normalizeMarks, type WordsBlock, type WordsDocument, type WordsInline, type WordsListItem, type WordsMark, type WordsTextAlign } from './document'; import { normalizeDocument, sanitizeWordsUrl } from './normalize'; const MARK_TAGS = { bold: 'strong', italic: 'em', underline: 'u', strike: 's', code: 'code' } as const satisfies Record; type HtmlNode = HtmlElementNode | HtmlTextNode; type HtmlElementNode = { readonly type: 'element'; readonly name: string; readonly attrs: Record; readonly children: HtmlNode[]; }; type HtmlTextNode = { readonly type: 'text'; readonly text: string; }; const BLOCK_TAGS = new Set([ 'article', 'blockquote', 'div', 'h1', 'h2', 'h3', 'li', 'ol', 'p', 'section', 'ul' ]); const RAW_TEXT_TAGS = new Set(['script', 'style']); const VOID_TAGS = new Set([ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta' ]); const HTML_ENTITIES: Record = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }; export function serializeWordsHtml(document: WordsDocument): string { return document.children.map(serializeBlockHtml).join(''); } export function parseWordsHtml(html: string): WordsDocument { const root = parseHtmlTree(html.replace(/\u0000/g, '')); const children = htmlNodesToBlocks(root.children); return normalizeDocument({ version: WORDS_DOCUMENT_VERSION, children: children.length ? children : [createParagraph()] }).document; } function serializeBlockHtml(block: WordsBlock): string { if (block.type === 'list') { const tag = block.kind === 'ordered' ? 'ol' : 'ul'; const children = block.children .map((item) => { const checkbox = block.kind === 'check' ? ` ` : ''; const indentStyle = item.indent ? ` style="margin-left: ${item.indent * 1.5}em"` : ''; return `${checkbox}${serializeInlinesHtml(item.children)}`; }) .join(''); return `<${tag}>${children}`; } const align = block.textAlign && block.textAlign !== 'left' ? ` style="text-align: ${block.textAlign}"` : ''; if (block.type === 'heading') { return `${serializeInlinesHtml(block.children)}`; } if (block.type === 'quote') { return `${serializeInlinesHtml(block.children)}`; } return `${serializeInlinesHtml(block.children)}

`; } function serializeInlinesHtml(inlines: readonly WordsInline[]): string { return inlines.map(serializeInlineHtml).join(''); } function serializeInlineHtml(inline: WordsInline): string { if (inline.type === 'link') { const href = sanitizeWordsUrl(inline.href); if (!href) return escapeHtml(inline.children.map(getInlineText).join('')); const title = inline.title ? ` title="${escapeHtmlAttr(inline.title)}"` : ''; return `${serializeInlinesHtml(inline.children)}`; } return wrapMarks(escapeHtml(inline.text), inline.marks ?? []); } function wrapMarks(value: string, marks: readonly WordsMark[]): string { return marks.reduce((html, mark) => { const tag = MARK_TAGS[mark]; return `<${tag}>${html}`; }, value); } function escapeHtml(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function escapeHtmlAttr(value: string): string { return escapeHtml(value).replace(/`/g, '`'); } function parseHtmlTree(html: string): HtmlElementNode { const root = createHtmlElement('root'); const stack: HtmlElementNode[] = [root]; let skippedRawTag: string | null = null; const tokens = html.match(/|<\/?[^>]+>|[^<]+/g) ?? []; for (const token of tokens) { if (token.startsWith('