mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-20 11:31:47 +08:00
296 lines
5.9 KiB
TypeScript
296 lines
5.9 KiB
TypeScript
import type { Element, ElementContent, Text } from 'hast';
|
|
|
|
import type { HtmlAST } from '../types/hast.js';
|
|
|
|
// Block elements that html adapter supports
|
|
const blockElements = [
|
|
'div',
|
|
'p',
|
|
'h1',
|
|
'h2',
|
|
'h3',
|
|
'h4',
|
|
'h5',
|
|
'h6',
|
|
'ul',
|
|
'ol',
|
|
'li',
|
|
'blockquote',
|
|
'pre',
|
|
];
|
|
|
|
const blockElementsSet = new Set(blockElements);
|
|
|
|
// Phrasing content
|
|
const inlineElements = [
|
|
'a',
|
|
'abbr',
|
|
'audio',
|
|
'b',
|
|
'bdi',
|
|
'bdo',
|
|
'br',
|
|
'button',
|
|
'canvas',
|
|
'cite',
|
|
'code',
|
|
'data',
|
|
'datalist',
|
|
'del',
|
|
'dfn',
|
|
'em',
|
|
'embed',
|
|
'i',
|
|
// 'iframe' is not included because it needs special handling
|
|
// 'img' is not included because it needs special handling
|
|
'input',
|
|
'ins',
|
|
'kbd',
|
|
'label',
|
|
'link',
|
|
'map',
|
|
'mark',
|
|
'math',
|
|
'meta',
|
|
'meter',
|
|
'noscript',
|
|
'object',
|
|
'output',
|
|
'picture',
|
|
'progress',
|
|
'q',
|
|
'ruby',
|
|
's',
|
|
'samp',
|
|
'script',
|
|
'select',
|
|
'slot',
|
|
'small',
|
|
'span',
|
|
'strong',
|
|
'sub',
|
|
'sup',
|
|
'svg',
|
|
'template',
|
|
'textarea',
|
|
'time',
|
|
'u',
|
|
'var',
|
|
'video',
|
|
'wbr',
|
|
];
|
|
|
|
const inlineElementsSet = new Set(inlineElements);
|
|
|
|
const isElement = (ast: HtmlAST): ast is Element => {
|
|
return ast.type === 'element';
|
|
};
|
|
|
|
const getTextContent = (ast: HtmlAST | undefined, defaultStr = ''): string => {
|
|
if (!ast) {
|
|
return defaultStr;
|
|
}
|
|
switch (ast.type) {
|
|
case 'text': {
|
|
return ast.value.replace(/\s+/g, ' ');
|
|
}
|
|
case 'element': {
|
|
switch (ast.tagName) {
|
|
case 'br': {
|
|
return '\n';
|
|
}
|
|
}
|
|
return ast.children.map(child => getTextContent(child)).join('');
|
|
}
|
|
}
|
|
return defaultStr;
|
|
};
|
|
|
|
const getElementChildren = (ast: HtmlAST | undefined): Element[] => {
|
|
if (!ast) {
|
|
return [];
|
|
}
|
|
if (ast.type === 'element') {
|
|
return ast.children.filter(child => child.type === 'element') as Element[];
|
|
}
|
|
return [];
|
|
};
|
|
|
|
const getTextChildren = (ast: HtmlAST | undefined): Text[] => {
|
|
if (!ast) {
|
|
return [];
|
|
}
|
|
if (ast.type === 'element') {
|
|
return ast.children.filter(child => child.type === 'text') as Text[];
|
|
}
|
|
return [];
|
|
};
|
|
|
|
const getTextChildrenOnlyAst = (ast: Element): Element => {
|
|
return {
|
|
...ast,
|
|
children: getTextChildren(ast),
|
|
};
|
|
};
|
|
|
|
const isTagBlock = (tagName: string): boolean => {
|
|
return blockElementsSet.has(tagName);
|
|
};
|
|
|
|
const isTagInline = (tagName: string): boolean => {
|
|
return inlineElementsSet.has(tagName);
|
|
};
|
|
|
|
const isElementInline = (element: Element): boolean => {
|
|
return (
|
|
isTagInline(element.tagName) ||
|
|
// Inline elements
|
|
!!(
|
|
typeof element.properties?.style === 'string' &&
|
|
element.properties.style.match(/display:\s*inline/)
|
|
)
|
|
);
|
|
};
|
|
|
|
const getInlineElementsAndText = (ast: Element): (Element | Text)[] => {
|
|
if (!ast || !ast.children) {
|
|
return [];
|
|
}
|
|
|
|
return ast.children.filter((child): child is Element | Text => {
|
|
if (child.type === 'text') {
|
|
return true;
|
|
}
|
|
if (child.type === 'element' && child.tagName && isElementInline(child)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
};
|
|
|
|
const getInlineOnlyElementAST = (ast: Element): Element => {
|
|
return {
|
|
...ast,
|
|
children: getInlineElementsAndText(ast),
|
|
};
|
|
};
|
|
|
|
const querySelectorTag = (
|
|
ast: HtmlAST,
|
|
tagName: string
|
|
): Element | undefined => {
|
|
if (ast.type === 'element') {
|
|
if (ast.tagName === tagName) {
|
|
return ast;
|
|
}
|
|
for (const child of ast.children) {
|
|
const result = querySelectorTag(child, tagName);
|
|
if (result) {
|
|
return result;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const querySelectorClass = (
|
|
ast: HtmlAST,
|
|
className: string
|
|
): Element | undefined => {
|
|
if (ast.type === 'element') {
|
|
if (
|
|
Array.isArray(ast.properties?.className) &&
|
|
ast.properties.className.includes(className)
|
|
) {
|
|
return ast;
|
|
}
|
|
for (const child of ast.children) {
|
|
const result = querySelectorClass(child, className);
|
|
if (result) {
|
|
return result;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const querySelectorId = (ast: HtmlAST, id: string): Element | undefined => {
|
|
if (ast.type === 'element') {
|
|
if (ast.properties.id === id) {
|
|
return ast;
|
|
}
|
|
for (const child of ast.children) {
|
|
const result = querySelectorId(child, id);
|
|
if (result) {
|
|
return result;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const querySelector = (ast: HtmlAST, selector: string): Element | undefined => {
|
|
if (ast.type === 'root') {
|
|
for (const child of ast.children) {
|
|
const result = querySelector(child, selector);
|
|
if (result) {
|
|
return result;
|
|
}
|
|
}
|
|
} else if (ast.type === 'element') {
|
|
if (selector.startsWith('.')) {
|
|
return querySelectorClass(ast, selector.slice(1));
|
|
} else if (selector.startsWith('#')) {
|
|
return querySelectorId(ast, selector.slice(1));
|
|
} else {
|
|
return querySelectorTag(ast, selector);
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const flatNodes = (
|
|
ast: HtmlAST,
|
|
expression: (tagName: string) => boolean
|
|
): HtmlAST => {
|
|
if (ast.type === 'element') {
|
|
const children = ast.children.map(child => flatNodes(child, expression));
|
|
return {
|
|
...ast,
|
|
children: children.flatMap(child => {
|
|
if (child.type === 'element' && expression(child.tagName)) {
|
|
return child.children;
|
|
}
|
|
return child;
|
|
}) as ElementContent[],
|
|
};
|
|
}
|
|
return ast;
|
|
};
|
|
|
|
// Check if it is a paragraph like element
|
|
// https://html.spec.whatwg.org/#paragraph
|
|
const isParagraphLike = (node: Element): boolean => {
|
|
// Flex container
|
|
return (
|
|
(typeof node.properties?.style === 'string' &&
|
|
node.properties.style.match(/display:\s*flex/) !== null) ||
|
|
getElementChildren(node).every(child => isElementInline(child))
|
|
);
|
|
};
|
|
|
|
export const HastUtils = {
|
|
isElement,
|
|
getTextContent,
|
|
getElementChildren,
|
|
getTextChildren,
|
|
getTextChildrenOnlyAst,
|
|
getInlineOnlyElementAST,
|
|
querySelector,
|
|
flatNodes,
|
|
isParagraphLike,
|
|
isTagBlock,
|
|
isTagInline,
|
|
isElementInline,
|
|
};
|