// // Copyright © 2025 Hardcore Engineering Inc. // // Licensed under the Eclipse Public License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. You may // obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // // See the License for the specific language governing permissions and // limitations under the License. // import { type Attrs, type AttrValue, type MarkupMark, type MarkupNode, MarkupMarkType, MarkupNodeType } from '@hcengineering/text-core' // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface HtmlSerializerOptions {} export class HtmlSerializer { constructor (private readonly options: HtmlSerializerOptions = {}) {} serialize (markup: MarkupNode): string { const builder = new NodeBuilder(true) addNode(builder, markup) return builder.toText() } } class NodeBuilder { textParts: string[] = [] constructor (private readonly addTags: boolean) {} addText (text: string): void { this.textParts.push(text) } openTag ( tag: string, attributes: Record = {}, options?: { newLine?: boolean, selfClosing?: boolean } ): void { if (this.addTags) { this.textParts.push('<') this.textParts.push(tag) for (const [key, value] of Object.entries(attributes)) { if (value == null) continue if (typeof value === 'boolean') { if (value) { this.textParts.push(` ${key}`) } } else { this.textParts.push(` ${key}="${escapeHtml(String(value))}"`) } } this.textParts.push(options?.selfClosing === true ? '/>' : '>') } else { if (options?.newLine === true) { this.textParts.push('\n') } } } closeTag (tag: string, options?: { newLine: boolean }): void { if (this.addTags) { this.textParts.push(``) } else if (options?.newLine === true) { this.textParts.push('\n') } } toText (): string { return this.textParts.join('') } } // Helper function to escape HTML special characters function escapeHtml (text: string): string { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') .replace(/\r/g, ' ') .replace(/\n/g, ' ') } function addMark (builder: NodeBuilder, mark?: MarkupMark, next?: () => void): void { if (mark != null) { const attrs = mark.attrs ?? {} if (mark.type === MarkupMarkType.bold) { builder.openTag('strong') next?.() builder.closeTag('strong') } else if (mark.type === MarkupMarkType.code) { builder.openTag('code') next?.() builder.closeTag('code') } else if (mark.type === MarkupMarkType.em) { builder.openTag('em') next?.() builder.closeTag('em') } else if (mark.type === MarkupMarkType.link) { builder.openTag('a', { target: attrs.target, rel: attrs.rel, class: attrs.class, href: attrs.href, title: attrs.title }) next?.() builder.closeTag('a') } else if (mark.type === MarkupMarkType.strike) { builder.openTag('s') next?.() builder.closeTag('s') } else if (mark.type === MarkupMarkType.underline) { builder.openTag('u') next?.() builder.closeTag('u') } else if (mark.type === MarkupMarkType.textColor) { const color = attrs.color builder.openTag('span', { style: color !== undefined ? `color: ${color}` : undefined, 'data-color': color }) next?.() builder.closeTag('span') } else if (mark.type === MarkupMarkType.textStyle) { // Convert camelCase properties back to kebab-case CSS properties const styleAttrs = Object.entries(attrs) .map(([key, value]) => { const kebabKey = key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`) return `${kebabKey}: ${value}` }) .join('; ') builder.openTag('span', { style: styleAttrs }) next?.() builder.closeTag('span') } else { // Handle unknown mark as span with data attribute builder.openTag('span', { 'data-mark-type': mark.type as string }) next?.() builder.closeTag('span') } } } function addMarks (builder: NodeBuilder, marks: MarkupMark[], next?: () => void): void { if (marks.length > 0) { const mark = marks[0] const others = marks.slice(1) if (others.length > 0) { addMark(builder, mark, () => { addMarks(builder, others, next) }) } else { addMark(builder, mark, next) } } } function addNodes (builder: NodeBuilder, nodes: MarkupNode[]): void { nodes.forEach((childNode) => { addNode(builder, childNode) }) } function addNodeContent (builder: NodeBuilder, node?: MarkupNode): void { if (node == null) return const attrs = node.attrs ?? {} const nodes = node.content ?? [] const style = toStyleAttr(attrs) if (node.type === MarkupNodeType.doc) { addNodes(builder, nodes) } else if (node.type === MarkupNodeType.paragraph) { builder.openTag('p', { style }) addNodes(builder, nodes) builder.closeTag('p') } else if (node.type === MarkupNodeType.blockquote) { builder.openTag('blockquote') addNodes(builder, nodes) builder.closeTag('blockquote') } else if (node.type === MarkupNodeType.horizontal_rule) { builder.openTag('hr', {}, { selfClosing: true }) } else if (node.type === MarkupNodeType.heading) { const level = toNumber(attrs.level) ?? 1 const tag = `h${level}` builder.openTag(tag, { style }) addNodes(builder, nodes) builder.closeTag(tag) } else if (node.type === MarkupNodeType.code_block) { const attrs = node.attrs?.language !== undefined ? { class: `language-${node.attrs.language}` } : {} builder.openTag('pre') builder.openTag('code', attrs) addNodes(builder, nodes) builder.closeTag('code') builder.closeTag('pre') } else if (node.type === MarkupNodeType.text) { builder.addText(node.text ?? '') } else if (node.type === MarkupNodeType.image) { const src = toString(attrs.src) const alt = toString(attrs.alt) const width = toString(attrs.width) const height = toString(attrs.height) builder.openTag('img', { src, alt, width, height }, { selfClosing: true }) } else if (node.type === MarkupNodeType.reference) { const label = toString(attrs.label) builder.openTag('span', { 'data-type': 'reference', 'data-id': attrs.id, 'data-objectclass': attrs.objectclass, 'data-label': attrs.label }) builder.addText(label !== undefined ? `@${label}` : '') builder.closeTag('span') } else if (node.type === MarkupNodeType.hard_break) { builder.openTag('br', {}, { selfClosing: true }) } else if (node.type === MarkupNodeType.ordered_list) { builder.openTag('ol') addNodes(builder, nodes) builder.closeTag('ol') } else if (node.type === MarkupNodeType.bullet_list) { builder.openTag('ul') addNodes(builder, nodes) builder.closeTag('ul') } else if (node.type === MarkupNodeType.list_item) { builder.openTag('li') addNodes(builder, nodes) builder.closeTag('li') } else if (node.type === MarkupNodeType.todoList) { builder.openTag('ul', { 'data-type': MarkupNodeType.todoList }) addNodes(builder, nodes) builder.closeTag('ul') } else if (node.type === MarkupNodeType.todoItem) { const checked = node.attrs?.checked === true || node.attrs?.checked === 'true' const disabled = node.attrs?.disabled === true || node.attrs?.disabled === 'true' builder.openTag('li', { 'data-type': MarkupNodeType.todoItem, 'data-todoid': node.attrs?.todoid, 'data-userid': node.attrs?.userid, 'data-checked': checked }) builder.openTag('input', { type: 'checkbox', checked, disabled }, { selfClosing: true }) addNodes(builder, nodes) builder.closeTag('li') } else if (node.type === MarkupNodeType.taskList) { builder.openTag('ul', { 'data-type': MarkupNodeType.taskList }) addNodes(builder, nodes) builder.closeTag('ul') } else if (node.type === MarkupNodeType.taskItem) { const checked = node.attrs?.checked === true || node.attrs?.checked === 'true' const disabled = node.attrs?.disabled === true || node.attrs?.disabled === 'true' builder.openTag('li', { 'data-type': MarkupNodeType.taskItem, 'data-checked': checked }) builder.openTag('input', { type: 'checkbox', checked, disabled }, { selfClosing: true }) addNodes(builder, nodes) builder.closeTag('li') } else if (node.type === MarkupNodeType.subLink) { builder.openTag('sub') addNodes(builder, nodes) builder.closeTag('sub') } else if (node.type === MarkupNodeType.table) { builder.openTag('table') builder.openTag('tbody') addNodes(builder, nodes) builder.closeTag('tbody') builder.closeTag('table') } else if (node.type === MarkupNodeType.table_row) { builder.openTag('tr') addNodes(builder, nodes) builder.closeTag('tr') } else if (node.type === MarkupNodeType.table_cell) { const colspan = toNumber(attrs.colspan) ?? 1 const rowspan = toNumber(attrs.rowspan) ?? 1 const colwidth = toNumber(attrs.colwidth) builder.openTag('td', { colspan: colspan !== 1 ? colspan : undefined, rowspan: rowspan !== 1 ? rowspan : undefined, colwidth: colwidth !== undefined && colwidth > 0 ? colwidth : undefined }) addNodes(builder, nodes) builder.closeTag('td') } else if (node.type === MarkupNodeType.table_header) { const colspan = toNumber(attrs.colspan) ?? 1 const rowspan = toNumber(attrs.rowspan) ?? 1 const colwidth = toNumber(attrs.colwidth) builder.openTag('th', { colspan: colspan !== 1 ? colspan : undefined, rowspan: rowspan !== 1 ? rowspan : undefined, colwidth: colwidth !== undefined && colwidth > 0 ? colwidth : undefined }) addNodes(builder, nodes) builder.closeTag('th') } else if (node.type === MarkupNodeType.comment) { builder.addText('') } else if (node.type === MarkupNodeType.embed) { const src = toString(attrs.src) ?? '' builder.openTag('a', { href: encodeURI(src), 'data-type': 'embed' }) builder.addText(escapeHtml(src)) builder.closeTag('a') } else { // Handle unknown node types as div with data attribute builder.openTag('div', { 'data-node-type': node.type }) addNodes(builder, nodes) builder.closeTag('div') } } function addNode (builder: NodeBuilder, node: MarkupNode): void { const marks = node.marks ?? [] if (marks.length > 0) { addMarks(builder, marks, () => { addNodeContent(builder, node) }) } else { addNodeContent(builder, node) } } function toString (value: AttrValue | undefined): string | undefined { return value !== undefined ? `${value}` : undefined } function toNumber (value: AttrValue | undefined): number | undefined { if (typeof value === 'boolean') { return value ? 1 : 0 } return value != null ? (typeof value === 'string' ? parseInt(value) : value) : undefined } function toStyleAttr (attrs: Attrs): string | undefined { const styles: string[] = [] if (attrs.textAlign != null) { styles.push(`text-align: ${attrs.textAlign}`) } return styles.length > 0 ? styles.join('; ') : undefined }