Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI Component MarkdownView for code editor #1109

Merged
merged 1 commit into from
Nov 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 22 additions & 13 deletions spx-gui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions spx-gui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,25 @@
"eslint-plugin-vue": "^9.20.1",
"file-saver": "^2.0.5",
"happy-dom": "^14.3.6",
"hast": "^1.0.0",
"hast-util-raw": "^9.1.0",
"hast-util-sanitize": "^5.0.2",
"install": "^0.13.0",
"jszip": "^3.10.1",
"jwt-decode": "^4.0.0",
"konva": "^9.3.1",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"mdast-util-from-markdown": "^2.0.2",
"mdast-util-to-hast": "^13.2.0",
"monaco-editor": "^0.45.0",
"naive-ui": "^2.38.1",
"nanoid": "^5.0.5",
"npm": "^10.3.0",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
"prettier": "^3.2.2",
"property-information": "^6.5.0",
"qiniu-js": "^3.4.2",
"sass": "^1.69.7",
"scss": "^0.2.4",
Expand Down
143 changes: 143 additions & 0 deletions spx-gui/src/components/common/markdown-vue/MarkdownView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { computed, defineComponent, h, type VNode, type Component } from 'vue'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { html, find } from 'property-information'
import type * as hast from 'hast'
import { toHast } from 'mdast-util-to-hast'
import { raw } from 'hast-util-raw'
import { defaultSchema, sanitize, type Schema } from 'hast-util-sanitize'

export type Props = {
/** Markdown string */
value: string
/**
* Custom components (key as component name expected to be in kebab-case).
* Example:
* ```js
* {
* 'my-comp1': MyComp1,
* 'my-comp2': MyComp2
* }
* ```
* Usage in markdown (prop name expected to be in kebab-case):
* ```markdown
* <my-comp1 prop1="value1" prop2="value2" />
* <my-comp2 prop1="value1" prop2="value2">
* Content
* </my-comp2>
* ```
*/
components?: Record<string, Component>
}

export default defineComponent<Props>(
(props, { attrs }) => {
const hastNodes = computed(() => {
const components = props.components ?? {}
const mdast = fromMarkdown(props.value)
const hast = toHast(mdast, { allowDangerousHtml: true })
const rawProcessed = raw(hast, { tagfilter: false })

// XSS protection
const sanitizeSchema: Schema = {
tagNames: (defaultSchema.tagNames ?? []).concat(...Object.keys(components)),
attributes: {
...defaultSchema.attributes,
...Object.entries(components).reduce(
(attrs, [tagName, component]) => {
attrs[tagName] = getComponentPropNames(component).map(camelCase2KebabCase)
return attrs
},
{} as Record<string, string[]>
)
}
}
return sanitize(rawProcessed, sanitizeSchema)
})
return function render() {
return renderHastNodes(hastNodes.value, attrs, props.components ?? {})
}
},
{
name: 'MarkdownView',
props: {
value: {
type: String,
required: true
},
components: {
type: Object,
required: false,
default: () => ({})
}
}
}
)

function renderHastNodes(node: hast.Nodes, attrs: Record<string, unknown>, components: Record<string, Component>) {
if (node.type === 'root') {
return h(
'div',
attrs,
node.children.map((child) => renderHastNode(child, components))
)
}
return h('div', attrs, [renderHastNode(node, components)])
}

type VRendered = VNode | string | null | VRendered[]

function renderHastNode(node: hast.Node, components: Record<string, Component>): VRendered {
switch (node.type) {
case 'element':
return renderHastElement(node as hast.Element, components)
case 'text':
return (node as hast.Text).value
default:
return null
}
}

function renderHastElement(element: hast.Element, components: Record<string, Component>): VNode {
const props = hastProps2VueProps(element.properties)
let type: string | Component
let children: VRendered | (() => VRendered)
if (Object.prototype.hasOwnProperty.call(components, element.tagName)) {
type = components[element.tagName]
// Use function slot for custom components to avoid Vue warning:
// [Vue warn]: Non-function value encountered for default slot. Prefer function slots for better performance.
children = () => element.children.map((c) => renderHastNode(c, components))
} else {
type = element.tagName
children = element.children.map((c) => renderHastNode(c, components))
}
return h(type, props, children)
}

function hastProps2VueProps(properties: hast.Properties) {
return Object.keys(properties).reduce(
(props, key) => {
const value = properties[key]
if (value == null) return props
const propertyInfo = find(html, key)
const newKey = propertyInfo.attribute
const newValue = Array.isArray(value) ? value[0] : value
props[newKey] = newValue
return props
},
{} as Record<string, string | boolean | number>
)
}

function camelCase2KebabCase(str: string) {
return str
.replace(/([A-Z])/g, '-$1')
.replace(/^-/, '')
.toLowerCase()
}

function getComponentPropNames(compDef: Component): string[] {
const props = (compDef as any).props
if (props == null) return []
if (Array.isArray(props)) return props
return Object.keys(props)
}
6 changes: 6 additions & 0 deletions spx-gui/src/components/common/markdown-vue/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Using Markdown with Vue

- Render markdown in Vue components
- Integrate Vue components in markdown
- Seamless cooperation between nested and parent Vue components
- In-browser parsing and rendering
41 changes: 41 additions & 0 deletions spx-gui/src/components/editor/code-editor/common.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest'
import { stringifyDefinitionId, parseDefinitionId } from './common'

