You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/soma/components/words/engine/serialize-html.ts

448 lines
12 KiB

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<WordsMark, string>;
type HtmlNode = HtmlElementNode | HtmlTextNode;
type HtmlElementNode = {
readonly type: 'element';
readonly name: string;
readonly attrs: Record<string, string | true>;
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<string, string> = {
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'
? `<input type="checkbox"${item.checked === true ? ' checked' : ''} disabled> `
: '';
const indentStyle = item.indent ? ` style="margin-left: ${item.indent * 1.5}em"` : '';
return `<li${indentStyle}>${checkbox}${serializeInlinesHtml(item.children)}</li>`;
})
.join('');
return `<${tag}>${children}</${tag}>`;
}
const align = block.textAlign && block.textAlign !== 'left' ? ` style="text-align: ${block.textAlign}"` : '';
if (block.type === 'heading') {
return `<h${block.level}${align}>${serializeInlinesHtml(block.children)}</h${block.level}>`;
}
if (block.type === 'quote') {
return `<blockquote${align}>${serializeInlinesHtml(block.children)}</blockquote>`;
}
return `<p${align}>${serializeInlinesHtml(block.children)}</p>`;
}
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 `<a href="${escapeHtmlAttr(href)}"${title}>${serializeInlinesHtml(inline.children)}</a>`;
}
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}</${tag}>`;
}, value);
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeHtmlAttr(value: string): string {
return escapeHtml(value).replace(/`/g, '&#96;');
}
function parseHtmlTree(html: string): HtmlElementNode {
const root = createHtmlElement('root');
const stack: HtmlElementNode[] = [root];
let skippedRawTag: string | null = null;
const tokens = html.match(/<!--[\s\S]*?-->|<\/?[^>]+>|[^<]+/g) ?? [];
for (const token of tokens) {
if (token.startsWith('<!--')) continue;
if (token.startsWith('<')) {
const tag = parseHtmlTag(token);
if (!tag) continue;
if (skippedRawTag) {
if (tag.closing && tag.name === skippedRawTag) skippedRawTag = null;
continue;
}
if (RAW_TEXT_TAGS.has(tag.name)) {
if (!tag.closing) skippedRawTag = tag.name;
continue;
}
if (tag.closing) {
popHtmlStackToTag(stack, tag.name);
continue;
}
const node = createHtmlElement(tag.name, tag.attrs);
stack[stack.length - 1]?.children.push(node);
if (!tag.selfClosing && !VOID_TAGS.has(tag.name)) stack.push(node);
continue;
}
if (!skippedRawTag) {
stack[stack.length - 1]?.children.push({ type: 'text', text: decodeHtml(token) });
}
}
return root;
}
function parseHtmlTag(
token: string
): {
name: string;
attrs: Record<string, string | true>;
closing: boolean;
selfClosing: boolean;
} | null {
const match = token.match(/^<\s*(\/)?\s*([a-zA-Z][\w:-]*)([\s\S]*?)(\/)?\s*>$/);
if (!match) return null;
return {
name: (match[2] ?? '').toLowerCase(),
attrs: parseHtmlAttrs(match[3] ?? ''),
closing: Boolean(match[1]),
selfClosing: Boolean(match[4])
};
}
function parseHtmlAttrs(raw: string): Record<string, string | true> {
const attrs: Record<string, string | true> = {};
for (const match of raw.matchAll(
/([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>/]+)))?/g
)) {
const name = (match[1] ?? '').toLowerCase();
if (!name) continue;
attrs[name] =
match[2] || match[3] || match[4] ? decodeHtml(match[2] ?? match[3] ?? match[4] ?? '') : true;
}
return attrs;
}
function createHtmlElement(
name: string,
attrs: Record<string, string | true> = {}
): HtmlElementNode {
return { type: 'element', name, attrs, children: [] };
}
function popHtmlStackToTag(stack: HtmlElementNode[], name: string) {
for (let index = stack.length - 1; index > 0; index -= 1) {
if (stack[index]?.name !== name) continue;
stack.length = index;
return;
}
}
function htmlNodesToBlocks(nodes: readonly HtmlNode[]): WordsBlock[] {
const blocks: WordsBlock[] = [];
let inlineBuffer: WordsInline[] = [];
const flushInlineBuffer = () => {
if (!inlineBuffer.length) return;
if (inlineBuffer.some((inline) => getInlineText(inline).trim().length > 0)) {
blocks.push(createParagraph(inlineBuffer));
}
inlineBuffer = [];
};
for (const node of nodes) {
if (node.type === 'text') {
if (node.text.trim()) inlineBuffer.push(createText(collapseHtmlWhitespace(node.text)));
continue;
}
if (!BLOCK_TAGS.has(node.name)) {
inlineBuffer.push(...htmlNodesToInlines([node]));
continue;
}
flushInlineBuffer();
blocks.push(...htmlElementToBlocks(node));
}
flushInlineBuffer();
return blocks;
}
function textAlignFromStyle(node: HtmlElementNode): WordsTextAlign | undefined {
const style = typeof node.attrs.style === 'string' ? node.attrs.style : '';
const match = style.match(/text-align\s*:\s*(left|center|right|justify)/i);
const align = match?.[1]?.toLowerCase();
if (align === 'left' || align === 'center' || align === 'right' || align === 'justify') {
return align;
}
return undefined;
}
function htmlElementToBlocks(node: HtmlElementNode): WordsBlock[] {
if (node.name === 'h1' || node.name === 'h2' || node.name === 'h3') {
return [
createHeading(
Number(node.name.slice(1)) as 1 | 2 | 3,
htmlNodesToInlines(node.children),
textAlignFromStyle(node)
)
];
}
if (node.name === 'blockquote') {
return [createQuote(htmlNodesToInlines(node.children), textAlignFromStyle(node))];
}
if (node.name === 'ul' || node.name === 'ol') {
return [htmlListToBlock(node)];
}
if (node.name === 'li') return [createParagraph(htmlNodesToInlines(node.children))];
if (node.children.some((child) => child.type === 'element' && BLOCK_TAGS.has(child.name))) {
return htmlNodesToBlocks(node.children);
}
return [createParagraph(htmlNodesToInlines(node.children), textAlignFromStyle(node))];
}
function htmlListToBlock(node: HtmlElementNode): WordsBlock {
const items = node.children.filter(
(child): child is HtmlElementNode => child.type === 'element' && child.name === 'li'
);
const kind =
node.name === 'ol' ? 'ordered' : items.some(hasCheckboxInput) ? 'check' : 'unordered';
return createList(
kind,
items.map((item) => htmlListItemToWords(item))
);
}
function htmlListItemToWords(node: HtmlElementNode): WordsListItem {
const hasCheckbox = hasCheckboxInput(node);
const inlines = htmlNodesToInlines(node.children);
const indent = indentFromStyle(node);
return createListItem(
hasCheckbox ? trimLeadingInlineWhitespace(inlines) : inlines,
hasCheckedInput(node),
indent
);
}
function indentFromStyle(node: HtmlElementNode): number | undefined {
const style = typeof node.attrs.style === 'string' ? node.attrs.style : '';
const match = style.match(/margin-left\s*:\s*([\d.]+)em/);
if (!match) return undefined;
const em = Number.parseFloat(match[1] ?? '');
if (!Number.isFinite(em) || em <= 0) return undefined;
const level = Math.round(em / 1.5);
return level > 0 && level <= 8 ? level : undefined;
}
function htmlNodesToInlines(
nodes: readonly HtmlNode[],
marks: readonly WordsMark[] = [],
options: { insideLink?: boolean } = {}
): WordsInline[] {
const out: WordsInline[] = [];
for (const node of nodes) {
if (node.type === 'text') {
out.push(createText(collapseHtmlWhitespace(node.text), marks));
continue;
}
if (node.name === 'br') {
out.push(createText('\n', marks));
continue;
}
if (node.name === 'input') continue;
const nextMarks = marksForHtmlElement(node.name, marks);
if (node.name === 'a' && !options.insideLink) {
const href =
typeof node.attrs.href === 'string' ? sanitizeWordsUrl(node.attrs.href) : undefined;
const children = htmlNodesToInlines(node.children, nextMarks, { insideLink: true });
if (href) {
out.push(
createLink(
href,
children,
typeof node.attrs.title === 'string' ? node.attrs.title : undefined
)
);
} else {
out.push(...children);
}
continue;
}
out.push(...htmlNodesToInlines(node.children, nextMarks, options));
}
return out.length ? out : [createText('', marks)];
}
function marksForHtmlElement(name: string, marks: readonly WordsMark[]): readonly WordsMark[] {
switch (name) {
case 'strong':
case 'b':
return normalizeMarks([...marks, 'bold']);
case 'em':
case 'i':
return normalizeMarks([...marks, 'italic']);
case 'u':
return normalizeMarks([...marks, 'underline']);
case 's':
case 'strike':
case 'del':
return normalizeMarks([...marks, 'strike']);
case 'code':
return normalizeMarks([...marks, 'code']);
default:
return marks;
}
}
function hasCheckboxInput(node: HtmlElementNode): boolean {
return node.children.some(
(child) =>
child.type === 'element' &&
child.name === 'input' &&
(typeof child.attrs.type !== 'string' || child.attrs.type.toLowerCase() === 'checkbox')
);
}
function hasCheckedInput(node: HtmlElementNode): boolean {
return node.children.some(
(child) =>
child.type === 'element' &&
child.name === 'input' &&
(typeof child.attrs.type !== 'string' || child.attrs.type.toLowerCase() === 'checkbox') &&
'checked' in child.attrs
);
}
function collapseHtmlWhitespace(value: string): string {
return value.replace(/\s+/g, ' ');
}
function trimLeadingInlineWhitespace(inlines: readonly WordsInline[]): readonly WordsInline[] {
const [first, ...rest] = inlines;
if (!first || first.type !== 'text') return inlines;
return [createText(first.text.replace(/^\s+/, ''), first.marks), ...rest];
}
function decodeHtml(value: string): string {
return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (entity, body: string) => {
if (body.startsWith('#x') || body.startsWith('#X')) {
return codepointToString(Number.parseInt(body.slice(2), 16), entity);
}
if (body.startsWith('#')) return codepointToString(Number.parseInt(body.slice(1), 10), entity);
return HTML_ENTITIES[body] ?? entity;
});
}
function codepointToString(value: number, fallback: string): string {
return Number.isFinite(value) ? String.fromCodePoint(value) : fallback;
}

Powered by TurnKey Linux.