Use consistent handling of embed nodes in markdown and html

Signed-off-by: Nikolay Marchuk <nikolay.marchuk@hardcoreeng.com>
This commit is contained in:
Nikolay Marchuk 2025-06-01 00:34:43 +07:00
parent ca31a6b033
commit 0854ca9958
7 changed files with 186 additions and 80 deletions

View File

@ -41,7 +41,8 @@ export enum MarkupNodeType {
table_header = 'tableHeader',
mermaid = 'mermaid',
comment = 'comment',
markdown = 'markdown'
markdown = 'markdown',
embed = 'embed'
}
/** @public */

View File

@ -431,6 +431,87 @@ const tests: Array<{ name: string, markup: object, html: string }> = [
]
},
html: '<p>hello <span data-type="reference" data-id="64708c79c8f2613474dea38b" data-objectclass="contact:class:Person" data-label="John Doe">@John Doe</span></p>'
},
{
name: 'embed',
markup: {
type: 'doc',
content: [
{
type: 'paragraph',
attrs: {
textAlign: null
},
content: [
{
type: 'text',
text: 'hello '
},
{
type: 'embed',
attrs: {
src: 'http://localhost/embed'
}
}
]
}
]
},
html: '<p>hello <a href="http://localhost/embed" data-type="embed">http://localhost/embed</a></p>'
},
{
name: 'embed-uri-escape',
markup: {
type: 'doc',
content: [
{
type: 'paragraph',
attrs: {
textAlign: null
},
content: [
{
type: 'text',
text: 'hello '
},
{
type: 'embed',
attrs: {
src: 'http://localhost/embed spaces'
}
}
]
}
]
},
html: '<p>hello <a href="http://localhost/embed%20spaces" data-type="embed">http://localhost/embed spaces</a></p>'
},
{
name: 'embed-html-escape',
markup: {
type: 'doc',
content: [
{
type: 'paragraph',
attrs: {
textAlign: null
},
content: [
{
type: 'text',
text: 'hello '
},
{
type: 'embed',
attrs: {
src: 'http://localhost/embed<html>'
}
}
]
}
]
},
html: '<p>hello <a href="http://localhost/embed%3Chtml%3E" data-type="embed">http://localhost/embed&lt;html&gt;</a></p>'
}
]

View File

@ -199,15 +199,6 @@ const styleRules: HtmlStyleRule[] = [
]
const markRules: Record<string, HtmlMarkRule> = {
a: {
mark: MarkupMarkType.link,
getAttrs: (attributes: Record<string, string>) => {
return {
href: attributes.href,
title: attributes.title
}
}
},
b: {
mark: MarkupMarkType.bold
},
@ -415,6 +406,30 @@ const specialRules: Record<string, HtmlSpecialRule> = {
state.closeMark(MarkupMarkType.code)
}
}
},
a: {
handleOpenTag: (state: HtmlParseState, tag: string, attributes: Record<string, string>) => {
const dataType = attributes['data-type']
if (dataType === 'embed') {
state.openNode(MarkupNodeType.embed, {
src: decodeURI(attributes.href)
})
} else {
state.openMark(MarkupMarkType.link, {
href: attributes.href,
title: attributes.title
})
}
},
handleCloseTag: (state: HtmlParseState, tag: string) => {
const top = state.top()
if (top?.type === MarkupNodeType.embed) {
delete top.content
state.closeNode(MarkupNodeType.embed)
} else {
state.closeMark(MarkupMarkType.link)
}
}
}
}

View File

@ -281,6 +281,11 @@ function addNodeContent (builder: NodeBuilder, node?: MarkupNode): void {
builder.addText('<!-- ')
addNodes(builder, nodes)
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 })

View File

