Skip to content

Commit cbbd722

Browse files
committed
refactor(spx-gui): convert self-closing components into explicit tags for simplified handling
1 parent 85d3777 commit cbbd722

File tree

2 files changed

+70
-153
lines changed

2 files changed

+70
-153
lines changed

spx-gui/src/components/common/markdown-vue/MarkdownView.test.ts

Lines changed: 57 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useSlotText } from '@/utils/vnode'
55
import MarkdowView, {
66
preprocessCustomRawComponents,
77
preprocessIncompleteTags,
8-
preprocessInlineComponents
8+
preprocessSelfClosingComponents
99
} from './MarkdownView'
1010

1111
describe('preprocessCustomRawComponents', () => {
@@ -102,60 +102,61 @@ describe('preprocessCustomRawComponents', () => {
102102
})
103103
})
104104

105-
describe('preprocessInlineComponents', () => {
106-
it('should separate self-closing tag from next line text with blank line', () => {
107-
const value = '<x-comp />\nHello world'
108-
const tagNames = ['x-comp']
109-
const result = preprocessInlineComponents(value, tagNames)
110-
expect(result).toBe('<x-comp />\n\nHello world')
111-
})
112-
it('should separate self-closing tag from next line text with blank line with attributes', () => {
113-
const value = '<x-comp some="foo" />\nHello world'
114-
const tagNames = ['x-comp']
115-
const result = preprocessInlineComponents(value, tagNames)
116-
expect(result).toBe('<x-comp some="foo" />\n\nHello world')
117-
})
118-
it('should preserve leading spaces of next line', () => {
119-
const value = '<x-comp />\n Hello'
120-
const tagNames = ['x-comp']
121-
const result = preprocessInlineComponents(value, tagNames)
122-
expect(result).toBe('<x-comp />\n\n Hello')
123-
})
124-
it('should ignore when there is a blank line', () => {
125-
const value = '<x-comp />\n\nHello'
126-
const tagNames = ['x-comp']
127-
const result = preprocessInlineComponents(value, tagNames)
128-
expect(result).toBe('<x-comp />\n\nHello')
129-
})
130-
it('should not change when inline already', () => {
131-
const value = '<x-comp />Hello\nWorld'
132-
const tagNames = ['x-comp']
133-
const result = preprocessInlineComponents(value, tagNames)
134-
expect(result).toBe('<x-comp />Hello\nWorld')
135-
})
136-
it('should match spaces before newline', () => {
137-
const value = '<x-comp /> \nHello'
138-
const tagNames = ['x-comp']
139-
const result = preprocessInlineComponents(value, tagNames)
140-
expect(result).toBe('<x-comp /> \n\nHello')
141-
})
142-
it('should not change when not self-closing', () => {
143-
const value = '<x-comp></x-comp>\nHello'
144-
const tagNames = ['x-comp']
145-
const result = preprocessInlineComponents(value, tagNames)
146-
expect(result).toBe('<x-comp></x-comp>\nHello')
147-
})
148-
it('should separate even when a self-closing tag is preceded by text', () => {
149-
const value = 'before<x-comp />\nHello'
150-
const tagNames = ['x-comp']
151-
const result = preprocessInlineComponents(value, tagNames)
152-
expect(result).toBe('before<x-comp />\n\nHello')
153-
})
154-
it('should insert a blank line before an inline component when preceded by text', () => {
155-
const value = '<x-comp />\n<x-comp />\nHello\n<x-comp />'
156-
const tagNames = ['x-comp']
157-
const result = preprocessInlineComponents(value, tagNames)
158-
expect(result).toBe('<x-comp />\n\n<x-comp />\n\nHello\n<x-comp />')
105+
describe('preprocessSelfClosingComponents', () => {
106+
it('should convert self-closing tags to opening and closing tags', () => {
107+
const value = '<custom-component />'
108+
const tagNames = ['custom-component']
109+
const result = preprocessSelfClosingComponents(value, tagNames)
110+
expect(result).toBe('<custom-component></custom-component>')
111+
})
112+
113+
it('should handle attributes', () => {
114+
const value = '<custom-component attr="value" />'
115+
const tagNames = ['custom-component']
116+
const result = preprocessSelfClosingComponents(value, tagNames)
117+
expect(result).toBe('<custom-component attr="value"></custom-component>')
118+
})
119+
120+
it('should handle multiple attributes', () => {
121+
const value = '<custom-component attr1="value1" attr2="value2" />'
122+
const tagNames = ['custom-component']
123+
const result = preprocessSelfClosingComponents(value, tagNames)
124+
expect(result).toBe('<custom-component attr1="value1" attr2="value2"></custom-component>')
125+
})
126+
127+
it('should handle spaces before closing slash', () => {
128+
const value = '<custom-component />'
129+
const tagNames = ['custom-component']
130+
const result = preprocessSelfClosingComponents(value, tagNames)
131+
expect(result).toBe('<custom-component></custom-component>')
132+
})
133+
134+
it('should handle multiple occurrences', () => {
135+
const value = '<custom-component /> <custom-component />'
136+
const tagNames = ['custom-component']
137+
const result = preprocessSelfClosingComponents(value, tagNames)
138+
expect(result).toBe('<custom-component></custom-component> <custom-component></custom-component>')
139+
})
140+
141+
it('should not affect other tags', () => {
142+
const value = '<other-component />'
143+
const tagNames = ['custom-component']
144+
const result = preprocessSelfClosingComponents(value, tagNames)
145+
expect(result).toBe('<other-component />')
146+
})
147+
148+
it('should handle empty value', () => {
149+
const value = ''
150+
const tagNames = ['custom-component']
151+
const result = preprocessSelfClosingComponents(value, tagNames)
152+
expect(result).toBe('')
153+
})
154+
155+
it('should handle no tag names', () => {
156+
const value = '<custom-component />'
157+
const tagNames: string[] = []
158+
const result = preprocessSelfClosingComponents(value, tagNames)
159+
expect(result).toBe('<custom-component />')
159160
})
160161
})
161162

@@ -331,7 +332,7 @@ After`,
331332
})
332333
}
333334
})
334-
expect(result).toBe('<div><p>Before<div class="test-comp-1">hello world</div></p>\n<p>After</p></div>')
335+
expect(result).toBe('<div><p>Before<div class="test-comp-1">hello world</div>\nAfter</p></div>')
335336
})
336337
it('should handle a custom self-closing tag when text follows immediately on the next line', async () => {
337338
const testComp1 = {

spx-gui/src/components/common/markdown-vue/MarkdownView.ts

Lines changed: 13 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { computed, defineComponent, h, type VNode, type Component } from 'vue'
22
import { fromMarkdown } from 'mdast-util-from-markdown'
33
import { html, find } from 'property-information'
44
import type * as hast from 'hast'
5-
import { toHast, type Raw } from 'mdast-util-to-hast'
5+
import { toHast } from 'mdast-util-to-hast'
66
import { raw } from 'hast-util-raw'
77
import { defaultSchema, sanitize, type Schema as SanitizeSchema } from 'hast-util-sanitize'
88

@@ -129,20 +129,11 @@ export function preprocessCustomRawComponents(value: string, tagNames: string[])
129129
return value
130130
}
131131

132-
const tagNamePattern = /<([a-zA-Z0-9-]+)(\s[^>]*)?\/>$/
133-
function getTagName(line: string): string | null {
134-
const m = line.match(tagNamePattern)
135-
return m != null ? m[1] : null
136-
}
137-
138132
/**
139-
* Preprocesses text immediately following custom self-closing elements and line breaks within a Markdown string.
140-
* According to the Markdown specification, this behavior—the custom self-closing element,
141-
* the line break, and the subsequent text being merged into a single html_block—is standard,
142-
* but it is not the result we desire.
143-
*
144-
* We have addressed this situation while adhering to the Markdown specification.
145-
* The text following the custom self-closing element and the line break is separated into two distinct paragraphs.
133+
* Preprocesses custom self-closing elements in a Markdown string.
134+
* According to the Markdown specification, a custom self-closing element followed by a line break
135+
* and subsequent text will be merged into a single html_block, which is standard behavior
136+
* but not the desired result.
146137
*
147138
* For example:
148139
* ```markdown
@@ -151,90 +142,16 @@ function getTagName(line: string): string | null {
151142
* ```
152143
* will be preprocessed into:
153144
* ```markdown
154-
* <custom-component/>
155-
*
145+
* <custom-component></custom-component>
156146
* Content1
157147
* ```
158148
* Refer to: https://github.com/goplus/builder/issues/2472
159149
*/
160-
export function preprocessInlineComponents(value: string, tagNames: string[]) {
161-
const tagSet = new Set(tagNames)
162-
const lines = value.split(/\r?\n/)
163-
const out: string[] = []
164-
165-
for (let i = 0; i < lines.length; i++) {
166-
const line = lines[i]
167-
const trimmed = line.trimEnd()
168-
169-
// Check if the line ends with a self-closing tag
170-
if (trimmed.endsWith('/>')) {
171-
const tag = getTagName(trimmed)
172-
if (
173-
// Tag name must be in the provided list
174-
tag != null &&
175-
tagSet.has(tag) &&
176-
// There must be a following line
177-
i + 1 < lines.length &&
178-
// The next line must NOT be a blank line
179-
lines[i + 1].trim() !== ''
180-
) {
181-
out.push(line)
182-
out.push('')
183-
continue
184-
}
185-
}
186-
out.push(line)
187-
}
188-
189-
return out.join('\n')
190-
}
191-
192-
const tagHeaderPattern = /<([a-zA-Z0-9-]+)(\s|<)/
193-
194-
function processSelfClosingForHastRawNode(node: Raw, tagNames: string[]) {
195-
const value = node.value.trim()
196-
if (!value.endsWith('/>')) return node
197-
const match = value.match(tagHeaderPattern)
198-
if (match == null) return node
199-
const tagName = match[1]
200-
if (!tagNames.includes(tagName)) return node
201-
return {
202-
...node,
203-
value: value.slice(0, -2) + `></${tagName}>`
204-
}
205-
}
206-
207-
function processSelfClosingForHastNode(node: hast.Node, tagNames: string[]): hast.Node {
208-
switch (node.type) {
209-
case 'raw':
210-
return processSelfClosingForHastRawNode(node as Raw, tagNames)
211-
case 'text':
212-
return node
213-
case 'element': {
214-
const element = node as hast.Element
215-
const processedElement = {
216-
...element,
217-
children: element.children.map((child) => processSelfClosingForHastNode(child, tagNames))
218-
}
219-
return processedElement
220-
}
221-
default:
222-
return node
223-
}
224-
}
225-
226-
/**
227-
* Process self-closing tags in the hast nodes.
228-
* This makes sure self-closing is supported for all custom components.
229-
*/
230-
function processSelfClosingForHastNodes(nodes: hast.Nodes, tagNames: string[]): hast.Nodes {
231-
if (nodes.type === 'root') {
232-
return {
233-
...nodes,
234-
children: nodes.children.map((child) => processSelfClosingForHastNode(child, tagNames))
235-
} as hast.Root
236-
}
237-
return processSelfClosingForHastNode(nodes, tagNames) as hast.RootContent
150+
export function preprocessSelfClosingComponents(value: string, tagNames: string[]) {
151+
tagNames.forEach((tagName) => {
152+
value = value.replace(new RegExp(`<${tagName}([^>]*?)\\s*/>`, 'g'), `<${tagName}$1></${tagName}>`)
153+
})
154+
return value
238155
}
239156

240157
/**
@@ -260,13 +177,12 @@ export function preprocessIncompleteTags(value: string, tagNames: string[]) {
260177
function parseMarkdown({ value, components }: Props): hast.Nodes {
261178
const customComponents = { ...components?.custom, ...components?.customRaw }
262179
const customTagNames = Object.keys(customComponents)
263-
value = preprocessInlineComponents(value, customTagNames)
264180
value = preprocessCustomRawComponents(value, Object.keys(components?.customRaw ?? {}))
181+
value = preprocessSelfClosingComponents(value, customTagNames)
265182
value = preprocessIncompleteTags(value, customTagNames)
266183
const mdast = fromMarkdown(value)
267184
const hast = toHast(mdast, { allowDangerousHtml: true })
268-
const hastWithSelfClosingProcessed = processSelfClosingForHastNodes(hast, customTagNames)
269-
const rawProcessed = raw(hastWithSelfClosingProcessed, { tagfilter: false })
185+
const rawProcessed = raw(hast, { tagfilter: false })
270186
const sanitizeSchema = getSanitizeSchema(customComponents)
271187
return sanitize(rawProcessed, sanitizeSchema)
272188
}

0 commit comments

Comments
 (0)