feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
export const MERMAID_PREVIEW_MIN_HEIGHT = 360
|
||||
export const MERMAID_PREVIEW_MAX_HEIGHT = 500
|
||||
export const INFOGRAPHIC_PREVIEW_MIN_HEIGHT = 360
|
||||
export const INFOGRAPHIC_PREVIEW_MAX_HEIGHT = 500
|
||||
|
||||
export function parsePositiveNumber(value: unknown) {
|
||||
const numeric = typeof value === 'number' ? value : Number.parseFloat(String(value ?? ''))
|
||||
return Number.isFinite(numeric) && numeric > 0 ? numeric : null
|
||||
}
|
||||
|
||||
export function getMermaidDiagramKind(code: string) {
|
||||
for (const rawLine of code.split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
if (!line || line.startsWith('%%'))
|
||||
continue
|
||||
const match = line.match(/^([A-Z][\w-]*)\b/i)
|
||||
return match?.[1]?.toLowerCase() || ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function estimateMermaidPreviewHeight(code: string) {
|
||||
const meaningfulLines = code
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('%%'))
|
||||
const lineCount = Math.max(1, meaningfulLines.length)
|
||||
const kind = getMermaidDiagramKind(code)
|
||||
|
||||
if (kind === 'gantt')
|
||||
return 220 + lineCount * 28
|
||||
if (kind === 'sequencediagram')
|
||||
return 180 + lineCount * 26
|
||||
if (kind === 'classdiagram' || kind === 'statediagram' || kind === 'erdiagram')
|
||||
return 180 + lineCount * 24
|
||||
if (kind === 'flowchart' || kind === 'graph')
|
||||
return 170 + lineCount * 28
|
||||
return 200 + lineCount * 22
|
||||
}
|
||||
|
||||
export function estimateInfographicPreviewHeight(code: string) {
|
||||
const itemCount = code
|
||||
.split(/\r?\n/)
|
||||
.filter(line => /^\s*-\s+/.test(line))
|
||||
.length
|
||||
|
||||
if (itemCount >= 3)
|
||||
return INFOGRAPHIC_PREVIEW_MAX_HEIGHT
|
||||
if (itemCount > 0)
|
||||
return 280 + itemCount * 60
|
||||
|
||||
return INFOGRAPHIC_PREVIEW_MIN_HEIGHT
|
||||
}
|
||||
|
||||
export function clampPreviewHeight(
|
||||
height: number,
|
||||
minHeight = MERMAID_PREVIEW_MIN_HEIGHT,
|
||||
maxHeight: number | null = MERMAID_PREVIEW_MAX_HEIGHT,
|
||||
) {
|
||||
return maxHeight == null
|
||||
? Math.max(minHeight, height)
|
||||
: Math.min(Math.max(minHeight, height), maxHeight)
|
||||
}
|
||||
|
||||
export function clampMermaidPreviewHeight(
|
||||
height: number,
|
||||
minHeight = MERMAID_PREVIEW_MIN_HEIGHT,
|
||||
maxHeight: number | null = MERMAID_PREVIEW_MAX_HEIGHT,
|
||||
) {
|
||||
return clampPreviewHeight(height, minHeight, maxHeight)
|
||||
}
|
||||
|
||||
export function clampInfographicPreviewHeight(
|
||||
height: number,
|
||||
minHeight = INFOGRAPHIC_PREVIEW_MIN_HEIGHT,
|
||||
maxHeight: number | null = INFOGRAPHIC_PREVIEW_MAX_HEIGHT,
|
||||
) {
|
||||
return clampPreviewHeight(height, minHeight, maxHeight)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { HtmlToken } from 'stream-markdown-parser'
|
||||
import type { Component } from 'vue-demi'
|
||||
import {
|
||||
BLOCKED_HTML_TAGS as BLOCKED_TAGS,
|
||||
convertHtmlAttrsToProps,
|
||||
convertHtmlPropValue,
|
||||
getHtmlTagFromContent,
|
||||
hasCompleteHtmlTagContent,
|
||||
hasCustomHtmlComponents,
|
||||
isCustomHtmlComponentTag,
|
||||
sanitizeHtmlAttrs,
|
||||
shouldRenderUnknownHtmlTagAsText,
|
||||
stripCustomHtmlWrapper,
|
||||
tokenizeHtml,
|
||||
} from 'stream-markdown-parser'
|
||||
import { h as vueH } from 'vue'
|
||||
import * as VueModule from 'vue-demi'
|
||||
import { h as demiH } from 'vue-demi'
|
||||
|
||||
export {
|
||||
getHtmlTagFromContent,
|
||||
hasCompleteHtmlTagContent,
|
||||
shouldRenderUnknownHtmlTagAsText,
|
||||
stripCustomHtmlWrapper,
|
||||
tokenizeHtml,
|
||||
}
|
||||
|
||||
export type { HtmlToken } from 'stream-markdown-parser'
|
||||
|
||||
type CreateElementLike = (tag: any, attrs?: Record<string, any>, children?: any[] | undefined) => any
|
||||
|
||||
function getVue2CreateElementFallback(): CreateElementLike | null {
|
||||
const anyMod = VueModule as any
|
||||
const isVueCtor = (candidate: any) => typeof candidate === 'function' && typeof candidate.extend === 'function'
|
||||
const candidates = [
|
||||
anyMod,
|
||||
anyMod?.default,
|
||||
anyMod?.default?.default,
|
||||
(anyMod?.default ?? anyMod)?.default,
|
||||
anyMod?.Vue2,
|
||||
anyMod?.Vue,
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
const ctor = isVueCtor(candidate) ? candidate : (isVueCtor(candidate?.default) ? candidate.default : null)
|
||||
if (!ctor)
|
||||
continue
|
||||
try {
|
||||
// eslint-disable-next-line new-cap
|
||||
const vm = new ctor()
|
||||
if (typeof vm.$createElement === 'function')
|
||||
return vm.$createElement.bind(vm) as CreateElementLike
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const vue2CreateElementFallback = getVue2CreateElementFallback()
|
||||
|
||||
function safeRender(
|
||||
createElement: CreateElementLike | undefined,
|
||||
tag: any,
|
||||
attrs?: Record<string, any>,
|
||||
children?: any[] | undefined,
|
||||
) {
|
||||
if (createElement)
|
||||
return createElement(tag, attrs, children)
|
||||
|
||||
try {
|
||||
return vueH(tag as any, attrs as any, children as any)
|
||||
}
|
||||
catch {}
|
||||
|
||||
try {
|
||||
return demiH(tag, attrs, children)
|
||||
}
|
||||
catch {}
|
||||
|
||||
if (vue2CreateElementFallback)
|
||||
return vue2CreateElementFallback(tag, attrs, children)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const SHOULD_LOG = (() => {
|
||||
try {
|
||||
return Boolean((import.meta as any).env?.DEV)
|
||||
}
|
||||
catch {}
|
||||
return false
|
||||
})()
|
||||
|
||||
function warn(message: string) {
|
||||
if (SHOULD_LOG)
|
||||
console.warn(message)
|
||||
}
|
||||
|
||||
function logError(message: string, err: unknown) {
|
||||
if (SHOULD_LOG)
|
||||
console.error(message, err)
|
||||
}
|
||||
|
||||
export function isCustomComponent(
|
||||
tagName: string,
|
||||
customComponents: Record<string, Component>,
|
||||
): boolean {
|
||||
return isCustomHtmlComponentTag(tagName, customComponents as Record<string, unknown>)
|
||||
}
|
||||
|
||||
export function sanitizeAttrs(attrs: Record<string, string>): Record<string, string> {
|
||||
return sanitizeHtmlAttrs(attrs)
|
||||
}
|
||||
|
||||
export function convertPropValue(value: string, key: string): any {
|
||||
return convertHtmlPropValue(value, key)
|
||||
}
|
||||
|
||||
export function convertAttrsToProps(attrs: Record<string, string>): Record<string, any> {
|
||||
return convertHtmlAttrsToProps(attrs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build VNode tree from tokens
|
||||
*/
|
||||
export function buildVNodeTree(
|
||||
tokens: HtmlToken[],
|
||||
customComponents: Record<string, Component>,
|
||||
createElement?: CreateElementLike,
|
||||
): any[] {
|
||||
const stack: Array<{ tagName: string, children: any[], attrs?: Record<string, string> }> = []
|
||||
const rootNodes: any[] = []
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.type === 'text') {
|
||||
const target = stack.length > 0 ? stack[stack.length - 1].children : rootNodes
|
||||
target.push(token.content!)
|
||||
}
|
||||
else if (token.type === 'self_closing') {
|
||||
const vnode = createVNode(token.tagName!, token.attrs || {}, [], customComponents, createElement)
|
||||
const target = stack.length > 0 ? stack[stack.length - 1].children : rootNodes
|
||||
vnode != null && target.push(vnode)
|
||||
}
|
||||
else if (token.type === 'tag_open') {
|
||||
stack.push({ tagName: token.tagName!, children: [], attrs: token.attrs })
|
||||
}
|
||||
else if (token.type === 'tag_close') {
|
||||
const closingTag = token.tagName!.toLowerCase()
|
||||
|
||||
// Find matching opening tag (handle nested same tags)
|
||||
let matchedIndex = -1
|
||||
for (let i = stack.length - 1; i >= 0; i--) {
|
||||
if (stack[i].tagName.toLowerCase() === closingTag) {
|
||||
matchedIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedIndex !== -1) {
|
||||
// Pop all tags until the matched one (auto-closing intermediate tags)
|
||||
while (stack.length > matchedIndex) {
|
||||
const opening = stack.pop()!
|
||||
const vnode = createVNode(opening.tagName, opening.attrs || {}, opening.children, customComponents, createElement)
|
||||
|
||||
if (stack.length > 0)
|
||||
vnode != null && stack[stack.length - 1].children.push(vnode)
|
||||
else
|
||||
vnode != null && rootNodes.push(vnode)
|
||||
|
||||
// Warn if auto-closing tags
|
||||
if (opening.tagName.toLowerCase() !== closingTag && stack.length > matchedIndex) {
|
||||
warn(`Auto-closing unclosed tag: <${opening.tagName}>`)
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// No matching opening tag, warn and ignore
|
||||
warn(`Ignoring closing tag with no matching opening tag: </${token.tagName}>`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle any remaining unclosed tags
|
||||
while (stack.length > 0) {
|
||||
const unclosed = stack.pop()!
|
||||
const vnode = createVNode(unclosed.tagName, unclosed.attrs || {}, unclosed.children, customComponents, createElement)
|
||||
vnode != null && rootNodes.push(vnode)
|
||||
warn(`Auto-closing unclosed tag: <${unclosed.tagName}>`)
|
||||
}
|
||||
|
||||
return rootNodes
|
||||
}
|
||||
|
||||
/**
|
||||
* Create VNode for a tag
|
||||
*/
|
||||
function createVNode(
|
||||
tagName: string,
|
||||
attrs: Record<string, string>,
|
||||
children: any[],
|
||||
customComponents: Record<string, Component>,
|
||||
createElement?: CreateElementLike,
|
||||
): any {
|
||||
if (BLOCKED_TAGS.has(tagName.toLowerCase()))
|
||||
return null
|
||||
|
||||
const sanitizedAttrs = sanitizeAttrs(attrs)
|
||||
|
||||
if (isCustomComponent(tagName, customComponents)) {
|
||||
// It's a custom Vue component
|
||||
const component = customComponents[tagName] || customComponents[tagName.toLowerCase()]
|
||||
const convertedAttrs = convertAttrsToProps(sanitizedAttrs)
|
||||
return safeRender(createElement, component as Component, convertedAttrs, children.length > 0 ? children : undefined)
|
||||
}
|
||||
else {
|
||||
// It's a standard HTML element
|
||||
const { innerHTML, ...validAttrs } = sanitizedAttrs as any
|
||||
return safeRender(createElement, tagName, validAttrs, children.length > 0 ? children : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if HTML content contains custom components
|
||||
*/
|
||||
export function hasCustomComponents(
|
||||
content: string,
|
||||
customComponents: Record<string, Component>,
|
||||
): boolean {
|
||||
return hasCustomHtmlComponents(content, customComponents as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HTML content to VNodes
|
||||
*/
|
||||
export function parseHtmlToVNodes(
|
||||
content: string,
|
||||
customComponents: Record<string, Component>,
|
||||
createElement?: CreateElementLike,
|
||||
): any[] | null {
|
||||
if (!content)
|
||||
return []
|
||||
|
||||
try {
|
||||
const tokens = tokenizeHtml(content)
|
||||
const nodes = buildVNodeTree(tokens, customComponents, createElement)
|
||||
return nodes
|
||||
}
|
||||
catch (error) {
|
||||
logError('Failed to parse HTML to VNodes:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from '../components/CodeBlockNode/monaco'
|
||||
export * from './languageIcon'
|
||||
export * from './nestedHtml'
|
||||
export * from './nestedNodes'
|
||||
export * from 'stream-markdown-parser'
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Recommend when to use Worker based on formula count and complexity.
|
||||
*
|
||||
* N_threshold ≈ floor(B / (R × (1 - H)))
|
||||
* - B: main-thread budget (ms), e.g. 50ms or a stricter 16.7ms
|
||||
* - R: avg render time per unique formula (ms)
|
||||
* - H: cache hit rate (0~1)
|
||||
*/
|
||||
|
||||
export interface ThresholdInput {
|
||||
R: number // avg render time per unique formula, ms
|
||||
H?: number // cache hit rate (0~1), default 0 for first paint
|
||||
B?: number // main-thread budget ms, default 50
|
||||
}
|
||||
|
||||
export function recommendWorkerThreshold({ R, H = 0, B = 50 }: ThresholdInput) {
|
||||
const denom = R * (1 - H) || 1e-6
|
||||
const N = Math.max(1, Math.floor(B / denom))
|
||||
return N
|
||||
}
|
||||
|
||||
// A very lightweight classifier by formula length/characters to pick R ballpark
|
||||
export function estimateRByFormula(sample: string): 'simple' | 'medium' | 'complex' {
|
||||
const len = sample.length
|
||||
const slashes = (sample.match(/\\/g) || []).length
|
||||
// heuristics: more backslashes and longer length indicate complexity
|
||||
const score = len + slashes * 10
|
||||
if (score < 10)
|
||||
return 'simple'
|
||||
if (score < 40)
|
||||
return 'medium'
|
||||
return 'complex'
|
||||
}
|
||||
|
||||
export function defaultRByClass(cls: 'simple' | 'medium' | 'complex'): number {
|
||||
// conservative defaults (ms) for mid-tier devices
|
||||
switch (cls) {
|
||||
case 'simple': return 3
|
||||
case 'medium': return 10
|
||||
case 'complex': return 30
|
||||
}
|
||||
}
|
||||
|
||||
export function recommendNForSamples(formulas: string[], opts?: { H?: number, B?: number }) {
|
||||
// classify each and take the worst (max R) since bursts often contain mixed complexity
|
||||
let maxR = 0
|
||||
for (const f of formulas) {
|
||||
const cls = estimateRByFormula(f)
|
||||
const R = defaultRByClass(cls)
|
||||
if (R > maxR)
|
||||
maxR = R
|
||||
}
|
||||
return recommendWorkerThreshold({ R: maxR, H: opts?.H ?? 0, B: opts?.B ?? 50 })
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { ref } from 'vue-demi'
|
||||
import CIcon from '../icon/c.svg?raw'
|
||||
import CppIcon from '../icon/cpp.svg?raw'
|
||||
import CsharpIcon from '../icon/csharp.svg?raw'
|
||||
import CssIcon from '../icon/css.svg?raw'
|
||||
import GoIcon from '../icon/go.svg?raw'
|
||||
import HtmlIcon from '../icon/html.svg?raw'
|
||||
import JavaIcon from '../icon/java.svg?raw'
|
||||
import JsxReactIcon from '../icon/javascript-react.svg?raw'
|
||||
import JsIcon from '../icon/javascript.svg?raw'
|
||||
import JsonIcon from '../icon/json.svg?raw'
|
||||
import KotlinIcon from '../icon/kotlin.svg?raw'
|
||||
import MarkdownIcon from '../icon/markdown.svg?raw'
|
||||
import MermaidIcon from '../icon/mermaid.svg?raw'
|
||||
import PhpIcon from '../icon/php.svg?raw'
|
||||
import PythonIcon from '../icon/python.svg?raw'
|
||||
import RubyIcon from '../icon/ruby.svg?raw'
|
||||
import RustIcon from '../icon/rust.svg?raw'
|
||||
import SassIcon from '../icon/sass.svg?raw'
|
||||
import ShellIcon from '../icon/shell.svg?raw'
|
||||
import SqlIcon from '../icon/sql.svg?raw'
|
||||
import SquareCodeIcon from '../icon/square-code.svg?raw'
|
||||
import SvgIcon from '../icon/svg.svg?raw'
|
||||
import TextIcon from '../icon/text.svg?raw'
|
||||
import TsReactIcon from '../icon/typescript-react.svg?raw'
|
||||
import TsIcon from '../icon/typescript.svg?raw'
|
||||
import VueIcon from '../icon/vue.svg?raw'
|
||||
import XmlIcon from '../icon/xml.svg?raw'
|
||||
import YamlIcon from '../icon/yaml.svg?raw'
|
||||
|
||||
export type LanguageIconResolver = (lang: string) => string | undefined | null
|
||||
|
||||
type LanguageIconMap = Record<string, string>
|
||||
|
||||
let userLanguageIconResolver: LanguageIconResolver | null = null
|
||||
let extendedLanguageIconMap: LanguageIconMap | null = null
|
||||
let extendedLanguageIconPromise: Promise<LanguageIconMap | null> | null = null
|
||||
|
||||
export const languageIconsRevision = ref(0)
|
||||
|
||||
const DEFAULT_LANGUAGE_ICON = SquareCodeIcon
|
||||
|
||||
const CORE_LANGUAGE_ICON_MAP: LanguageIconMap = {
|
||||
'': TextIcon,
|
||||
'plain': TextIcon,
|
||||
'text': TextIcon,
|
||||
'javascript': JsIcon,
|
||||
'typescript': TsIcon,
|
||||
'jsx': JsxReactIcon,
|
||||
'tsx': TsReactIcon,
|
||||
'html': HtmlIcon,
|
||||
'css': CssIcon,
|
||||
'scss': SassIcon,
|
||||
'json': JsonIcon,
|
||||
'python': PythonIcon,
|
||||
'ruby': RubyIcon,
|
||||
'go': GoIcon,
|
||||
'java': JavaIcon,
|
||||
'kotlin': KotlinIcon,
|
||||
'c': CIcon,
|
||||
'cpp': CppIcon,
|
||||
'cs': CsharpIcon,
|
||||
'csharp': CsharpIcon,
|
||||
'php': PhpIcon,
|
||||
'shell': ShellIcon,
|
||||
'powershell': ShellIcon,
|
||||
'sql': SqlIcon,
|
||||
'yaml': YamlIcon,
|
||||
'markdown': MarkdownIcon,
|
||||
'xml': XmlIcon,
|
||||
'rust': RustIcon,
|
||||
'vue': VueIcon,
|
||||
'mermaid': MermaidIcon,
|
||||
'svg': SvgIcon,
|
||||
}
|
||||
|
||||
const LANGUAGE_ALIAS_MAP: Record<string, string> = {
|
||||
'': '',
|
||||
'javascript': 'javascript',
|
||||
'js': 'javascript',
|
||||
'mjs': 'javascript',
|
||||
'cjs': 'javascript',
|
||||
'typescript': 'typescript',
|
||||
'ts': 'typescript',
|
||||
'jsx': 'jsx',
|
||||
'tsx': 'tsx',
|
||||
'golang': 'go',
|
||||
'py': 'python',
|
||||
'rb': 'ruby',
|
||||
'sh': 'shell',
|
||||
'bash': 'shell',
|
||||
'zsh': 'shell',
|
||||
'shellscript': 'shell',
|
||||
'bat': 'shell',
|
||||
'batch': 'shell',
|
||||
'ps1': 'powershell',
|
||||
'plaintext': 'plain',
|
||||
'text': 'plain',
|
||||
'c++': 'cpp',
|
||||
'c#': 'csharp',
|
||||
'objective-c': 'objectivec',
|
||||
'objective-c++': 'objectivecpp',
|
||||
'yml': 'yaml',
|
||||
'md': 'markdown',
|
||||
'rs': 'rust',
|
||||
'kt': 'kotlin',
|
||||
'd': 'dlang',
|
||||
'vbnet': 'vb.net',
|
||||
}
|
||||
|
||||
function extractLanguageToken(lang?: string | null): string {
|
||||
if (!lang)
|
||||
return ''
|
||||
const trimmed = lang.trim()
|
||||
if (!trimmed)
|
||||
return ''
|
||||
const [firstToken] = trimmed.split(/\s+/)
|
||||
const [base] = firstToken.split(':')
|
||||
return base.toLowerCase()
|
||||
}
|
||||
|
||||
async function loadExtendedLanguageIconMap(): Promise<LanguageIconMap | null> {
|
||||
if (extendedLanguageIconMap)
|
||||
return extendedLanguageIconMap
|
||||
|
||||
if (!extendedLanguageIconPromise) {
|
||||
extendedLanguageIconPromise = import('./languageIconExtended')
|
||||
.then((mod) => {
|
||||
extendedLanguageIconMap = mod.EXTENDED_LANGUAGE_ICON_MAP
|
||||
languageIconsRevision.value++
|
||||
return extendedLanguageIconMap
|
||||
})
|
||||
.catch(() => null)
|
||||
}
|
||||
|
||||
return extendedLanguageIconPromise
|
||||
}
|
||||
|
||||
export function setLanguageIconResolver(resolver?: LanguageIconResolver | null) {
|
||||
userLanguageIconResolver = resolver ?? null
|
||||
}
|
||||
|
||||
export function normalizeLanguageIdentifier(lang?: string | null): string {
|
||||
const token = extractLanguageToken(lang)
|
||||
return LANGUAGE_ALIAS_MAP[token] ?? token
|
||||
}
|
||||
|
||||
export function resolveMonacoLanguageId(lang?: string | null): string {
|
||||
const canonical = normalizeLanguageIdentifier(lang)
|
||||
if (!canonical)
|
||||
return 'plaintext'
|
||||
switch (canonical) {
|
||||
case 'plain':
|
||||
return 'plaintext'
|
||||
case 'jsx':
|
||||
return 'javascript'
|
||||
case 'tsx':
|
||||
return 'typescript'
|
||||
default:
|
||||
return canonical
|
||||
}
|
||||
}
|
||||
|
||||
export async function preloadExtendedLanguageIcons() {
|
||||
await loadExtendedLanguageIconMap()
|
||||
}
|
||||
|
||||
export function getLanguageIcon(lang: string): string {
|
||||
if (userLanguageIconResolver) {
|
||||
const hit = userLanguageIconResolver(lang)
|
||||
if (hit != null && hit !== '')
|
||||
return hit
|
||||
}
|
||||
|
||||
const normalized = normalizeLanguageIdentifier(lang)
|
||||
const coreIcon = CORE_LANGUAGE_ICON_MAP[normalized]
|
||||
if (coreIcon)
|
||||
return coreIcon
|
||||
|
||||
const extendedIcon = extendedLanguageIconMap?.[normalized]
|
||||
if (extendedIcon)
|
||||
return extendedIcon
|
||||
|
||||
void loadExtendedLanguageIconMap()
|
||||
return DEFAULT_LANGUAGE_ICON
|
||||
}
|
||||
|
||||
// 映射一些常见语言的显示名称
|
||||
export const languageMap: Record<string, string> = {
|
||||
'js': 'JavaScript',
|
||||
'javascript': 'JavaScript',
|
||||
'ts': 'TypeScript',
|
||||
'jsx': 'JSX',
|
||||
'tsx': 'TSX',
|
||||
'html': 'HTML',
|
||||
'css': 'CSS',
|
||||
'scss': 'SCSS',
|
||||
'json': 'JSON',
|
||||
'py': 'Python',
|
||||
'python': 'Python',
|
||||
'rb': 'Ruby',
|
||||
'go': 'Go',
|
||||
'java': 'Java',
|
||||
'c': 'C',
|
||||
'cpp': 'C++',
|
||||
'cs': 'C#',
|
||||
'php': 'PHP',
|
||||
'sh': 'Shell',
|
||||
'bash': 'Bash',
|
||||
'sql': 'SQL',
|
||||
'yaml': 'YAML',
|
||||
'md': 'Markdown',
|
||||
'd2': 'D2',
|
||||
'd2lang': 'D2',
|
||||
'': 'Plain Text',
|
||||
'plain': 'Plain Text',
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import AdaIcon from '../icon/ada.svg?raw'
|
||||
import ApplescriptIcon from '../icon/apple.svg?raw'
|
||||
import AssemblyIcon from '../icon/assembly.svg?raw'
|
||||
import ClojureIcon from '../icon/clojure.svg?raw'
|
||||
import CobolIcon from '../icon/cobol.svg?raw'
|
||||
import CrystalIcon from '../icon/crystal.svg?raw'
|
||||
import DartIcon from '../icon/dart.svg?raw'
|
||||
import DlangIcon from '../icon/dlang.svg?raw'
|
||||
import DockerIcon from '../icon/docker.svg?raw'
|
||||
import ElixirIcon from '../icon/elixir.svg?raw'
|
||||
import ErlangIcon from '../icon/erlang.svg?raw'
|
||||
import FortranIcon from '../icon/fortran.svg?raw'
|
||||
import GroovyIcon from '../icon/groovy.svg?raw'
|
||||
import HaskellIcon from '../icon/haskell.svg?raw'
|
||||
import JuliaIcon from '../icon/julia.svg?raw'
|
||||
import LispIcon from '../icon/lisp.svg?raw'
|
||||
import LuaIcon from '../icon/lua.svg?raw'
|
||||
import MatlabIcon from '../icon/matlab.svg?raw'
|
||||
import NimIcon from '../icon/nim.svg?raw'
|
||||
import ObjectivecIcon from '../icon/objectivec.svg?raw'
|
||||
import ObjectivecppIcon from '../icon/objectivecpp.svg?raw'
|
||||
import OcamlIcon from '../icon/ocaml.svg?raw'
|
||||
import PerlIcon from '../icon/perl.svg?raw'
|
||||
import PrologIcon from '../icon/prolog.svg?raw'
|
||||
import RIcon from '../icon/r.svg?raw'
|
||||
import ScalaIcon from '../icon/scala.svg?raw'
|
||||
import SolidityIcon from '../icon/solidity.svg?raw'
|
||||
import SwiftIcon from '../icon/swift.svg?raw'
|
||||
import TerraformIcon from '../icon/terraform.svg?raw'
|
||||
import VbnetIcon from '../icon/vbnet.svg?raw'
|
||||
|
||||
export const EXTENDED_LANGUAGE_ICON_MAP: Record<string, string> = {
|
||||
'r': RIcon,
|
||||
'scala': ScalaIcon,
|
||||
'swift': SwiftIcon,
|
||||
'perl': PerlIcon,
|
||||
'lua': LuaIcon,
|
||||
'haskell': HaskellIcon,
|
||||
'erlang': ErlangIcon,
|
||||
'clojure': ClojureIcon,
|
||||
'dart': DartIcon,
|
||||
'assembly': AssemblyIcon,
|
||||
'dockerfile': DockerIcon,
|
||||
'fortran': FortranIcon,
|
||||
'lisp': LispIcon,
|
||||
'ocaml': OcamlIcon,
|
||||
'prolog': PrologIcon,
|
||||
'groovy': GroovyIcon,
|
||||
'matlab': MatlabIcon,
|
||||
'cobol': CobolIcon,
|
||||
'ada': AdaIcon,
|
||||
'julia': JuliaIcon,
|
||||
'elixir': ElixirIcon,
|
||||
'vb.net': VbnetIcon,
|
||||
'nim': NimIcon,
|
||||
'crystal': CrystalIcon,
|
||||
'dlang': DlangIcon,
|
||||
'applescript': ApplescriptIcon,
|
||||
'solidity': SolidityIcon,
|
||||
'objectivec': ObjectivecIcon,
|
||||
'objectivecpp': ObjectivecppIcon,
|
||||
'terraform': TerraformIcon,
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
interface CachedMathRender {
|
||||
html: string
|
||||
}
|
||||
|
||||
const MAX_CACHE_SIZE = 200
|
||||
const mathRenderCache = new Map<string, CachedMathRender>()
|
||||
|
||||
function touchCacheEntry(key: string, value: CachedMathRender) {
|
||||
mathRenderCache.delete(key)
|
||||
mathRenderCache.set(key, value)
|
||||
if (mathRenderCache.size > MAX_CACHE_SIZE) {
|
||||
const oldestKey = mathRenderCache.keys().next().value
|
||||
if (oldestKey)
|
||||
mathRenderCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedMathRender(key: string | null | undefined) {
|
||||
if (!key)
|
||||
return null
|
||||
const cached = mathRenderCache.get(key) || null
|
||||
if (cached)
|
||||
touchCacheEntry(key, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
export function setCachedMathRender(key: string | null | undefined, html: string) {
|
||||
if (!key || !html)
|
||||
return
|
||||
touchCacheEntry(key, { html })
|
||||
}
|
||||
|
||||
export function clearCachedMathRender(key?: string | null) {
|
||||
if (!key) {
|
||||
mathRenderCache.clear()
|
||||
return
|
||||
}
|
||||
mathRenderCache.delete(key)
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
import type { BaseNode, CustomComponentAttrs, ParsedNode } from 'stream-markdown-parser'
|
||||
import {
|
||||
DANGEROUS_HTML_ATTRS,
|
||||
getMarkdown,
|
||||
isUnsafeHtmlUrl,
|
||||
NON_STRUCTURING_HTML_TAGS,
|
||||
normalizeCustomHtmlTagName,
|
||||
normalizeCustomHtmlTags,
|
||||
URL_HTML_ATTRS,
|
||||
} from 'stream-markdown-parser'
|
||||
|
||||
export type NestedRenderableNode = (ParsedNode | BaseNode) & Record<string, unknown>
|
||||
|
||||
type NestedClassValue = string | readonly string[] | null | undefined
|
||||
|
||||
export interface NestedMarkdownHtmlOptions {
|
||||
cacheKey?: string
|
||||
customHtmlTags?: readonly string[]
|
||||
allowHtml?: boolean
|
||||
customNodeTag?: string
|
||||
customNodeClass?: NestedClassValue | ((node: NestedRenderableNode) => NestedClassValue)
|
||||
}
|
||||
|
||||
export interface NestedMarkdownHtmlInput {
|
||||
node?: NestedRenderableNode | null
|
||||
nodes?: readonly NestedRenderableNode[] | null
|
||||
content?: string | null
|
||||
}
|
||||
|
||||
interface RenderContext {
|
||||
markdown: ReturnType<typeof getMarkdown>
|
||||
options: Required<Pick<NestedMarkdownHtmlOptions, 'allowHtml' | 'customNodeTag'>> & Pick<NestedMarkdownHtmlOptions, 'customNodeClass'>
|
||||
}
|
||||
|
||||
const DEFAULT_CACHE_KEY = 'markstream-vue2-nested-html'
|
||||
const DEFAULT_CUSTOM_NODE_TAG = 'div'
|
||||
const markdownCache = new Map<string, ReturnType<typeof getMarkdown>>()
|
||||
const KNOWN_NODE_TYPES = new Set([
|
||||
'admonition',
|
||||
'blockquote',
|
||||
'checkbox',
|
||||
'checkbox_input',
|
||||
'code_block',
|
||||
'definition_item',
|
||||
'definition_list',
|
||||
'emoji',
|
||||
'emphasis',
|
||||
'footnote',
|
||||
'footnote_reference',
|
||||
'hardbreak',
|
||||
'heading',
|
||||
'highlight',
|
||||
'html_block',
|
||||
'html_inline',
|
||||
'image',
|
||||
'inline_code',
|
||||
'insert',
|
||||
'link',
|
||||
'list',
|
||||
'list_item',
|
||||
'math_block',
|
||||
'math_inline',
|
||||
'paragraph',
|
||||
'reference',
|
||||
'strikethrough',
|
||||
'strong',
|
||||
'subscript',
|
||||
'superscript',
|
||||
'table',
|
||||
'table_cell',
|
||||
'table_row',
|
||||
'text',
|
||||
'thematic_break',
|
||||
])
|
||||
|
||||
export function renderNestedMarkdownToHtml(
|
||||
input: NestedMarkdownHtmlInput,
|
||||
options: NestedMarkdownHtmlOptions = {},
|
||||
): string {
|
||||
const ctx = createRenderContext(options)
|
||||
return renderNestedInputToHtml(input, ctx)
|
||||
}
|
||||
|
||||
export function renderMarkdownNodesToHtml(
|
||||
nodes: readonly NestedRenderableNode[] | null | undefined,
|
||||
options: NestedMarkdownHtmlOptions = {},
|
||||
): string {
|
||||
const ctx = createRenderContext(options)
|
||||
return renderNodesToHtml(nodes, ctx)
|
||||
}
|
||||
|
||||
export function renderMarkdownNodeToHtml(
|
||||
node: NestedRenderableNode | null | undefined,
|
||||
options: NestedMarkdownHtmlOptions = {},
|
||||
): string {
|
||||
const ctx = createRenderContext(options)
|
||||
return renderNodeToHtml(node, ctx)
|
||||
}
|
||||
|
||||
function createRenderContext(options: NestedMarkdownHtmlOptions): RenderContext {
|
||||
const normalizedTags = normalizeCustomHtmlTags(options.customHtmlTags)
|
||||
const cacheKey = `${options.cacheKey || DEFAULT_CACHE_KEY}::${normalizedTags.join(',')}`
|
||||
let markdown = markdownCache.get(cacheKey)
|
||||
|
||||
if (!markdown) {
|
||||
markdown = getMarkdown(cacheKey, {
|
||||
customHtmlTags: normalizedTags,
|
||||
})
|
||||
markdownCache.set(cacheKey, markdown)
|
||||
}
|
||||
|
||||
return {
|
||||
markdown,
|
||||
options: {
|
||||
allowHtml: options.allowHtml !== false,
|
||||
customNodeTag: normalizeCustomHtmlTagName(options.customNodeTag) || DEFAULT_CUSTOM_NODE_TAG,
|
||||
customNodeClass: options.customNodeClass,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function renderNestedInputToHtml(input: NestedMarkdownHtmlInput, ctx: RenderContext): string {
|
||||
if (Array.isArray(input.nodes) && input.nodes.length > 0)
|
||||
return renderNodesToHtml(input.nodes, ctx)
|
||||
|
||||
const node = input.node
|
||||
if (node) {
|
||||
const children = getNodeList(node.children)
|
||||
if (children.length > 0)
|
||||
return renderNodesToHtml(children, ctx)
|
||||
|
||||
const content = getString(node.content)
|
||||
if (content)
|
||||
return renderMarkdownFragment(content, ctx)
|
||||
|
||||
if (node.raw != null)
|
||||
return escapeHtml(getString(node.raw))
|
||||
}
|
||||
|
||||
return input.content ? renderMarkdownFragment(getString(input.content), ctx) : ''
|
||||
}
|
||||
|
||||
function renderNodesToHtml(nodes: readonly NestedRenderableNode[] | null | undefined, ctx: RenderContext): string {
|
||||
return (Array.isArray(nodes) ? nodes : []).map(node => renderNodeToHtml(node, ctx)).join('')
|
||||
}
|
||||
|
||||
function renderNodeToHtml(node: NestedRenderableNode | null | undefined, ctx: RenderContext): string {
|
||||
if (!node || typeof node !== 'object')
|
||||
return ''
|
||||
|
||||
switch (node.type) {
|
||||
case 'text':
|
||||
return escapeHtml(getString(node.content))
|
||||
case 'paragraph':
|
||||
return `<p>${renderNodesToHtml(getNodeList(node.children), ctx)}</p>`
|
||||
case 'strong':
|
||||
return `<strong>${renderNodesToHtml(getNodeList(node.children), ctx)}</strong>`
|
||||
case 'emphasis':
|
||||
return `<em>${renderNodesToHtml(getNodeList(node.children), ctx)}</em>`
|
||||
case 'strikethrough':
|
||||
return `<del>${renderNodesToHtml(getNodeList(node.children), ctx)}</del>`
|
||||
case 'highlight':
|
||||
return `<mark>${renderNodesToHtml(getNodeList(node.children), ctx)}</mark>`
|
||||
case 'insert':
|
||||
return `<ins>${renderNodesToHtml(getNodeList(node.children), ctx)}</ins>`
|
||||
case 'subscript':
|
||||
return `<sub>${renderNodesToHtml(getNodeList(node.children), ctx)}</sub>`
|
||||
case 'superscript':
|
||||
return `<sup>${renderNodesToHtml(getNodeList(node.children), ctx)}</sup>`
|
||||
case 'inline_code':
|
||||
return `<code>${escapeHtml(getString(node.code))}</code>`
|
||||
case 'hardbreak':
|
||||
return '<br>'
|
||||
case 'link':
|
||||
return renderLinkNode(node, ctx)
|
||||
case 'image':
|
||||
return renderImageNode(node)
|
||||
case 'list':
|
||||
return renderListNode(node, ctx)
|
||||
case 'list_item':
|
||||
return `<li>${renderNodesToHtml(getNodeList(node.children), ctx)}</li>`
|
||||
case 'blockquote':
|
||||
return `<blockquote>${renderNodesToHtml(getNodeList(node.children), ctx)}</blockquote>`
|
||||
case 'heading':
|
||||
return renderHeadingNode(node, ctx)
|
||||
case 'code_block':
|
||||
return renderCodeBlockNode(node)
|
||||
case 'thematic_break':
|
||||
return '<hr>'
|
||||
case 'table':
|
||||
return renderTableNode(node, ctx)
|
||||
case 'table_row':
|
||||
return renderTableRowNode(node, ctx)
|
||||
case 'table_cell':
|
||||
return renderTableCellNode(node, ctx)
|
||||
case 'definition_list':
|
||||
return renderDefinitionListNode(node, ctx)
|
||||
case 'definition_item':
|
||||
return renderDefinitionItemNode(node, ctx)
|
||||
case 'footnote':
|
||||
return renderFootnoteNode(node, ctx)
|
||||
case 'footnote_reference':
|
||||
return renderFootnoteReferenceNode(node)
|
||||
case 'admonition':
|
||||
return renderAdmonitionNode(node, ctx)
|
||||
case 'checkbox':
|
||||
case 'checkbox_input':
|
||||
return `<input type="checkbox" disabled${node.checked ? ' checked' : ''}>`
|
||||
case 'emoji':
|
||||
return escapeHtml(getString(node.raw || node.markup || node.content || node.name))
|
||||
case 'math_inline':
|
||||
return `<span class="markstream-nested-math" data-display="inline">${escapeHtml(getString(node.content || node.markup || node.raw))}</span>`
|
||||
case 'math_block':
|
||||
return `<pre class="markstream-nested-math-block"><code>${escapeHtml(getString(node.content || node.markup || node.raw))}</code></pre>`
|
||||
case 'reference':
|
||||
return `<sup class="markstream-nested-reference">[${escapeHtml(getString(node.id))}]</sup>`
|
||||
case 'html_inline':
|
||||
case 'html_block':
|
||||
return renderHtmlNode(node, ctx)
|
||||
default:
|
||||
return renderCustomOrFallbackNode(node, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
function renderLinkNode(node: NestedRenderableNode, ctx: RenderContext): string {
|
||||
const href = getString(node.href)
|
||||
const title = getString(node.title)
|
||||
const content = getNodeList(node.children).length > 0
|
||||
? renderNodesToHtml(getNodeList(node.children), ctx)
|
||||
: escapeHtml(getString(node.text || href))
|
||||
const titleAttr = title ? ` title="${escapeAttr(title)}"` : ''
|
||||
const hrefAttr = href ? ` href="${escapeAttr(href)}"` : ''
|
||||
return `<a${hrefAttr}${titleAttr} target="_blank" rel="noreferrer noopener">${content}</a>`
|
||||
}
|
||||
|
||||
function renderImageNode(node: NestedRenderableNode): string {
|
||||
const src = getString(node.src)
|
||||
const alt = getString(node.alt)
|
||||
const title = getString(node.title)
|
||||
const titleAttr = title ? ` title="${escapeAttr(title)}"` : ''
|
||||
return `<img src="${escapeAttr(src)}" alt="${escapeAttr(alt)}"${titleAttr}>`
|
||||
}
|
||||
|
||||
function renderListNode(node: NestedRenderableNode, ctx: RenderContext): string {
|
||||
const tag = node.ordered ? 'ol' : 'ul'
|
||||
const start = node.ordered && Number.isFinite(node.start) ? ` start="${Number(node.start)}"` : ''
|
||||
return `<${tag}${start}>${renderNodesToHtml(getNodeList(node.items), ctx)}</${tag}>`
|
||||
}
|
||||
|
||||
function renderHeadingNode(node: NestedRenderableNode, ctx: RenderContext): string {
|
||||
const level = clampHeadingLevel(node.level)
|
||||
return `<h${level}>${renderNodesToHtml(getNodeList(node.children), ctx)}</h${level}>`
|
||||
}
|
||||
|
||||
function renderCodeBlockNode(node: NestedRenderableNode): string {
|
||||
const language = sanitizeClassToken(getString(node.language))
|
||||
const languageClass = language ? ` class="language-${language}"` : ''
|
||||
return `<pre><code${languageClass}>${escapeHtml(getString(node.code))}</code></pre>`
|
||||
}
|
||||
|
||||
function renderTableNode(node: NestedRenderableNode, ctx: RenderContext): string {
|
||||
const header = node.header ? renderTableRowNode(node.header as NestedRenderableNode, ctx, true) : ''
|
||||
const rows = renderNodesToHtml(getNodeList(node.rows), ctx)
|
||||
const thead = header ? `<thead>${header}</thead>` : ''
|
||||
const tbody = rows ? `<tbody>${rows}</tbody>` : ''
|
||||
return `<table>${thead}${tbody}</table>`
|
||||
}
|
||||
|
||||
function renderTableRowNode(node: NestedRenderableNode, ctx: RenderContext, forceHeader = false): string {
|
||||
const cells = getNodeList(node.cells).map(cell => renderTableCellNode(cell, ctx, forceHeader)).join('')
|
||||
return `<tr>${cells}</tr>`
|
||||
}
|
||||
|
||||
function renderTableCellNode(node: NestedRenderableNode, ctx: RenderContext, forceHeader = false): string {
|
||||
const tag = forceHeader || !!node.header ? 'th' : 'td'
|
||||
const align = getString(node.align)
|
||||
const alignAttr = align ? ` style="text-align:${escapeAttr(align)}"` : ''
|
||||
return `<${tag}${alignAttr}>${renderNodesToHtml(getNodeList(node.children), ctx)}</${tag}>`
|
||||
}
|
||||
|
||||
function renderDefinitionListNode(node: NestedRenderableNode, ctx: RenderContext): string {
|
||||
return `<dl>${getNodeList(node.items).map(item => renderDefinitionItemNode(item, ctx)).join('')}</dl>`
|
||||
}
|
||||
|
||||
function renderDefinitionItemNode(node: NestedRenderableNode, ctx: RenderContext): string {
|
||||
const term = renderNodesToHtml(getNodeList(node.term), ctx)
|
||||
const definition = renderNodesToHtml(getNodeList(node.definition), ctx)
|
||||
return `<dt>${term}</dt><dd>${definition}</dd>`
|
||||
}
|
||||
|
||||
function renderFootnoteNode(node: NestedRenderableNode, ctx: RenderContext): string {
|
||||
const id = escapeAttr(getString(node.id))
|
||||
return `<div id="fnref--${id}" class="markstream-nested-footnote">${renderNodesToHtml(getNodeList(node.children), ctx)}</div>`
|
||||
}
|
||||
|
||||
function renderFootnoteReferenceNode(node: NestedRenderableNode): string {
|
||||
const id = escapeHtml(getString(node.id))
|
||||
const href = escapeAttr(getString(node.id))
|
||||
return `<sup class="markstream-nested-footnote-ref"><a href="#fnref--${href}">[${id}]</a></sup>`
|
||||
}
|
||||
|
||||
function renderAdmonitionNode(node: NestedRenderableNode, ctx: RenderContext): string {
|
||||
const kind = sanitizeClassToken(getString(node.kind || 'note')) || 'note'
|
||||
const title = getString(node.title) || capitalize(kind)
|
||||
return `<div class="markstream-nested-admonition markstream-nested-admonition--${kind}"><div class="markstream-nested-admonition__title">${escapeHtml(title)}</div><div class="markstream-nested-admonition__content">${renderNodesToHtml(getNodeList(node.children), ctx)}</div></div>`
|
||||
}
|
||||
|
||||
function renderHtmlNode(node: NestedRenderableNode, ctx: RenderContext): string {
|
||||
const content = getString(node.content)
|
||||
const rawContent = content || getString(node.raw)
|
||||
const tag = getString(node.tag).trim().toLowerCase()
|
||||
const children = getNodeList(node.children)
|
||||
if (!ctx.options.allowHtml)
|
||||
return escapeHtml(rawContent)
|
||||
if (node.loading && !node.autoClosed)
|
||||
return escapeHtml(rawContent)
|
||||
if (tag && children.length > 0 && !NON_STRUCTURING_HTML_TAGS.has(tag)) {
|
||||
const attrs = serializeAttrs(node.attrs as CustomComponentAttrs | undefined)
|
||||
return `<${tag}${attrs}>${renderNodesToHtml(children, ctx)}</${tag}>`
|
||||
}
|
||||
return rawContent
|
||||
}
|
||||
|
||||
function renderCustomOrFallbackNode(node: NestedRenderableNode, ctx: RenderContext): string {
|
||||
const tagName = getString(node.tag || node.type)
|
||||
if (!tagName || KNOWN_NODE_TYPES.has(tagName))
|
||||
return escapeHtml(getString(node.content || node.raw))
|
||||
|
||||
const classes = [
|
||||
'markstream-nested-custom',
|
||||
`markstream-nested-custom--${sanitizeClassToken(tagName) || 'node'}`,
|
||||
resolveCustomNodeClass(node, ctx.options.customNodeClass),
|
||||
].filter(Boolean).join(' ')
|
||||
const attrs = serializeAttrs(node.attrs as CustomComponentAttrs | undefined, classes)
|
||||
const body = resolveCustomNodeBody(node, ctx)
|
||||
const wrapperTag = ctx.options.customNodeTag
|
||||
return `<${wrapperTag}${attrs} data-markstream-custom-tag="${escapeAttr(tagName)}">${body}</${wrapperTag}>`
|
||||
}
|
||||
|
||||
function resolveCustomNodeBody(node: NestedRenderableNode, ctx: RenderContext): string {
|
||||
const children = getNodeList(node.children)
|
||||
if (children.length > 0)
|
||||
return renderNodesToHtml(children, ctx)
|
||||
|
||||
const content = getString(node.content)
|
||||
if (content)
|
||||
return renderMarkdownFragment(content, ctx)
|
||||
|
||||
return escapeHtml(getString(node.raw))
|
||||
}
|
||||
|
||||
function renderMarkdownFragment(content: string, ctx: RenderContext): string {
|
||||
return content ? ctx.markdown.render(content) : ''
|
||||
}
|
||||
|
||||
function resolveCustomNodeClass(
|
||||
node: NestedRenderableNode,
|
||||
customNodeClass?: NestedMarkdownHtmlOptions['customNodeClass'],
|
||||
): string {
|
||||
if (!customNodeClass)
|
||||
return ''
|
||||
const resolved = typeof customNodeClass === 'function'
|
||||
? customNodeClass(node)
|
||||
: customNodeClass
|
||||
return serializeClassValue(resolved)
|
||||
}
|
||||
|
||||
function serializeClassValue(value: NestedClassValue): string {
|
||||
if (Array.isArray(value))
|
||||
return value.map(item => serializeClassValue(item)).filter(Boolean).join(' ')
|
||||
return typeof value === 'string'
|
||||
? value.trim()
|
||||
: ''
|
||||
}
|
||||
|
||||
function serializeAttrs(attrs?: CustomComponentAttrs | null, extraClass = ''): string {
|
||||
const pairs = normalizeAttrs(attrs)
|
||||
const rendered: string[] = []
|
||||
const mergedClasses = [extraClass]
|
||||
|
||||
for (const [name, value] of pairs) {
|
||||
const safeName = String(name).trim()
|
||||
const lowerName = safeName.toLowerCase()
|
||||
if (!safeName || !isSafeAttrName(safeName))
|
||||
continue
|
||||
if (DANGEROUS_HTML_ATTRS.has(lowerName))
|
||||
continue
|
||||
if (value !== true && URL_HTML_ATTRS.has(lowerName) && value && isUnsafeHtmlUrl(String(value)))
|
||||
continue
|
||||
if (lowerName === 'class') {
|
||||
mergedClasses.push(String(value))
|
||||
continue
|
||||
}
|
||||
if (value === true)
|
||||
rendered.push(` ${safeName}`)
|
||||
else
|
||||
rendered.push(` ${safeName}="${escapeAttr(String(value))}"`)
|
||||
}
|
||||
|
||||
const className = mergedClasses.map(value => value.trim()).filter(Boolean).join(' ')
|
||||
if (className)
|
||||
rendered.unshift(` class="${escapeAttr(className)}"`)
|
||||
return rendered.join('')
|
||||
}
|
||||
|
||||
function normalizeAttrs(attrs?: CustomComponentAttrs | null): Array<[string, string | boolean]> {
|
||||
if (!attrs)
|
||||
return []
|
||||
|
||||
if (Array.isArray(attrs)) {
|
||||
if (attrs.length === 0)
|
||||
return []
|
||||
const first = attrs[0]
|
||||
if (Array.isArray(first))
|
||||
return (attrs as [string, string][]).map(([name, value]) => [String(name), value])
|
||||
return (attrs as Array<{ name: string, value: string | boolean }>).map(item => [String(item.name), item.value])
|
||||
}
|
||||
|
||||
return Object.entries(attrs).map(([name, value]) => [name, value])
|
||||
}
|
||||
|
||||
function getNodeList(value: unknown): NestedRenderableNode[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is NestedRenderableNode => !!item && typeof item === 'object')
|
||||
: []
|
||||
}
|
||||
|
||||
function getString(value: unknown): string {
|
||||
return typeof value === 'string'
|
||||
? value
|
||||
: value == null
|
||||
? ''
|
||||
: String(value)
|
||||
}
|
||||
|
||||
function isSafeAttrName(value: string): boolean {
|
||||
return /^[^\s"'<>`=]+$/.test(value) && !/^on/i.test(value)
|
||||
}
|
||||
|
||||
function sanitizeClassToken(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
function clampHeadingLevel(value: unknown): number {
|
||||
const level = Math.trunc(Number(value) || 1)
|
||||
return Math.min(6, Math.max(1, level))
|
||||
}
|
||||
|
||||
function escapeHtml(value: unknown): string {
|
||||
return getString(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function escapeAttr(value: unknown): string {
|
||||
return escapeHtml(value).replace(/`/g, '`')
|
||||
}
|
||||
|
||||
function capitalize(value: string): string {
|
||||
return value ? `${value[0].toUpperCase()}${value.slice(1)}` : ''
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { BaseNode, MarkdownIt, ParseOptions } from 'stream-markdown-parser'
|
||||
import { getMarkdown, mergeCustomHtmlTags, parseMarkdownToStructure } from 'stream-markdown-parser'
|
||||
|
||||
export interface NestedMarkdownNodesInput {
|
||||
node?: (BaseNode & Record<string, any>) | null
|
||||
nodes?: readonly BaseNode[] | null
|
||||
content?: string | null
|
||||
}
|
||||
|
||||
export interface NestedMarkdownNodesOptions {
|
||||
cacheKey?: string
|
||||
final?: boolean
|
||||
parseOptions?: ParseOptions
|
||||
customHtmlTags?: readonly string[]
|
||||
customMarkdownIt?: (markdown: MarkdownIt) => MarkdownIt
|
||||
}
|
||||
|
||||
const DEFAULT_CACHE_KEY = 'markstream-vue2-nested-nodes'
|
||||
const markdownCache = new Map<string, MarkdownIt>()
|
||||
|
||||
export function parseNestedMarkdownToNodes(
|
||||
input: NestedMarkdownNodesInput,
|
||||
options: NestedMarkdownNodesOptions = {},
|
||||
): BaseNode[] {
|
||||
if (Array.isArray(input.nodes))
|
||||
return input.nodes.slice()
|
||||
|
||||
const nestedNode = input.node
|
||||
if (nestedNode) {
|
||||
const children = getNodeList(nestedNode.children)
|
||||
if (children.length > 0)
|
||||
return children.slice()
|
||||
}
|
||||
|
||||
const content = resolveContent(input)
|
||||
if (!content)
|
||||
return []
|
||||
|
||||
const parseOptions = resolveParseOptions(input, options)
|
||||
const markdown = resolveMarkdownInstance(options)
|
||||
return parseMarkdownToStructure(content, markdown, parseOptions)
|
||||
}
|
||||
|
||||
function resolveContent(input: NestedMarkdownNodesInput) {
|
||||
if (typeof input.content === 'string')
|
||||
return input.content
|
||||
if (typeof input.node?.content === 'string')
|
||||
return input.node.content
|
||||
return ''
|
||||
}
|
||||
|
||||
function resolveParseOptions(
|
||||
input: NestedMarkdownNodesInput,
|
||||
options: NestedMarkdownNodesOptions,
|
||||
): ParseOptions | undefined {
|
||||
const base = options.parseOptions ?? {}
|
||||
const resolvedFinal = options.final ?? resolveFinalFromNode(input.node)
|
||||
const customHtmlTags = mergeCustomHtmlTags(options.customHtmlTags, (base as any).customHtmlTags)
|
||||
|
||||
if (resolvedFinal == null && customHtmlTags.length === 0)
|
||||
return base
|
||||
|
||||
return {
|
||||
...(base as any),
|
||||
...(resolvedFinal == null ? {} : { final: resolvedFinal }),
|
||||
...(customHtmlTags.length === 0 ? {} : { customHtmlTags }),
|
||||
} as ParseOptions
|
||||
}
|
||||
|
||||
function resolveFinalFromNode(node?: (BaseNode & Record<string, any>) | null) {
|
||||
if (!node || typeof node !== 'object')
|
||||
return undefined
|
||||
if (typeof node.loading === 'boolean')
|
||||
return !node.loading
|
||||
return undefined
|
||||
}
|
||||
|
||||
function resolveMarkdownInstance(options: NestedMarkdownNodesOptions) {
|
||||
const normalizedTags = mergeCustomHtmlTags(options.customHtmlTags, (options.parseOptions as any)?.customHtmlTags)
|
||||
const cacheKey = `${options.cacheKey || DEFAULT_CACHE_KEY}::${normalizedTags.join(',')}`
|
||||
let markdown = markdownCache.get(cacheKey)
|
||||
|
||||
if (!markdown) {
|
||||
markdown = getMarkdown(cacheKey, {
|
||||
customHtmlTags: normalizedTags,
|
||||
})
|
||||
markdownCache.set(cacheKey, markdown)
|
||||
}
|
||||
|
||||
return options.customMarkdownIt
|
||||
? options.customMarkdownIt(markdown)
|
||||
: markdown
|
||||
}
|
||||
|
||||
function getNodeList(value: unknown) {
|
||||
return Array.isArray(value) ? value as BaseNode[] : []
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { CustomComponents } from '../types'
|
||||
import { shallowRef } from 'vue-demi'
|
||||
|
||||
// Store mappings per scope id. A special key is kept for the legacy/global mapping.
|
||||
const GLOBAL_KEY = '__global__'
|
||||
interface CustomComponentsStore {
|
||||
scopedCustomComponents: Record<string, Partial<CustomComponents>>
|
||||
revision: ReturnType<typeof shallowRef<any>>
|
||||
}
|
||||
|
||||
const STORE_KEY = '__MARKSTREAM_VUE2_CUSTOM_COMPONENTS_STORE__'
|
||||
const store: CustomComponentsStore = (() => {
|
||||
const g = globalThis as any
|
||||
if (g[STORE_KEY])
|
||||
return g[STORE_KEY] as CustomComponentsStore
|
||||
const next: CustomComponentsStore = {
|
||||
scopedCustomComponents: {},
|
||||
revision: shallowRef(0),
|
||||
}
|
||||
g[STORE_KEY] = next
|
||||
return next
|
||||
})()
|
||||
|
||||
// Reactive revision counter so renderers can re-parse when mappings change.
|
||||
export const customComponentsRevision = store.revision
|
||||
|
||||
// Overloads for nicer TypeScript API
|
||||
export function setCustomComponents(id: string, mapping: Partial<CustomComponents>): void
|
||||
export function setCustomComponents(mapping: Partial<CustomComponents>): void
|
||||
export function setCustomComponents(
|
||||
customIdOrMapping: string | Partial<CustomComponents>,
|
||||
maybeMapping?: Partial<CustomComponents>,
|
||||
): void {
|
||||
if (typeof customIdOrMapping === 'string') {
|
||||
// scoped API: setCustomComponents('my-id', { ... })
|
||||
store.scopedCustomComponents[customIdOrMapping] = maybeMapping || {}
|
||||
}
|
||||
else {
|
||||
// legacy/global API: setCustomComponents({ ... })
|
||||
store.scopedCustomComponents[GLOBAL_KEY] = customIdOrMapping || {}
|
||||
}
|
||||
customComponentsRevision.value++
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve custom components for a given scope id.
|
||||
* If no id is provided, returns the legacy/global mapping (if any).
|
||||
*/
|
||||
export function getCustomNodeComponents(customId?: string) {
|
||||
const globalMapping = store.scopedCustomComponents[GLOBAL_KEY] || {}
|
||||
if (!customId)
|
||||
return globalMapping
|
||||
|
||||
const scopedMapping = store.scopedCustomComponents[customId] || {}
|
||||
if (!globalMapping || Object.keys(globalMapping).length === 0)
|
||||
return scopedMapping
|
||||
if (!scopedMapping || Object.keys(scopedMapping).length === 0)
|
||||
return globalMapping
|
||||
return {
|
||||
...globalMapping,
|
||||
...scopedMapping,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a scoped custom components mapping.
|
||||
* Use this to clean up mappings for dynamic or temporary renderers.
|
||||
*/
|
||||
export function removeCustomComponents(id: string) {
|
||||
if (id === GLOBAL_KEY) {
|
||||
// Don't allow deleting the internal global key via this function.
|
||||
// Use clearGlobalCustomComponents() for explicit global clearing.
|
||||
throw new Error('removeCustomComponents: use clearGlobalCustomComponents() to clear the global mapping')
|
||||
}
|
||||
delete store.scopedCustomComponents[id]
|
||||
customComponentsRevision.value++
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the legacy/global custom components mapping.
|
||||
* Use this when you want to remove the single-argument mapping set by
|
||||
* `setCustomComponents(mapping)`.
|
||||
*/
|
||||
export function clearGlobalCustomComponents() {
|
||||
delete store.scopedCustomComponents[GLOBAL_KEY]
|
||||
customComponentsRevision.value++
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
export function normalizeKaTeXRenderInput(content: string) {
|
||||
if (!content)
|
||||
return ''
|
||||
|
||||
return content
|
||||
.replace(/·/g, '⋅')
|
||||
.replace(/℃/g, '°C')
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Performance monitoring utility for KaTeX rendering
|
||||
* Helps determine if Worker is beneficial in production
|
||||
*/
|
||||
|
||||
interface RenderMetrics {
|
||||
type: 'worker' | 'direct' | 'cache-hit'
|
||||
duration: number
|
||||
formulaLength: number
|
||||
timestamp: number
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
class PerformanceMonitor {
|
||||
private metrics: RenderMetrics[] = []
|
||||
private enabled = false
|
||||
private maxMetrics = 1000 // Prevent memory leak
|
||||
|
||||
enable() {
|
||||
this.enabled = true
|
||||
}
|
||||
|
||||
disable() {
|
||||
this.enabled = false
|
||||
}
|
||||
|
||||
recordRender(metrics: RenderMetrics) {
|
||||
if (!this.enabled)
|
||||
return
|
||||
|
||||
this.metrics.push(metrics)
|
||||
|
||||
// Keep only recent metrics
|
||||
if (this.metrics.length > this.maxMetrics) {
|
||||
this.metrics.shift()
|
||||
}
|
||||
}
|
||||
|
||||
getStats() {
|
||||
if (this.metrics.length === 0) {
|
||||
return {
|
||||
totalRenders: 0,
|
||||
cacheHitRate: 0,
|
||||
averageWorkerTime: 0,
|
||||
averageDirectTime: 0,
|
||||
averageCacheHitTime: 0,
|
||||
workerSavings: 0,
|
||||
recommendation: 'Insufficient data',
|
||||
}
|
||||
}
|
||||
|
||||
const workerMetrics = this.metrics.filter(m => m.type === 'worker' && m.success)
|
||||
const directMetrics = this.metrics.filter(m => m.type === 'direct' && m.success)
|
||||
const cacheHitMetrics = this.metrics.filter(m => m.type === 'cache-hit')
|
||||
|
||||
const totalRenders = this.metrics.length
|
||||
const cacheHits = cacheHitMetrics.length
|
||||
const cacheHitRate = (cacheHits / totalRenders) * 100
|
||||
|
||||
const avgWorkerTime = workerMetrics.length > 0
|
||||
? workerMetrics.reduce((sum, m) => sum + m.duration, 0) / workerMetrics.length
|
||||
: 0
|
||||
|
||||
const avgDirectTime = directMetrics.length > 0
|
||||
? directMetrics.reduce((sum, m) => sum + m.duration, 0) / directMetrics.length
|
||||
: 0
|
||||
|
||||
const avgCacheHitTime = cacheHitMetrics.length > 0
|
||||
? cacheHitMetrics.reduce((sum, m) => sum + m.duration, 0) / cacheHitMetrics.length
|
||||
: 0
|
||||
|
||||
// Calculate time saved by using Worker + Cache vs always using direct
|
||||
const estimatedDirectTime = totalRenders * avgDirectTime
|
||||
const actualTime = workerMetrics.reduce((sum, m) => sum + m.duration, 0)
|
||||
+ directMetrics.reduce((sum, m) => sum + m.duration, 0)
|
||||
+ cacheHitMetrics.reduce((sum, m) => sum + m.duration, 0)
|
||||
|
||||
const workerSavings = estimatedDirectTime - actualTime
|
||||
|
||||
// Recommendation logic
|
||||
let recommendation = ''
|
||||
if (cacheHitRate > 70 && avgWorkerTime < avgDirectTime * 2) {
|
||||
recommendation = '✅ Worker + Cache is highly beneficial'
|
||||
}
|
||||
else if (cacheHitRate > 50) {
|
||||
recommendation = '✅ Worker + Cache is beneficial'
|
||||
}
|
||||
else if (avgWorkerTime > avgDirectTime * 3) {
|
||||
recommendation = '⚠️ Worker overhead too high, consider direct rendering'
|
||||
}
|
||||
else if (avgDirectTime < 5) {
|
||||
recommendation = '⚠️ Formulas too simple, Worker overhead may not be worth it'
|
||||
}
|
||||
else {
|
||||
recommendation = '✅ Worker prevents main thread blocking'
|
||||
}
|
||||
|
||||
return {
|
||||
totalRenders,
|
||||
cacheHits,
|
||||
cacheHitRate: cacheHitRate.toFixed(1),
|
||||
workerCalls: workerMetrics.length,
|
||||
directCalls: directMetrics.length,
|
||||
averageWorkerTime: avgWorkerTime.toFixed(2),
|
||||
averageDirectTime: avgDirectTime.toFixed(2),
|
||||
averageCacheHitTime: avgCacheHitTime.toFixed(3),
|
||||
workerSavings: workerSavings.toFixed(2),
|
||||
recommendation,
|
||||
}
|
||||
}
|
||||
|
||||
printReport() {
|
||||
const stats = this.getStats()
|
||||
|
||||
console.group('📊 KaTeX Rendering Performance Report')
|
||||
console.log(`Total renders: ${stats.totalRenders}`)
|
||||
console.log(`Cache hits: ${stats.cacheHits} (${stats.cacheHitRate}%)`)
|
||||
console.log(`Worker calls: ${stats.workerCalls}`)
|
||||
console.log(`Direct calls: ${stats.directCalls}`)
|
||||
console.log('')
|
||||
console.log('Timing:')
|
||||
console.log(` Worker average: ${stats.averageWorkerTime}ms`)
|
||||
console.log(` Direct average: ${stats.averageDirectTime}ms`)
|
||||
console.log(` Cache hit average: ${stats.averageCacheHitTime}ms`)
|
||||
console.log('')
|
||||
console.log(`Time saved: ${stats.workerSavings}ms`)
|
||||
console.log(`Recommendation: ${stats.recommendation}`)
|
||||
console.groupEnd()
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.metrics = []
|
||||
}
|
||||
|
||||
// Export metrics for analysis
|
||||
exportMetrics() {
|
||||
return {
|
||||
metrics: [...this.metrics],
|
||||
stats: this.getStats(),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const perfMonitor = new PerformanceMonitor()
|
||||
|
||||
// Convenience functions
|
||||
export function enablePerfMonitoring() {
|
||||
perfMonitor.enable()
|
||||
console.log('📊 KaTeX performance monitoring enabled')
|
||||
}
|
||||
|
||||
export function disablePerfMonitoring() {
|
||||
perfMonitor.disable()
|
||||
console.log('📊 KaTeX performance monitoring disabled')
|
||||
}
|
||||
|
||||
export function getPerfReport() {
|
||||
return perfMonitor.printReport()
|
||||
}
|
||||
|
||||
// Can be called from browser console: window.__katexPerfReport()
|
||||
if (typeof window !== 'undefined') {
|
||||
; (window as any).__katexPerfReport = getPerfReport
|
||||
; (window as any).__katexPerfMonitor = perfMonitor
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Safe requestAnimationFrame / cancel wrapper to avoid ReferenceError in SSR
|
||||
export function safeRaf(cb: FrameRequestCallback) {
|
||||
try {
|
||||
if (typeof globalThis !== 'undefined' && typeof (globalThis as any).requestAnimationFrame === 'function')
|
||||
return (globalThis as any).requestAnimationFrame(cb)
|
||||
}
|
||||
catch {}
|
||||
// Fallback to setTimeout when RAF isn't available (SSR or older envs)
|
||||
return (globalThis as any).setTimeout(cb as any, 0) as unknown as number
|
||||
}
|
||||
|
||||
export function safeCancelRaf(id: number | null) {
|
||||
try {
|
||||
if (id == null)
|
||||
return
|
||||
if (typeof globalThis !== 'undefined' && typeof (globalThis as any).cancelAnimationFrame === 'function') {
|
||||
(globalThis as any).cancelAnimationFrame(id)
|
||||
return
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
try {
|
||||
;(globalThis as any).clearTimeout(id)
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
export function getVue2MinorVersion(version: unknown) {
|
||||
const raw = typeof version === 'string' ? version : ''
|
||||
const [major, minor] = raw.split('.').map(Number)
|
||||
if (!Number.isFinite(major) || !Number.isFinite(minor) || major !== 2)
|
||||
return null
|
||||
return minor
|
||||
}
|
||||
|
||||
function hasProperty(input: unknown, key: PropertyKey) {
|
||||
if (input == null)
|
||||
return false
|
||||
const type = typeof input
|
||||
if (type !== 'object' && type !== 'function')
|
||||
return false
|
||||
return key in (input as object)
|
||||
}
|
||||
|
||||
export function isLegacyVue26Version(version: unknown) {
|
||||
const minor = getVue2MinorVersion(version)
|
||||
return minor != null && minor < 7
|
||||
}
|
||||
|
||||
export function resolveVueVersion(input: any) {
|
||||
const directBaseVersion = input?.$options?._base?.version
|
||||
if (typeof directBaseVersion === 'string' && directBaseVersion)
|
||||
return directBaseVersion
|
||||
|
||||
const rootBaseVersion = input?.$root?.$options?._base?.version
|
||||
if (typeof rootBaseVersion === 'string' && rootBaseVersion)
|
||||
return rootBaseVersion
|
||||
|
||||
if (hasProperty(input, '$')) {
|
||||
const appVersion = input.$?.appContext?.app?.version
|
||||
if (typeof appVersion === 'string' && appVersion)
|
||||
return appVersion
|
||||
}
|
||||
|
||||
if (hasProperty(input, 'constructor')) {
|
||||
const ctorVersion = input.constructor?.version
|
||||
if (typeof ctorVersion === 'string' && ctorVersion)
|
||||
return ctorVersion
|
||||
}
|
||||
|
||||
if (hasProperty(input, 'version')) {
|
||||
const version = input.version
|
||||
if (typeof version === 'string' && version)
|
||||
return version
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function isLegacyVue26Vm(input: any) {
|
||||
return isLegacyVue26Version(resolveVueVersion(input))
|
||||
}
|
||||
|
||||
type VueListener = (...args: unknown[]) => unknown
|
||||
type VueListenerValue = VueListener | VueListener[]
|
||||
|
||||
function normalizeListenerEvent(key: string) {
|
||||
return key
|
||||
.slice(2)
|
||||
.replace(/^[A-Z]/, char => char.toLowerCase())
|
||||
.replace(/([A-Z])/g, '-$1')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export function resolveVueListeners(input: any) {
|
||||
if (hasProperty(input, '$listeners')) {
|
||||
const listeners = input.$listeners
|
||||
if (listeners && typeof listeners === 'object')
|
||||
return listeners as Record<string, VueListenerValue>
|
||||
}
|
||||
|
||||
if (!hasProperty(input, '$attrs'))
|
||||
return {}
|
||||
|
||||
const attrs = input.$attrs
|
||||
if (!attrs || typeof attrs !== 'object')
|
||||
return {}
|
||||
|
||||
const listeners: Record<string, VueListenerValue> = {}
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (!/^on[A-Z]/.test(key))
|
||||
continue
|
||||
if (typeof value === 'function')
|
||||
listeners[normalizeListenerEvent(key)] = value as VueListener
|
||||
else if (Array.isArray(value) && value.every(item => typeof item === 'function'))
|
||||
listeners[normalizeListenerEvent(key)] = value as VueListener[]
|
||||
}
|
||||
return listeners
|
||||
}
|
||||
Reference in New Issue
Block a user