@ -13,7 +13,6 @@
// limitations under the License.
//
import { MarkupNode } from '@hcengineering/text-core'
import { markdownToMarkup, markupToMarkdown } from '..'
import { isMarkdownsEquals } from '../compare'
@ -774,6 +773,65 @@ Lorem ipsum dolor sit amet.
}
]
}
},
{
name: 'embed',
markdown: '<a href="http://localhost/embed" data-type="embed">http:&#x2F;&#x2F;localhost&#x2F;embed</a>',
markup: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'embed',
attrs: { src: 'http://localhost/embed' },
content: []
}
]
}
]
}
},
{
name: 'embed-uri-escape',
markdown:
'<a href="http://localhost/embed%20spaces" data-type="embed">http:&#x2F;&#x2F;localhost&#x2F;embed spaces</a>',
markup: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'embed',
attrs: { src: 'http://localhost/embed spaces' },
content: []
}
]
}
]
}
},
{
name: 'embed-html-escape',
markdown:
'<a href="http://localhost/embed%3Chtml%3E" data-type="embed">http:&#x2F;&#x2F;localhost&#x2F;embed&lt;html&gt;</a>',
markup: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'embed',
attrs: { src: 'http://localhost/embed<html>' },
content: []
}
]
}
]
}
}
]
@ -851,61 +909,3 @@ describe('markdownToMarkup -> markupToMarkdown', () => {
})
})
})
describe('markupToMarkdown', () => {
const tests: Array<{ name: string, markup: object, markdown: string }> = [
{
name: 'embed',
markup: {
type: 'doc',
content: [
{
type: 'embed',
attrs: { src: 'http://localhost/embed' },
content: []
}
]
},
markdown: '<http://localhost/embed>'
},
{
name: 'embed-spaces',
markup: {
type: 'doc',
content: [
{
type: 'embed',
attrs: { src: 'http://localhost/embed spaces' },
content: []
}
]
},
markdown: '[http://localhost/embed spaces](http://localhost/embed%20spaces)'
},
{
name: 'embed-sub',
markup: {
type: 'doc',
content: [
{
type: 'subLink',
content: [
{
type: 'embed',
attrs: { src: 'http://localhost/embed?c=<a>' },
content: []
}
]
}
]
},
markdown: '<sub><a href="http://localhost/embed?c=%3Ca%3E">http://localhost/embed?c=&lt;a&gt;</a></sub>'
}
]
tests.forEach(({ name, markup, markdown }) => {
it(name, () => {
const serialized = markupToMarkdown(markup as MarkupNode, options)
expect(serialized).toEqual(markdown)
})
})
})

View File

@ -332,16 +332,26 @@ function tokenHandlers (
handlers.html_inline = (state: MarkdownParseState, tok: Token) => {
try {
const top = state.top()
if (tok.content.trim() === '</a>' && top?.type === MarkupNodeType.embed) {
top.content = []
state.closeNode()
return
}
const markup = htmlParser(tok.content)
if (markup.content !== undefined) {
// unwrap content from wrapping paragraph
const shouldUnwrap =
markup.content.length === 1 &&
markup.content[0].type === MarkupNodeType.paragraph &&
state.top()?.type === MarkupNodeType.paragraph
top?.type === MarkupNodeType.paragraph
const content = nodeContent(shouldUnwrap ? markup.content[0] : markup)
for (const c of content) {
if (c.type === MarkupNodeType.embed) {
state.openNode(MarkupNodeType.embed, c.attrs ?? {})
continue
}
state.push(c)
}
}

View File

@ -278,16 +278,10 @@ export const storeNodes: Record<string, NodeProcessor> = {
embed: (state, node) => {
const attrs = nodeAttrs(node)
const embedUrl = attrs.src as string
if (state.renderAHref === true) {
state.write(`<a href="${encodeURI(embedUrl)}">${state.htmlEsc(embedUrl)}</a>`)
} else {
const encoded = encodeURI(embedUrl)
if (encoded === embedUrl) {
state.write(`<${state.esc(embedUrl)}>`)
} else {
state.write(`[${state.esc(embedUrl)}](${encoded})`)
}
}
state.write(`<a href="${encodeURI(embedUrl)}" data-type="embed">`)
// Slashes are escaped to prevent autolink creation
state.write(state.htmlEsc(embedUrl).replace(/\//g, '&#x2F;'))
state.write('</a>')
}
}