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:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -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 {}
}