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.
448 lines
12 KiB
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, '&')
|
|
.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(/<!--[\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;
|
|
}
|