describe('stringifyDefinitionId', () => {
it('should stringify definition identifier', () => {
expect(stringifyDefinitionId({ package: 'fmt', name: 'Println' })).toBe('gop:fmt?Println')
expect(stringifyDefinitionId({ package: 'github.com/goplus/spx', name: 'Sprite.Clone', overloadId: '1' })).toBe(
'gop:github.com/goplus/spx?Sprite.Clone#1'
)
expect(stringifyDefinitionId({ name: 'for_statement_with_single_condition' })).toBe(
'gop:?for_statement_with_single_condition'
)
expect(stringifyDefinitionId({ package: 'main', name: 'foo' })).toBe('gop:main?foo')
})
})

describe('parseDefinitionId', () => {
it('should parse definition identifier', () => {
expect(parseDefinitionId('gop:fmt?Println')).toEqual({ package: 'fmt', name: 'Println' })
expect(parseDefinitionId('gop:github.com/goplus/spx?Sprite.Clone#1')).toEqual({
package: 'github.com/goplus/spx',
name: 'Sprite.Clone',
overloadId: '1'
})
expect(parseDefinitionId('gop:?for_statement_with_single_condition')).toEqual({
name: 'for_statement_with_single_condition'
})
expect(parseDefinitionId('gop:main?foo')).toEqual({ package: 'main', name: 'foo' })
})

it('should stringify & parse well', () => {
;[
{ package: 'fmt', name: 'Println' },
{ package: 'github.com/goplus/spx', name: 'Sprite.Clone', overloadId: '1' },
{ name: 'for_statement_with_single_condition' },
{ package: 'main', name: 'foo' }
].forEach((defId) => {
expect(parseDefinitionId(stringifyDefinitionId(defId))).toEqual(defId)
})
})
})
37 changes: 25 additions & 12 deletions spx-gui/src/components/editor/code-editor/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,18 +73,31 @@ export type DefinitionIdentifier = {
* - `for_statement_with_single_condition`: kind-statement
*/
name?: string
/** Index in overloads. */
overloadIndex?: number
}

export function stringifyDefinitionId(defId: DefinitionIdentifier): string {
if (defId.name == null) {
if (defId.package == null) throw new Error('package expected for ' + defId)
return defId.package
/** Overload Identifier. */
overloadId?: string
}

/** gop:<package>?<name>#<overloadId> */
type DefinitionIdString = string

export function stringifyDefinitionId(defId: DefinitionIdentifier): DefinitionIdString {
let idStr = 'gop:'
if (defId.package != null) idStr += `${defId.package}`
if (defId.name != null) idStr += `?${encodeURIComponent(defId.name)}`
if (defId.overloadId != null) idStr += `#${encodeURIComponent(defId.overloadId)}`
return idStr
}

export function parseDefinitionId(idStr: DefinitionIdString): DefinitionIdentifier {
if (!idStr.startsWith('gop:')) throw new Error(`Invalid definition ID: ${idStr}`)
idStr = idStr.slice(4)
const [withoutHash, hash = ''] = idStr.split('#')
const [hostWithPath, query = ''] = withoutHash.split('?')
return {
package: hostWithPath === '' ? undefined : hostWithPath,
name: query === '' ? undefined : decodeURIComponent(query),
overloadId: hash === '' ? undefined : decodeURIComponent(hash)
}
const suffix = defId.overloadIndex == null ? '' : `[${defId.overloadIndex}]`
if (defId.package == null) return defId.name + suffix
return defId.package + '|' + defId.name + suffix
}

/**
Expand All @@ -105,7 +118,7 @@ export type MarkdownStringFlag = 'basic' | 'advanced'
* We use flag to distinguish different types of Markdown string.
* Different types of Markdown string expect different rendering behaviors, especially for custom components support in MDX.
*/
export type MarkdownString<F extends MarkdownStringFlag> = {
export type MarkdownString<F extends MarkdownStringFlag = MarkdownStringFlag> = {
flag?: F
/** Markdown string with MDX support. */
value: string | LocaleMessage
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,8 @@ export const forIterate: DefinitionDocumentationItem = {
definition: { name: 'for_iterate (TODO)' },
insertText: 'for ${1:i}, ${2:v} <- ${3:set} {\n\t${4:}\n}',
overview: 'for i, v <- set {} (TODO)',
detail: makeBasicMarkdownString({ en: 'Iterate within given set', zh: '遍历指定集合' })
detail: makeBasicMarkdownString({
en: 'Iterate within given set, e.g., `for i, v <- set {}`',
zh: '遍历指定集合,示例:`for i, v <- set {}`'
})
}
10 changes: 8 additions & 2 deletions spx-gui/src/components/editor/code-editor/document-base/spx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ export const onStart: DefinitionDocumentationItem = {
},
insertText: 'onStart => {\n\t${1}\n}',
overview: 'func onStart(callback func())',
detail: makeBasicMarkdownString({ en: 'Listen to game start', zh: '游戏开始时执行' })
detail: makeBasicMarkdownString({
en: 'Listen to game start, e.g., `onStart => {}`',
zh: '游戏开始时执行,示例:`onStart => {}`'
})
}

export const setXYpos: DefinitionDocumentationItem = {
Expand All @@ -29,5 +32,8 @@ export const setXYpos: DefinitionDocumentationItem = {
},
insertText: 'setXYpos ${1:X}, ${2:Y}',
overview: 'func setXYpos(x, y float64)',
detail: makeBasicMarkdownString({ en: 'Move to given position', zh: '移动到指定位置' })
detail: makeBasicMarkdownString({
en: 'Move to given position, e.g., `setXYpos 0, 0`',
zh: '移动到指定位置,示例:`setXYpos 0, 0`'
})
}
Loading
Loading