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,474 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import type { HtmlToken } from '../htmlRenderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import {
|
||||
buildVNodeTree,
|
||||
convertAttrsToProps,
|
||||
convertPropValue,
|
||||
hasCustomComponents,
|
||||
|
||||
isCustomComponent,
|
||||
parseHtmlToVNodes,
|
||||
sanitizeAttrs,
|
||||
tokenizeHtml,
|
||||
} from '../htmlRenderer'
|
||||
|
||||
// Mock Vue components for testing
|
||||
const MockComponentA = defineComponent({
|
||||
name: 'MockComponentA',
|
||||
props: ['data-type', 'value'],
|
||||
setup(props) {
|
||||
return () => h('div', { class: 'mock-a' }, `Component A: ${props['data-type']}`)
|
||||
},
|
||||
})
|
||||
|
||||
const MockComponentB = defineComponent({
|
||||
name: 'MockComponentB',
|
||||
props: ['active'],
|
||||
setup(props) {
|
||||
return () => h('span', { class: 'mock-b' }, `Component B: ${props.active}`)
|
||||
},
|
||||
})
|
||||
|
||||
describe('htmlRenderer', () => {
|
||||
describe('isCustomComponent', () => {
|
||||
it('should return false for standard HTML tags', () => {
|
||||
const customComponents = {}
|
||||
expect(isCustomComponent('div', customComponents)).toBe(false)
|
||||
expect(isCustomComponent('span', customComponents)).toBe(false)
|
||||
expect(isCustomComponent('a', customComponents)).toBe(false)
|
||||
expect(isCustomComponent('img', customComponents)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true for registered custom components', () => {
|
||||
const customComponents = {
|
||||
mycomponent: MockComponentA,
|
||||
}
|
||||
expect(isCustomComponent('mycomponent', customComponents)).toBe(true)
|
||||
expect(isCustomComponent('MyComponent', customComponents)).toBe(true) // case-insensitive
|
||||
})
|
||||
|
||||
it('should return false for unregistered non-standard tags', () => {
|
||||
const customComponents = {}
|
||||
expect(isCustomComponent('custom-tag', customComponents)).toBe(false)
|
||||
expect(isCustomComponent('unknown', customComponents)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeAttrs', () => {
|
||||
it('should remove dangerous event handlers', () => {
|
||||
const attrs = {
|
||||
'class': 'test',
|
||||
'onclick': 'alert(1)',
|
||||
'data-value': '123',
|
||||
'onerror': 'malicious()',
|
||||
}
|
||||
const result = sanitizeAttrs(attrs)
|
||||
expect(result).toEqual({
|
||||
'class': 'test',
|
||||
'data-value': '123',
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep safe attributes', () => {
|
||||
const attrs = {
|
||||
'class': 'test-class',
|
||||
'id': 'test-id',
|
||||
'style': 'color: red;',
|
||||
'data-type': 'custom',
|
||||
'aria-label': 'Test',
|
||||
}
|
||||
const result = sanitizeAttrs(attrs)
|
||||
expect(result).toEqual(attrs)
|
||||
})
|
||||
|
||||
it('should handle empty attributes', () => {
|
||||
expect(sanitizeAttrs({})).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertPropValue', () => {
|
||||
it('should convert boolean attributes', () => {
|
||||
expect(convertPropValue('true', 'checked')).toBe(true)
|
||||
expect(convertPropValue('', 'checked')).toBe(true)
|
||||
expect(convertPropValue('checked', 'checked')).toBe(true)
|
||||
expect(convertPropValue('false', 'checked')).toBe(false)
|
||||
})
|
||||
|
||||
it('should convert numeric attributes', () => {
|
||||
expect(convertPropValue('123', 'value')).toBe(123)
|
||||
expect(convertPropValue('45.5', 'min')).toBe(45.5)
|
||||
expect(convertPropValue('', 'value')).toBe('')
|
||||
})
|
||||
|
||||
it('should keep string values as-is', () => {
|
||||
expect(convertPropValue('hello', 'class')).toBe('hello')
|
||||
expect(convertPropValue('test-value', 'data-type')).toBe('test-value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertAttrsToProps', () => {
|
||||
it('should convert multiple attributes', () => {
|
||||
const attrs = {
|
||||
checked: 'true',
|
||||
value: '42',
|
||||
class: 'test',
|
||||
disabled: '',
|
||||
}
|
||||
const result = convertAttrsToProps(attrs)
|
||||
expect(result).toEqual({
|
||||
checked: true,
|
||||
value: 42,
|
||||
class: 'test',
|
||||
disabled: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('tokenizeHtml', () => {
|
||||
it('should tokenize simple HTML', () => {
|
||||
const html = '<div>text</div>'
|
||||
const tokens = tokenizeHtml(html)
|
||||
expect(tokens).toEqual([
|
||||
{ type: 'tag_open', tagName: 'div', attrs: {} },
|
||||
{ type: 'text', content: 'text' },
|
||||
{ type: 'tag_close', tagName: 'div' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should tokenize nested HTML', () => {
|
||||
const html = '<div><span>nested</span></div>'
|
||||
const tokens = tokenizeHtml(html)
|
||||
expect(tokens).toHaveLength(5)
|
||||
expect(tokens[0].type).toBe('tag_open')
|
||||
expect(tokens[0].tagName).toBe('div')
|
||||
expect(tokens[1].type).toBe('tag_open')
|
||||
expect(tokens[1].tagName).toBe('span')
|
||||
})
|
||||
|
||||
it('should tokenize HTML with attributes', () => {
|
||||
const html = '<div class="test" id="myid">content</div>'
|
||||
const tokens = tokenizeHtml(html)
|
||||
expect(tokens[0].attrs).toEqual({
|
||||
class: 'test',
|
||||
id: 'myid',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle self-closing tags', () => {
|
||||
const html = '<img src="test.jpg" />'
|
||||
const tokens = tokenizeHtml(html)
|
||||
expect(tokens).toHaveLength(1)
|
||||
expect(tokens[0].type).toBe('self_closing')
|
||||
expect(tokens[0].tagName).toBe('img')
|
||||
})
|
||||
|
||||
it('should skip HTML comments', () => {
|
||||
const html = '<div><!-- comment -->text</div>'
|
||||
const tokens = tokenizeHtml(html)
|
||||
expect(tokens).toHaveLength(3) // tag_open, text, tag_close
|
||||
expect(tokens[1].content).toBe('text')
|
||||
})
|
||||
|
||||
it('should handle CDATA sections', () => {
|
||||
const html = '<div><![CDATA[test]]></div>'
|
||||
const tokens = tokenizeHtml(html)
|
||||
expect(tokens.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should ignore whitespace-only text nodes', () => {
|
||||
const html = '<div> </div>'
|
||||
const tokens = tokenizeHtml(html)
|
||||
expect(tokens.some(t => t.type === 'text')).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle unclosed tags gracefully', () => {
|
||||
const html = '<div>text'
|
||||
const tokens = tokenizeHtml(html)
|
||||
expect(tokens).toEqual([
|
||||
{ type: 'tag_open', tagName: 'div', attrs: {} },
|
||||
{ type: 'text', content: 'text' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildVNodeTree', () => {
|
||||
it('should build simple tree', () => {
|
||||
const tokens: HtmlToken[] = [
|
||||
{ type: 'tag_open', tagName: 'div', attrs: {} },
|
||||
{ type: 'text', content: 'hello' },
|
||||
{ type: 'tag_close', tagName: 'div' },
|
||||
]
|
||||
const nodes = buildVNodeTree(tokens, {})
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(nodes[0].type).toBe('div') // VNode type for 'div'
|
||||
})
|
||||
|
||||
it('should build nested tree with custom components', () => {
|
||||
const customComponents = {
|
||||
mycomp: MockComponentA,
|
||||
}
|
||||
const tokens: HtmlToken[] = [
|
||||
{ type: 'tag_open', tagName: 'div', attrs: {} },
|
||||
{ type: 'tag_open', tagName: 'mycomp', attrs: { 'data-type': 'test' } },
|
||||
{ type: 'text', content: 'content' },
|
||||
{ type: 'tag_close', tagName: 'mycomp' },
|
||||
{ type: 'tag_close', tagName: 'div' },
|
||||
]
|
||||
const nodes = buildVNodeTree(tokens, customComponents)
|
||||
expect(nodes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should handle mismatched tags gracefully', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const tokens: HtmlToken[] = [
|
||||
{ type: 'tag_open', tagName: 'div', attrs: {} },
|
||||
{ type: 'tag_open', tagName: 'span', attrs: {} },
|
||||
{ type: 'tag_close', tagName: 'div' },
|
||||
]
|
||||
const nodes = buildVNodeTree(tokens, {})
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should auto-close unclosed tags', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const tokens: HtmlToken[] = [
|
||||
{ type: 'tag_open', tagName: 'div', attrs: {} },
|
||||
{ type: 'text', content: 'text' },
|
||||
]
|
||||
const nodes = buildVNodeTree(tokens, {})
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseHtmlToVNodes', () => {
|
||||
it('should parse simple HTML', () => {
|
||||
const html = '<div>hello</div>'
|
||||
const nodes = parseHtmlToVNodes(html, {})
|
||||
expect(nodes).not.toBeNull()
|
||||
expect(nodes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should return null on parse error', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
// Force an error by passing invalid input
|
||||
const result = parseHtmlToVNodes('<<invalid>>', {})
|
||||
// Should either return null or handle gracefully
|
||||
expect(result !== undefined).toBe(true)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should handle empty content', () => {
|
||||
const nodes = parseHtmlToVNodes('', {})
|
||||
expect(nodes).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasCustomComponents', () => {
|
||||
it('should return true when custom components are present', () => {
|
||||
const html = '<mycomponent>content</mycomponent>'
|
||||
const customComponents = {
|
||||
mycomponent: MockComponentA,
|
||||
}
|
||||
expect(hasCustomComponents(html, customComponents)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when only standard HTML is present', () => {
|
||||
const html = '<div><span>text</span></div>'
|
||||
expect(hasCustomComponents(html, {})).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for empty content', () => {
|
||||
expect(hasCustomComponents('', {})).toBe(false)
|
||||
})
|
||||
|
||||
it('should detect custom components mixed with standard HTML', () => {
|
||||
const html = '<div><mycomp>test</mycomp></div>'
|
||||
const customComponents = {
|
||||
mycomp: MockComponentA,
|
||||
}
|
||||
expect(hasCustomComponents(html, customComponents)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration Tests', () => {
|
||||
it('should handle nested custom components', () => {
|
||||
const html = '<outer>text <inner>nested</inner> more</outer>'
|
||||
const customComponents = {
|
||||
outer: MockComponentA,
|
||||
inner: MockComponentB,
|
||||
}
|
||||
|
||||
const nodes = parseHtmlToVNodes(html, customComponents)
|
||||
expect(nodes).not.toBeNull()
|
||||
expect(nodes!.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should handle deeply nested structure', () => {
|
||||
const html = '<div><one><two><three>deep</three></two></one></div>'
|
||||
const nodes = parseHtmlToVNodes(html, {})
|
||||
expect(nodes).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should sanitize dangerous attributes in custom components', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const html = '<mycomp onclick="alert(1)" onerror="bad()">text</mycomp>'
|
||||
const customComponents = {
|
||||
mycomp: MockComponentA,
|
||||
}
|
||||
|
||||
const nodes = parseHtmlToVNodes(html, customComponents)
|
||||
expect(nodes).not.toBeNull()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should convert props correctly', () => {
|
||||
const html = '<mycomp checked="true" value="42">text</mycomp>'
|
||||
const customComponents = {
|
||||
mycomp: MockComponentA,
|
||||
}
|
||||
|
||||
const nodes = parseHtmlToVNodes(html, customComponents)
|
||||
expect(nodes).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should handle the test scenario from playground', () => {
|
||||
const html = '<diyelement data-type="outer">外层组件 <diyelement data-type="inner">内层组件</diyelement> 继续外层</diyelement>'
|
||||
const customComponents = {
|
||||
diyelement: MockComponentA,
|
||||
}
|
||||
|
||||
const nodes = parseHtmlToVNodes(html, customComponents)
|
||||
expect(nodes).not.toBeNull()
|
||||
expect(nodes!.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should handle complex nested HTML with custom components', () => {
|
||||
const html = `
|
||||
<section class="flex">
|
||||
<diyelement class="wrapper" data-type="block-level">
|
||||
<div>Standard HTML</div>
|
||||
<diyelement data-type="inner">Nested component</diyelement>
|
||||
</diyelement>
|
||||
</section>
|
||||
`
|
||||
const customComponents = {
|
||||
diyelement: MockComponentA,
|
||||
}
|
||||
|
||||
const nodes = parseHtmlToVNodes(html, customComponents)
|
||||
expect(nodes).not.toBeNull()
|
||||
expect(nodes!.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should handle mixed custom and standard HTML elements', () => {
|
||||
const html = `
|
||||
<div class="container">
|
||||
<h2>Title</h2>
|
||||
<mycomp data-type="test">
|
||||
<p>Paragraph</p>
|
||||
<anothercomp active="true">Content</anothercomp>
|
||||
</mycomp>
|
||||
</div>
|
||||
`
|
||||
const customComponents = {
|
||||
mycomp: MockComponentA,
|
||||
anothercomp: MockComponentB,
|
||||
}
|
||||
|
||||
const nodes = parseHtmlToVNodes(html, customComponents)
|
||||
expect(nodes).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should handle attributes with various quote styles', () => {
|
||||
const html = `<div class="test" data-value='single' id=unquoted>text</div>`
|
||||
const nodes = parseHtmlToVNodes(html, {})
|
||||
expect(nodes).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('xSS Protection', () => {
|
||||
it('should filter out all dangerous event handlers', () => {
|
||||
const dangerousAttrs = [
|
||||
'onclick',
|
||||
'onerror',
|
||||
'onload',
|
||||
'onmouseover',
|
||||
'onmouseout',
|
||||
'ontouchstart',
|
||||
'onwheel',
|
||||
'onscroll',
|
||||
'oncopy',
|
||||
'oncut',
|
||||
]
|
||||
|
||||
dangerousAttrs.forEach((attr) => {
|
||||
const result = sanitizeAttrs({ [attr]: 'malicious()' })
|
||||
expect(result[attr]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should filter out dangerous URL protocols', () => {
|
||||
expect(sanitizeAttrs({ href: 'javascript:alert(1)' }).href).toBeUndefined()
|
||||
expect(sanitizeAttrs({ src: 'vbscript:msgbox(1)' }).src).toBeUndefined()
|
||||
expect(sanitizeAttrs({ href: 'data:text/html;base64,PGgxPkJBRDwvaDE+' }).href).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should allow safe data URLs for media', () => {
|
||||
const result = sanitizeAttrs({ src: 'data:image/png;base64,iVBORw0KGgo=' })
|
||||
expect(result.src).toBe('data:image/png;base64,iVBORw0KGgo=')
|
||||
})
|
||||
|
||||
it('should preserve safe event-like attributes', () => {
|
||||
const safeAttrs = {
|
||||
'data-onclick': 'safe',
|
||||
'aria-label': 'Test',
|
||||
'class': 'test-class',
|
||||
}
|
||||
const result = sanitizeAttrs(safeAttrs)
|
||||
expect(result).toEqual(safeAttrs)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge Cases', () => {
|
||||
it('should handle HTML with only whitespace', () => {
|
||||
const html = ' \n\t '
|
||||
const tokens = tokenizeHtml(html)
|
||||
expect(tokens).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle malformed tags', () => {
|
||||
const html = '<div>unclosed'
|
||||
const nodes = parseHtmlToVNodes(html, {})
|
||||
expect(nodes).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should handle self-closing syntax for non-void elements', () => {
|
||||
const html = '<div />'
|
||||
const tokens = tokenizeHtml(html)
|
||||
expect(tokens[0].type).toBe('self_closing')
|
||||
})
|
||||
|
||||
it('should handle boolean attributes without values', () => {
|
||||
const html = '<input checked disabled>'
|
||||
const tokens = tokenizeHtml(html)
|
||||
expect(tokens[0].attrs).toEqual({ checked: '', disabled: '' })
|
||||
})
|
||||
|
||||
it('should drop blocked tags like <script>', () => {
|
||||
const html = '<script>alert(1)</script><div>ok</div>'
|
||||
const nodes = parseHtmlToVNodes(html, {})
|
||||
expect(nodes).not.toBeNull()
|
||||
const hasScript = (nodes || []).some((n: any) => typeof n === 'object' && n && n.type === 'script')
|
||||
expect(hasScript).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,204 @@
|
||||
import type { HtmlToken } from 'stream-markdown-parser'
|
||||
import type { Component } from 'vue'
|
||||
import {
|
||||
BLOCKED_HTML_TAGS as BLOCKED_TAGS,
|
||||
convertHtmlAttrsToProps,
|
||||
convertHtmlPropValue,
|
||||
getHtmlTagFromContent,
|
||||
hasCompleteHtmlTagContent,
|
||||
hasCustomHtmlComponents,
|
||||
isCustomHtmlComponentTag,
|
||||
sanitizeHtmlAttrs,
|
||||
shouldRenderUnknownHtmlTagAsText,
|
||||
stripCustomHtmlWrapper,
|
||||
tokenizeHtml,
|
||||
} from 'stream-markdown-parser'
|
||||
import { h } from 'vue'
|
||||
|
||||
export {
|
||||
getHtmlTagFromContent,
|
||||
hasCompleteHtmlTagContent,
|
||||
shouldRenderUnknownHtmlTagAsText,
|
||||
stripCustomHtmlWrapper,
|
||||
tokenizeHtml,
|
||||
}
|
||||
|
||||
export type { HtmlToken } from 'stream-markdown-parser'
|
||||
|
||||
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>,
|
||||
): any[] {
|
||||
let autoKeySeed = 0
|
||||
const stack: Array<{ tagName: string, children: any[], attrs?: Record<string, string>, autoKey: 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, `ms-html-${autoKeySeed++}`)
|
||||
const target = stack.length > 0 ? stack[stack.length - 1].children : rootNodes
|
||||
vnode != null && target.push(vnode)
|
||||
}
|
||||
else if (token.type === 'tag_open') {
|
||||
// Assign an auto-key at open time so outer node keys stay stable while
|
||||
// streaming content grows (otherwise keys shift based on close order).
|
||||
stack.push({
|
||||
tagName: token.tagName!,
|
||||
children: [],
|
||||
attrs: token.attrs,
|
||||
autoKey: `ms-html-${autoKeySeed++}`,
|
||||
})
|
||||
}
|
||||
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, opening.autoKey)
|
||||
|
||||
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, unclosed.autoKey)
|
||||
if (stack.length > 0)
|
||||
vnode != null && stack[stack.length - 1].children.push(vnode)
|
||||
else
|
||||
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>,
|
||||
autoKey: string,
|
||||
): any {
|
||||
if (BLOCKED_TAGS.has(tagName.toLowerCase()))
|
||||
return null
|
||||
|
||||
const sanitizedAttrs = sanitizeAttrs(attrs)
|
||||
const explicitKey = (sanitizedAttrs as any).key
|
||||
const vnodeKey = explicitKey != null && explicitKey !== '' ? explicitKey : autoKey
|
||||
|
||||
if (isCustomComponent(tagName, customComponents)) {
|
||||
// It's a custom Vue component
|
||||
const component = customComponents[tagName] || customComponents[tagName.toLowerCase()]
|
||||
const convertedAttrs = convertAttrsToProps(sanitizedAttrs)
|
||||
return h(component as Component, { ...convertedAttrs, key: vnodeKey }, children.length > 0 ? children : undefined)
|
||||
}
|
||||
else {
|
||||
// It's a standard HTML element
|
||||
return h(tagName, { ...sanitizedAttrs, innerHTML: undefined, key: vnodeKey }, 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>,
|
||||
): any[] | null {
|
||||
if (!content)
|
||||
return []
|
||||
|
||||
try {
|
||||
const tokens = tokenizeHtml(content)
|
||||
const nodes = buildVNodeTree(tokens, customComponents)
|
||||
return nodes
|
||||
}
|
||||
catch (error) {
|
||||
logError('Failed to parse HTML to VNodes:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from '../components/CodeBlockNode/monaco'
|
||||
export { getRegisteredThemes, registerIconTheme, setIconTheme } from '../icon-themes'
|
||||
export type { IconTheme } from '../icon-themes'
|
||||
export * from './katex-threshold'
|
||||
export * from './languageIcon'
|
||||
export * from './performance-monitor'
|
||||
export * from './safeRaf'
|
||||
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,149 @@
|
||||
import { shallowRef } from 'vue'
|
||||
import { materialIconTheme } from '../icon-themes/material'
|
||||
import {
|
||||
_setRevisionBumper,
|
||||
getThemeFallback,
|
||||
preloadActiveThemeExtended,
|
||||
registerIconTheme,
|
||||
resolveFromTheme,
|
||||
} from '../icon-themes/registry'
|
||||
|
||||
export type LanguageIconResolver = (lang: string) => string | undefined | null
|
||||
|
||||
let userLanguageIconResolver: LanguageIconResolver | null = null
|
||||
|
||||
// Bump this ref when the async icon table is loaded so computed icon bindings can refresh.
|
||||
export const languageIconsRevision = shallowRef(0)
|
||||
|
||||
// Wire the registry to bump our revision ref
|
||||
_setRevisionBumper(() => {
|
||||
languageIconsRevision.value++
|
||||
})
|
||||
|
||||
// ── Default theme: Material Icon Theme ─────────────────────────────────
|
||||
|
||||
registerIconTheme(materialIconTheme)
|
||||
|
||||
// ── Language alias normalization ────────────────────────────────────────
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────
|
||||
|
||||
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 preloadActiveThemeExtended()
|
||||
}
|
||||
|
||||
export function getLanguageIcon(lang: string): string {
|
||||
// 1. User custom resolver always wins (backward compat)
|
||||
if (userLanguageIconResolver) {
|
||||
const hit = userLanguageIconResolver(lang)
|
||||
if (hit != null && hit !== '')
|
||||
return hit
|
||||
}
|
||||
|
||||
// 2. Active theme resolution
|
||||
const normalized = normalizeLanguageIdentifier(lang)
|
||||
const themeHit = resolveFromTheme(normalized)
|
||||
if (themeHit)
|
||||
return themeHit
|
||||
|
||||
// 3. Fallback from active theme
|
||||
return getThemeFallback()
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { CustomComponents } from '../types'
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
// 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<number>>
|
||||
}
|
||||
|
||||
const STORE_KEY = '__MARKSTREAM_VUE_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++
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function normalizeKaTeXRenderInput(content: string) {
|
||||
if (!content)
|
||||
return ''
|
||||
|
||||
return content
|
||||
// KaTeX maps U+00B7 to \cdotp, which breaks inside \text{...}.
|
||||
.replace(/·/g, '⋅')
|
||||
// U+2103 has no built-in metrics; split it into supported symbols.
|
||||
.replace(/℃/g, '°C')
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
Reference in New Issue
Block a user