mirror of
https://github.com/pumler/plugin-obsidian.git
synced 2026-07-22 06:50:45 +00:00
Fix Obsidian release review issues
This commit is contained in:
parent
a33b20dddb
commit
489b85bfd5
10 changed files with 245 additions and 549 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,3 +1,7 @@
|
|||
node_modules/
|
||||
.idea
|
||||
.qodo
|
||||
|
||||
.DS_Store
|
||||
main.js
|
||||
cache/
|
||||
|
|
|
|||
75
src/cache.ts
75
src/cache.ts
|
|
@ -1,6 +1,5 @@
|
|||
import type { DataAdapter } from 'obsidian'
|
||||
import { createRenderDiagramCacheSeed, type RenderDiagramOptions } from './client'
|
||||
import { createLogDigest, formatLogError, NOOP_PUMLER_LOGGER, type PumlerLogger } from './logger'
|
||||
|
||||
export const SVG_DISK_CACHE_LIMIT = 30
|
||||
|
||||
|
|
@ -29,59 +28,22 @@ export class PumlerDiskSvgCache implements PumlerSvgCache {
|
|||
constructor(
|
||||
private readonly adapter: DataAdapter,
|
||||
private readonly cacheDir: string,
|
||||
private readonly limit = SVG_DISK_CACHE_LIMIT,
|
||||
private readonly logger: PumlerLogger = NOOP_PUMLER_LOGGER
|
||||
private readonly limit = SVG_DISK_CACHE_LIMIT
|
||||
) {
|
||||
this.cacheDir = normalizeVaultPath(cacheDir)
|
||||
this.indexPath = normalizeVaultPath(`${this.cacheDir}/index.json`)
|
||||
}
|
||||
|
||||
async get(options: RenderDiagramOptions): Promise<string | null> {
|
||||
const startedAt = Date.now()
|
||||
const cacheSeedDigest = createLogDigest(createRenderDiagramCacheSeed(options))
|
||||
this.logger.debug('cache.get.start', createCacheLogData(options, cacheSeedDigest))
|
||||
|
||||
try {
|
||||
const svg = await this.enqueueOperation(() => this.readEntry(options, cacheSeedDigest))
|
||||
this.logger.debug(svg ? 'cache.get.hit' : 'cache.get.miss', {
|
||||
...createCacheLogData(options, cacheSeedDigest),
|
||||
durationMs: Date.now() - startedAt,
|
||||
svgLength: svg?.length
|
||||
})
|
||||
return svg
|
||||
return await this.enqueueOperation(() => this.readEntry(options))
|
||||
} catch (error) {
|
||||
this.logger.warn('cache.get.error', {
|
||||
...createCacheLogData(options, cacheSeedDigest),
|
||||
durationMs: Date.now() - startedAt,
|
||||
...formatLogError(error)
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async set(options: RenderDiagramOptions, svg: string): Promise<void> {
|
||||
const startedAt = Date.now()
|
||||
const cacheSeedDigest = createLogDigest(createRenderDiagramCacheSeed(options))
|
||||
this.logger.debug('cache.set.start', {
|
||||
...createCacheLogData(options, cacheSeedDigest),
|
||||
svgLength: svg.length
|
||||
})
|
||||
|
||||
await this.enqueueOperation(() => this.writeEntry(options, svg, cacheSeedDigest))
|
||||
.then(() => {
|
||||
this.logger.debug('cache.set.done', {
|
||||
...createCacheLogData(options, cacheSeedDigest),
|
||||
durationMs: Date.now() - startedAt,
|
||||
svgLength: svg.length
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
this.logger.warn('cache.set.error', {
|
||||
...createCacheLogData(options, cacheSeedDigest),
|
||||
durationMs: Date.now() - startedAt,
|
||||
...formatLogError(error)
|
||||
})
|
||||
})
|
||||
await this.enqueueOperation(() => this.writeEntry(options, svg)).catch(() => undefined)
|
||||
}
|
||||
|
||||
private enqueueOperation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
|
|
@ -96,7 +58,7 @@ export class PumlerDiskSvgCache implements PumlerSvgCache {
|
|||
return result
|
||||
}
|
||||
|
||||
private async readEntry(options: RenderDiagramOptions, cacheSeedDigest: string): Promise<string | null> {
|
||||
private async readEntry(options: RenderDiagramOptions): Promise<string | null> {
|
||||
const key = await createCacheKey(options)
|
||||
const index = await this.readIndex()
|
||||
const entry = index.entries.find(candidate => candidate.key === key)
|
||||
|
|
@ -110,15 +72,10 @@ export class PumlerDiskSvgCache implements PumlerSvgCache {
|
|||
entry.lastAccessed = now
|
||||
entry.updatedAt = Math.max(entry.updatedAt, entry.createdAt)
|
||||
await this.writeIndex(index)
|
||||
this.logger.debug('cache.index.touch', {
|
||||
...createCacheLogData(options, cacheSeedDigest),
|
||||
file: entry.file,
|
||||
entryCount: index.entries.length
|
||||
})
|
||||
return svg
|
||||
}
|
||||
|
||||
private async writeEntry(options: RenderDiagramOptions, svg: string, cacheSeedDigest: string): Promise<void> {
|
||||
private async writeEntry(options: RenderDiagramOptions, svg: string): Promise<void> {
|
||||
await this.ensureCacheDir()
|
||||
|
||||
const key = await createCacheKey(options)
|
||||
|
|
@ -145,11 +102,6 @@ export class PumlerDiskSvgCache implements PumlerSvgCache {
|
|||
|
||||
await this.prune(index)
|
||||
await this.writeIndex(index)
|
||||
this.logger.debug('cache.index.write', {
|
||||
...createCacheLogData(options, cacheSeedDigest),
|
||||
file,
|
||||
entryCount: index.entries.length
|
||||
})
|
||||
}
|
||||
|
||||
private async readIndex(): Promise<CacheIndex> {
|
||||
|
|
@ -191,13 +143,6 @@ export class PumlerDiskSvgCache implements PumlerSvgCache {
|
|||
index.entries = retainedEntries
|
||||
|
||||
await Promise.all(removedEntries.map(entry => this.removeCacheFile(entry.file)))
|
||||
if (removedEntries.length > 0) {
|
||||
this.logger.info('cache.prune', {
|
||||
removedCount: removedEntries.length,
|
||||
retainedCount: retainedEntries.length,
|
||||
limit: this.limit
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async removeCacheFile(file: string): Promise<void> {
|
||||
|
|
@ -209,16 +154,6 @@ export class PumlerDiskSvgCache implements PumlerSvgCache {
|
|||
}
|
||||
}
|
||||
|
||||
function createCacheLogData(options: RenderDiagramOptions, cacheSeedDigest: string): Record<string, unknown> {
|
||||
return {
|
||||
provider: options.provider,
|
||||
type: options.type,
|
||||
theme: options.theme,
|
||||
sourceLength: options.source.length,
|
||||
cacheSeedDigest
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeVaultPath(path: string): string {
|
||||
return path
|
||||
.replace(/\\/g, '/')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { PUMLER_API_URL, type Provider, type ResolvedTheme } from './constants'
|
||||
import { formatLogError, NOOP_PUMLER_LOGGER, type PumlerLogger } from './logger'
|
||||
|
||||
export interface RenderDiagramOptions {
|
||||
provider: Provider
|
||||
|
|
@ -33,19 +32,8 @@ export class PumlerRenderError extends Error {
|
|||
}
|
||||
|
||||
export class PumlerApiClient {
|
||||
constructor(private readonly logger: PumlerLogger = NOOP_PUMLER_LOGGER) {}
|
||||
|
||||
async renderDiagram(options: RenderDiagramOptions, requestOptions: RenderDiagramRequestOptions = {}): Promise<string> {
|
||||
let response: Response
|
||||
const startedAt = Date.now()
|
||||
this.logger.info('client.render.start', {
|
||||
provider: options.provider,
|
||||
type: options.type,
|
||||
theme: options.theme,
|
||||
sourceLength: options.source.length,
|
||||
signalAborted: requestOptions.signal?.aborted ?? false
|
||||
})
|
||||
|
||||
try {
|
||||
response = await fetch(PUMLER_API_URL, {
|
||||
method: 'POST',
|
||||
|
|
@ -66,13 +54,6 @@ export class PumlerApiClient {
|
|||
signal: requestOptions.signal
|
||||
})
|
||||
} catch (error) {
|
||||
this.logger.warn('client.render.network_error', {
|
||||
provider: options.provider,
|
||||
type: options.type,
|
||||
durationMs: Date.now() - startedAt,
|
||||
signalAborted: requestOptions.signal?.aborted ?? false,
|
||||
...formatLogError(error)
|
||||
})
|
||||
if (requestOptions.signal?.aborted) {
|
||||
throw error
|
||||
}
|
||||
|
|
@ -81,34 +62,14 @@ export class PumlerApiClient {
|
|||
|
||||
const data = await readJson(response)
|
||||
if (!response.ok) {
|
||||
this.logger.warn('client.render.api_error', {
|
||||
provider: options.provider,
|
||||
type: options.type,
|
||||
durationMs: Date.now() - startedAt,
|
||||
status: response.status
|
||||
})
|
||||
throw mapApiError(data)
|
||||
}
|
||||
|
||||
const diagram = data && typeof data === 'object' && 'diagram' in data ? data.diagram : undefined
|
||||
if (typeof diagram !== 'string' || diagram.trim() === '') {
|
||||
this.logger.warn('client.render.unexpected_response', {
|
||||
provider: options.provider,
|
||||
type: options.type,
|
||||
durationMs: Date.now() - startedAt,
|
||||
status: response.status
|
||||
})
|
||||
throw new PumlerRenderError('Unexpected response from the Pumler rendering API')
|
||||
}
|
||||
|
||||
this.logger.info('client.render.success', {
|
||||
provider: options.provider,
|
||||
type: options.type,
|
||||
durationMs: Date.now() - startedAt,
|
||||
status: response.status,
|
||||
svgLength: diagram.length
|
||||
})
|
||||
|
||||
return diagram
|
||||
}
|
||||
}
|
||||
|
|
|
|||
145
src/logger.ts
145
src/logger.ts
|
|
@ -1,145 +0,0 @@
|
|||
import type { DataAdapter } from 'obsidian'
|
||||
|
||||
type PumlerLogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
export type PumlerLogData = Record<string, unknown>
|
||||
|
||||
export interface PumlerLogger {
|
||||
debug(event: string, data?: PumlerLogData): void
|
||||
info(event: string, data?: PumlerLogData): void
|
||||
warn(event: string, data?: PumlerLogData): void
|
||||
error(event: string, data?: PumlerLogData): void
|
||||
}
|
||||
|
||||
export const NOOP_PUMLER_LOGGER: PumlerLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined
|
||||
}
|
||||
|
||||
export class PumlerDiskLogger implements PumlerLogger {
|
||||
private writeQueue: Promise<void> = Promise.resolve()
|
||||
private readonly logPath: string
|
||||
private readonly logDir: string | null
|
||||
|
||||
constructor(
|
||||
private readonly adapter: DataAdapter,
|
||||
logPath: string
|
||||
) {
|
||||
this.logPath = normalizeVaultPath(logPath)
|
||||
this.logDir = getParentPath(this.logPath)
|
||||
}
|
||||
|
||||
debug(event: string, data: PumlerLogData = {}): void {
|
||||
this.write('debug', event, data)
|
||||
}
|
||||
|
||||
info(event: string, data: PumlerLogData = {}): void {
|
||||
this.write('info', event, data)
|
||||
}
|
||||
|
||||
warn(event: string, data: PumlerLogData = {}): void {
|
||||
this.write('warn', event, data)
|
||||
}
|
||||
|
||||
error(event: string, data: PumlerLogData = {}): void {
|
||||
this.write('error', event, data)
|
||||
}
|
||||
|
||||
private write(level: PumlerLogLevel, event: string, data: PumlerLogData): void {
|
||||
const entry = {
|
||||
ts: new Date().toISOString(),
|
||||
level,
|
||||
event,
|
||||
...sanitizeLogData(data)
|
||||
}
|
||||
const line = `${JSON.stringify(entry)}\n`
|
||||
|
||||
this.writeQueue = this.writeQueue
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
await this.ensureLogDir()
|
||||
await this.ensureLogFile()
|
||||
await this.adapter.append(this.logPath, line)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
private async ensureLogDir(): Promise<void> {
|
||||
if (!this.logDir || await this.adapter.exists(this.logDir)) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.adapter.mkdir(this.logDir)
|
||||
}
|
||||
|
||||
private async ensureLogFile(): Promise<void> {
|
||||
if (await this.adapter.exists(this.logPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.adapter.write(this.logPath, '')
|
||||
}
|
||||
}
|
||||
|
||||
export function createLogDigest(value: string): string {
|
||||
let hash = 0x811c9dc5
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index)
|
||||
hash = Math.imul(hash, 0x01000193)
|
||||
}
|
||||
|
||||
return (hash >>> 0).toString(16).padStart(8, '0')
|
||||
}
|
||||
|
||||
export function formatLogError(error: unknown): PumlerLogData {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
errorName: error.name,
|
||||
errorMessage: error.message
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
errorMessage: String(error)
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeLogData(data: PumlerLogData): PumlerLogData {
|
||||
const sanitized: PumlerLogData = {}
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
sanitized[key] = sanitizeLogValue(value)
|
||||
})
|
||||
return sanitized
|
||||
}
|
||||
|
||||
function sanitizeLogValue(value: unknown): unknown {
|
||||
if (value instanceof Error) {
|
||||
return formatLogError(value)
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(sanitizeLogValue)
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return sanitizeLogData(value as PumlerLogData)
|
||||
}
|
||||
if (typeof value === 'string' && value.length > 500) {
|
||||
return `${value.slice(0, 500)}...[truncated:${value.length}]`
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function normalizeVaultPath(path: string): string {
|
||||
return path
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/\/+/g, '/')
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function getParentPath(path: string): string | null {
|
||||
const separatorIndex = path.lastIndexOf('/')
|
||||
return separatorIndex > 0 ? path.slice(0, separatorIndex) : null
|
||||
}
|
||||
107
src/main.ts
107
src/main.ts
|
|
@ -1,91 +1,46 @@
|
|||
import { MarkdownRenderChild, Plugin, type App, type MarkdownPostProcessorContext, type WorkspaceLeaf } from 'obsidian'
|
||||
import { MarkdownRenderChild, Plugin, type App, type MarkdownPostProcessorContext } from 'obsidian'
|
||||
import { PumlerDiskSvgCache } from './cache'
|
||||
import { PumlerApiClient } from './client'
|
||||
import { createLogDigest, NOOP_PUMLER_LOGGER, PumlerDiskLogger, type PumlerLogger } from './logger'
|
||||
import { parsePumlerBlock } from './parser'
|
||||
import { PumlerBlockRenderer } from './renderer'
|
||||
|
||||
export default class PumlerPlugin extends Plugin {
|
||||
async onload(): Promise<void> {
|
||||
const logger = this.manifest.dir
|
||||
? new PumlerDiskLogger(this.app.vault.adapter, `${this.manifest.dir}/pumler-debug.log`)
|
||||
: NOOP_PUMLER_LOGGER
|
||||
const cache = this.manifest.dir
|
||||
? new PumlerDiskSvgCache(this.app.vault.adapter, `${this.manifest.dir}/cache`, undefined, logger)
|
||||
? new PumlerDiskSvgCache(this.app.vault.adapter, `${this.manifest.dir}/cache`)
|
||||
: undefined
|
||||
const renderer = new PumlerBlockRenderer(new PumlerApiClient(logger), cache, logger)
|
||||
|
||||
logger.info('plugin.load', {
|
||||
pluginDir: this.manifest.dir,
|
||||
version: this.manifest.version
|
||||
})
|
||||
this.registerWorkspaceLogging(logger)
|
||||
const renderer = new PumlerBlockRenderer(new PumlerApiClient(), cache)
|
||||
|
||||
this.registerMarkdownCodeBlockProcessor('pumler', (source, element, context) => {
|
||||
const debounceKey = createDebounceKey(source, element, context)
|
||||
logger.info('processor.block', {
|
||||
sourcePath: context.sourcePath,
|
||||
docId: context.docId,
|
||||
debounceKey,
|
||||
sourceLength: source.length,
|
||||
sourceDigest: createLogDigest(source)
|
||||
})
|
||||
context.addChild(new PumlerMarkdownRenderChild(
|
||||
this.app,
|
||||
element,
|
||||
source,
|
||||
renderer,
|
||||
debounceKey,
|
||||
context.sourcePath,
|
||||
logger
|
||||
debounceKey
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
private registerWorkspaceLogging(logger: PumlerLogger): void {
|
||||
this.registerEvent(this.app.workspace.on('file-open', file => {
|
||||
logger.info('workspace.file_open', {
|
||||
path: file?.path,
|
||||
activeLeaf: describeLeaf(this.app.workspace.activeLeaf)
|
||||
})
|
||||
window.setTimeout(() => {
|
||||
logger.info('workspace.file_open.after_tick', describeWorkspace(this.app))
|
||||
}, 0)
|
||||
}))
|
||||
|
||||
this.registerEvent(this.app.workspace.on('active-leaf-change', leaf => {
|
||||
logger.info('workspace.active_leaf_change', {
|
||||
leaf: describeLeaf(leaf)
|
||||
})
|
||||
}))
|
||||
|
||||
this.registerEvent(this.app.workspace.on('layout-change', () => {
|
||||
logger.info('workspace.layout_change', describeWorkspace(this.app))
|
||||
}))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class PumlerMarkdownRenderChild extends MarkdownRenderChild {
|
||||
private abortController: AbortController | null = null
|
||||
private readonly modalCleanups = new Set<() => void>()
|
||||
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
containerEl: HTMLElement,
|
||||
private readonly source: string,
|
||||
private readonly renderer: PumlerBlockRenderer,
|
||||
private readonly debounceKey: string,
|
||||
private readonly sourcePath: string,
|
||||
private readonly logger: PumlerLogger
|
||||
private readonly debounceKey: string
|
||||
) {
|
||||
super(containerEl)
|
||||
}
|
||||
|
||||
onload(): void {
|
||||
this.logger.info('render_child.load', this.createLogData())
|
||||
this.registerEvent(this.app.workspace.on('css-change', () => {
|
||||
if (usesAutoTheme(this.source)) {
|
||||
this.logger.info('render_child.css_change_rerender', this.createLogData())
|
||||
this.render()
|
||||
}
|
||||
}))
|
||||
|
|
@ -93,37 +48,36 @@ class PumlerMarkdownRenderChild extends MarkdownRenderChild {
|
|||
}
|
||||
|
||||
onunload(): void {
|
||||
this.logger.info('render_child.unload', this.createLogData())
|
||||
this.abortActiveRender()
|
||||
this.closeOpenModals()
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
this.abortActiveRender()
|
||||
this.closeOpenModals()
|
||||
const abortController = new AbortController()
|
||||
this.abortController = abortController
|
||||
|
||||
this.logger.info('render_child.render', this.createLogData())
|
||||
void this.renderer.render(this.source, this.containerEl, {
|
||||
debounceKey: this.debounceKey,
|
||||
signal: abortController.signal
|
||||
signal: abortController.signal,
|
||||
registerModalCleanup: cleanup => {
|
||||
this.modalCleanups.add(cleanup)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private abortActiveRender(): void {
|
||||
if (this.abortController) {
|
||||
this.logger.debug('render_child.abort_active', this.createLogData())
|
||||
this.abortController.abort()
|
||||
}
|
||||
this.abortController = null
|
||||
}
|
||||
|
||||
private createLogData(): Record<string, unknown> {
|
||||
return {
|
||||
sourcePath: this.sourcePath,
|
||||
debounceKey: this.debounceKey,
|
||||
sourceLength: this.source.length,
|
||||
sourceDigest: createLogDigest(this.source)
|
||||
}
|
||||
private closeOpenModals(): void {
|
||||
const cleanups = Array.from(this.modalCleanups)
|
||||
this.modalCleanups.clear()
|
||||
cleanups.forEach(cleanup => cleanup())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -167,32 +121,3 @@ function createElementDebounceKey(element: HTMLElement): string {
|
|||
element.dataset.pumlerDebounceKey = key
|
||||
return key
|
||||
}
|
||||
|
||||
function describeWorkspace(app: App): Record<string, unknown> {
|
||||
return {
|
||||
activeLeaf: describeLeaf(app.workspace.activeLeaf),
|
||||
markdownLeafCount: app.workspace.getLeavesOfType('markdown').length
|
||||
}
|
||||
}
|
||||
|
||||
function describeLeaf(leaf: WorkspaceLeaf | null): Record<string, unknown> | null {
|
||||
if (!leaf) {
|
||||
return null
|
||||
}
|
||||
|
||||
const view = leaf.view as {
|
||||
getViewType?: () => string
|
||||
getMode?: () => string
|
||||
file?: { path?: string }
|
||||
}
|
||||
const state = leaf.getViewState()
|
||||
|
||||
return {
|
||||
viewType: typeof view.getViewType === 'function' ? view.getViewType() : state.type,
|
||||
stateType: state.type,
|
||||
mode: typeof view.getMode === 'function' ? view.getMode() : undefined,
|
||||
stateMode: typeof state.state?.mode === 'string' ? state.state.mode : undefined,
|
||||
filePath: view.file?.path,
|
||||
stateFile: typeof state.state?.file === 'string' ? state.state.file : undefined
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { PumlerSvgCache } from './cache'
|
|||
describe('PumlerBlockRenderer', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
document.querySelectorAll('.pumler-render__modal').forEach(element => element.remove())
|
||||
})
|
||||
|
||||
test('shows a loading state before rendering resolves', async () => {
|
||||
|
|
@ -102,6 +103,24 @@ describe('PumlerBlockRenderer', () => {
|
|||
expect(document.querySelector('.pumler-render__modal')).toBeNull()
|
||||
})
|
||||
|
||||
test('registers modal cleanup for render child unload', async () => {
|
||||
const renderer = new PumlerBlockRenderer(createClient(async () => '<svg viewBox="0 0 10 10"><circle /></svg>'))
|
||||
const element = document.createElement('div')
|
||||
const cleanups: Array<() => void> = []
|
||||
|
||||
await renderer.render(validBlock(), element, {
|
||||
debounceMs: 0,
|
||||
registerModalCleanup: cleanup => cleanups.push(cleanup)
|
||||
})
|
||||
element.querySelector<HTMLButtonElement>('.pumler-render__open-button')?.click()
|
||||
|
||||
expect(document.querySelector('.pumler-render__modal')).not.toBeNull()
|
||||
expect(cleanups).toHaveLength(1)
|
||||
|
||||
cleanups.forEach(cleanup => cleanup())
|
||||
expect(document.querySelector('.pumler-render__modal')).toBeNull()
|
||||
})
|
||||
|
||||
test('button zoom keeps the diagram center as focal point', async () => {
|
||||
const renderer = new PumlerBlockRenderer(createClient(async () => '<svg viewBox="0 0 10 10"><circle /></svg>'))
|
||||
const element = document.createElement('div')
|
||||
|
|
|
|||
231
src/renderer.ts
231
src/renderer.ts
|
|
@ -1,7 +1,6 @@
|
|||
import { parsePumlerBlock, PumlerValidationError } from './parser'
|
||||
import { resolveTheme } from './theme'
|
||||
import { createRenderDiagramCacheSeed, PumlerApiClient, PumlerRenderError, type RenderDiagramOptions } from './client'
|
||||
import { createLogDigest, formatLogError, NOOP_PUMLER_LOGGER, type PumlerLogger } from './logger'
|
||||
import { cloneSanitizedSvgWithIdScope, sanitizeSvg } from './svg'
|
||||
import type { PumlerSvgCache } from './cache'
|
||||
|
||||
|
|
@ -18,6 +17,7 @@ interface RenderOptions {
|
|||
debounceMs?: number
|
||||
requireConnected?: boolean
|
||||
signal?: AbortSignal
|
||||
registerModalCleanup?: (cleanup: () => void) => void
|
||||
}
|
||||
|
||||
interface PendingRender {
|
||||
|
|
@ -40,18 +40,11 @@ export class PumlerBlockRenderer {
|
|||
|
||||
constructor(
|
||||
private readonly client: PumlerApiClient,
|
||||
private readonly cache?: PumlerSvgCache,
|
||||
private readonly logger: PumlerLogger = NOOP_PUMLER_LOGGER
|
||||
private readonly cache?: PumlerSvgCache
|
||||
) {}
|
||||
|
||||
async render(source: string, element: HTMLElement, options: RenderOptions = {}): Promise<void> {
|
||||
const sourceDigest = createLogDigest(source)
|
||||
if (options.signal?.aborted) {
|
||||
this.logger.debug('renderer.render.skip_pre_aborted', {
|
||||
debounceKey: options.debounceKey,
|
||||
sourceLength: source.length,
|
||||
sourceDigest
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -61,14 +54,6 @@ export class PumlerBlockRenderer {
|
|||
const debounceKey = options.debounceKey || createElementDebounceKey(element)
|
||||
const token = ++this.nextRenderToken
|
||||
this.activeRenderTokens.set(debounceKey, token)
|
||||
this.logger.info('renderer.render.start', {
|
||||
debounceKey,
|
||||
token,
|
||||
sourceLength: source.length,
|
||||
sourceDigest,
|
||||
elementConnected: element.isConnected,
|
||||
signalAborted: options.signal?.aborted ?? false
|
||||
})
|
||||
|
||||
const isCurrentRender = () => this.activeRenderTokens.get(debounceKey) === token
|
||||
let parsed: ReturnType<typeof parsePumlerBlock>
|
||||
|
|
@ -81,45 +66,17 @@ export class PumlerBlockRenderer {
|
|||
theme: resolveTheme(parsed.metadata.theme),
|
||||
source: parsed.source
|
||||
}
|
||||
this.logger.info('renderer.render.parsed', {
|
||||
debounceKey,
|
||||
token,
|
||||
provider: renderOptions.provider,
|
||||
type: renderOptions.type,
|
||||
configuredTheme: parsed.metadata.theme,
|
||||
resolvedTheme: renderOptions.theme,
|
||||
diagramSourceLength: parsed.source.length,
|
||||
diagramSourceDigest: createLogDigest(parsed.source)
|
||||
})
|
||||
|
||||
const cachedSvg = await this.cache?.get(renderOptions)
|
||||
if (cachedSvg) {
|
||||
if (options.signal?.aborted || !isCurrentRender()) {
|
||||
this.logger.debug('renderer.render.cached_skip_stale', {
|
||||
debounceKey,
|
||||
token,
|
||||
signalAborted: options.signal?.aborted ?? false,
|
||||
isCurrentRender: isCurrentRender()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.logger.info('renderer.render.cache_hit_replace', {
|
||||
debounceKey,
|
||||
token,
|
||||
svgLength: cachedSvg.length
|
||||
})
|
||||
this.replaceWithDiagram(element, cachedSvg, parsed.metadata, renderOptions, debounceKey, token)
|
||||
this.replaceWithDiagram(element, cachedSvg, parsed.metadata, renderOptions, options.registerModalCleanup)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn('renderer.render.parse_or_cache_error', {
|
||||
debounceKey,
|
||||
token,
|
||||
signalAborted: options.signal?.aborted ?? false,
|
||||
isCurrentRender: isCurrentRender(),
|
||||
...formatLogError(error)
|
||||
})
|
||||
if (!options.signal?.aborted && isCurrentRender()) {
|
||||
element.replaceChildren(createErrorElement(error))
|
||||
}
|
||||
|
|
@ -128,11 +85,6 @@ export class PumlerBlockRenderer {
|
|||
|
||||
const pendingRender = this.pendingRenders.get(debounceKey)
|
||||
if (pendingRender) {
|
||||
this.logger.debug('renderer.debounce.supersede_previous', {
|
||||
debounceKey,
|
||||
token,
|
||||
hadTimer: pendingRender.timer !== null
|
||||
})
|
||||
if (pendingRender.timer !== null) {
|
||||
clearTimeout(pendingRender.timer)
|
||||
}
|
||||
|
|
@ -150,11 +102,6 @@ export class PumlerBlockRenderer {
|
|||
}
|
||||
settled = true
|
||||
options.signal?.removeEventListener('abort', settle)
|
||||
this.logger.debug('renderer.render.promise_settle', {
|
||||
debounceKey,
|
||||
token,
|
||||
signalAborted: options.signal?.aborted ?? false
|
||||
})
|
||||
resolve()
|
||||
}
|
||||
const cancelScheduledRender = () => {
|
||||
|
|
@ -164,10 +111,6 @@ export class PumlerBlockRenderer {
|
|||
if (pendingRender && this.pendingRenders.get(debounceKey) === pendingRender) {
|
||||
this.pendingRenders.delete(debounceKey)
|
||||
}
|
||||
this.logger.debug('renderer.debounce.cancel_scheduled', {
|
||||
debounceKey,
|
||||
token
|
||||
})
|
||||
settle()
|
||||
}
|
||||
|
||||
|
|
@ -175,12 +118,16 @@ export class PumlerBlockRenderer {
|
|||
if (pendingRender && this.pendingRenders.get(debounceKey) === pendingRender) {
|
||||
this.pendingRenders.delete(debounceKey)
|
||||
}
|
||||
this.logger.info('renderer.debounce.execute', {
|
||||
void this.renderRemote(
|
||||
renderOptions,
|
||||
parsed.metadata,
|
||||
element,
|
||||
debounceKey,
|
||||
token,
|
||||
signalAborted: options.signal?.aborted ?? false
|
||||
})
|
||||
void this.renderRemote(renderOptions, parsed.metadata, element, debounceKey, token, options.requireConnected, options.signal).finally(settle)
|
||||
options.requireConnected,
|
||||
options.signal,
|
||||
options.registerModalCleanup
|
||||
).finally(settle)
|
||||
}
|
||||
|
||||
if (debounceMs <= 0) {
|
||||
|
|
@ -189,11 +136,6 @@ export class PumlerBlockRenderer {
|
|||
}
|
||||
|
||||
timer = setTimeout(execute, debounceMs)
|
||||
this.logger.info('renderer.debounce.schedule', {
|
||||
debounceKey,
|
||||
token,
|
||||
debounceMs
|
||||
})
|
||||
pendingRender = {
|
||||
timer,
|
||||
supersede: cancelScheduledRender
|
||||
|
|
@ -201,10 +143,6 @@ export class PumlerBlockRenderer {
|
|||
this.pendingRenders.set(debounceKey, pendingRender)
|
||||
options.signal?.addEventListener('abort', settle, { once: true })
|
||||
if (options.signal?.aborted) {
|
||||
this.logger.debug('renderer.debounce.post_listener_aborted', {
|
||||
debounceKey,
|
||||
token
|
||||
})
|
||||
settle()
|
||||
}
|
||||
})
|
||||
|
|
@ -217,54 +155,24 @@ export class PumlerBlockRenderer {
|
|||
debounceKey: string,
|
||||
token: number,
|
||||
requireConnected: boolean | undefined,
|
||||
signal: AbortSignal | undefined
|
||||
signal: AbortSignal | undefined,
|
||||
registerModalCleanup: ((cleanup: () => void) => void) | undefined
|
||||
): Promise<void> {
|
||||
if (requireConnected && !element.isConnected) {
|
||||
this.logger.info('renderer.remote.skip_detached', {
|
||||
debounceKey,
|
||||
token
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const isCurrentRender = () => this.activeRenderTokens.get(debounceKey) === token
|
||||
|
||||
try {
|
||||
this.logger.info('renderer.remote.await', {
|
||||
debounceKey,
|
||||
token,
|
||||
provider: options.provider,
|
||||
type: options.type,
|
||||
theme: options.theme,
|
||||
signalAborted: signal?.aborted ?? false
|
||||
})
|
||||
const svg = await this.getOrStartRemoteRender(options)
|
||||
|
||||
if (signal?.aborted || !isCurrentRender()) {
|
||||
this.logger.info('renderer.remote.skip_replace_stale', {
|
||||
debounceKey,
|
||||
token,
|
||||
signalAborted: signal?.aborted ?? false,
|
||||
isCurrentRender: isCurrentRender(),
|
||||
svgLength: svg.length
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.logger.info('renderer.remote.replace', {
|
||||
debounceKey,
|
||||
token,
|
||||
svgLength: svg.length
|
||||
})
|
||||
this.replaceWithDiagram(element, svg, metadata, options, debounceKey, token)
|
||||
this.replaceWithDiagram(element, svg, metadata, options, registerModalCleanup)
|
||||
} catch (error) {
|
||||
this.logger.warn('renderer.remote.error', {
|
||||
debounceKey,
|
||||
token,
|
||||
signalAborted: signal?.aborted ?? false,
|
||||
isCurrentRender: isCurrentRender(),
|
||||
...formatLogError(error)
|
||||
})
|
||||
if (signal?.aborted || !isCurrentRender()) {
|
||||
return
|
||||
}
|
||||
|
|
@ -274,28 +182,18 @@ export class PumlerBlockRenderer {
|
|||
|
||||
private getOrStartRemoteRender(options: RenderDiagramOptions): Promise<string> {
|
||||
const key = createRenderDiagramCacheSeed(options)
|
||||
const renderDigest = createLogDigest(key)
|
||||
const existingRender = this.inFlightRemoteRenders.get(key)
|
||||
if (existingRender) {
|
||||
this.logger.info('renderer.remote.reuse_inflight', createRenderLogData(options, renderDigest))
|
||||
return existingRender
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
this.logger.info('renderer.remote.start_inflight', createRenderLogData(options, renderDigest))
|
||||
const render = this.client.renderDiagram(options)
|
||||
.then(async svg => {
|
||||
await this.cache?.set(options, svg)
|
||||
this.logger.info('renderer.remote.done_inflight', {
|
||||
...createRenderLogData(options, renderDigest),
|
||||
durationMs: Date.now() - startedAt,
|
||||
svgLength: svg.length
|
||||
})
|
||||
return svg
|
||||
})
|
||||
.finally(() => {
|
||||
this.inFlightRemoteRenders.delete(key)
|
||||
this.logger.debug('renderer.remote.clear_inflight', createRenderLogData(options, renderDigest))
|
||||
})
|
||||
|
||||
this.inFlightRemoteRenders.set(key, render)
|
||||
|
|
@ -307,70 +205,28 @@ export class PumlerBlockRenderer {
|
|||
svg: string,
|
||||
metadata: { provider: string, type: string, title?: string },
|
||||
options: RenderDiagramOptions,
|
||||
debounceKey: string,
|
||||
token: number
|
||||
registerModalCleanup: ((cleanup: () => void) => void) | undefined
|
||||
): void {
|
||||
const renderSeed = createRenderDiagramCacheSeed(options)
|
||||
const renderDigest = createLogDigest(renderSeed)
|
||||
const startedAt = Date.now()
|
||||
this.logger.debug('renderer.dom.sanitize_start', {
|
||||
debounceKey,
|
||||
token,
|
||||
renderDigest,
|
||||
provider: metadata.provider,
|
||||
type: metadata.type,
|
||||
svgLength: svg.length
|
||||
})
|
||||
|
||||
const template = this.getOrCreateSanitizedSvgTemplate(renderSeed, renderDigest, svg, metadata, debounceKey, token)
|
||||
const cloneStartedAt = Date.now()
|
||||
const template = this.getOrCreateSanitizedSvgTemplate(renderSeed, svg)
|
||||
const svgElement = cloneSanitizedSvgWithIdScope(template, this.createSvgIdScope())
|
||||
svgElement.classList.add('pumler-render__svg')
|
||||
this.logger.debug('renderer.dom.clone_done', {
|
||||
debounceKey,
|
||||
token,
|
||||
renderDigest,
|
||||
durationMs: Date.now() - cloneStartedAt
|
||||
})
|
||||
|
||||
const replaceStartedAt = Date.now()
|
||||
element.replaceChildren(createDiagramFigure(svgElement, metadata))
|
||||
this.logger.info('renderer.dom.replace_done', {
|
||||
debounceKey,
|
||||
token,
|
||||
renderDigest,
|
||||
provider: metadata.provider,
|
||||
type: metadata.type,
|
||||
title: metadata.title,
|
||||
replaceDurationMs: Date.now() - replaceStartedAt,
|
||||
totalDurationMs: Date.now() - startedAt
|
||||
})
|
||||
element.replaceChildren(createDiagramFigure(svgElement, metadata, registerModalCleanup))
|
||||
}
|
||||
|
||||
private getOrCreateSanitizedSvgTemplate(
|
||||
renderSeed: string,
|
||||
renderDigest: string,
|
||||
svg: string,
|
||||
metadata: { provider: string, type: string },
|
||||
debounceKey: string,
|
||||
token: number
|
||||
svg: string
|
||||
): SVGElement {
|
||||
const cachedTemplate = this.sanitizedSvgTemplates.get(renderSeed)
|
||||
if (cachedTemplate) {
|
||||
this.sanitizedSvgTemplates.delete(renderSeed)
|
||||
this.sanitizedSvgTemplates.set(renderSeed, cachedTemplate)
|
||||
this.logger.debug('renderer.dom.template_cache_hit', {
|
||||
debounceKey,
|
||||
token,
|
||||
renderDigest,
|
||||
provider: metadata.provider,
|
||||
type: metadata.type,
|
||||
templateCacheSize: this.sanitizedSvgTemplates.size
|
||||
})
|
||||
return cachedTemplate
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
const template = sanitizeSvg(svg)
|
||||
this.sanitizedSvgTemplates.set(renderSeed, template)
|
||||
while (this.sanitizedSvgTemplates.size > SANITIZED_SVG_TEMPLATE_CACHE_LIMIT) {
|
||||
|
|
@ -380,16 +236,6 @@ export class PumlerBlockRenderer {
|
|||
}
|
||||
this.sanitizedSvgTemplates.delete(oldestKey)
|
||||
}
|
||||
this.logger.info('renderer.dom.template_cache_miss', {
|
||||
debounceKey,
|
||||
token,
|
||||
renderDigest,
|
||||
provider: metadata.provider,
|
||||
type: metadata.type,
|
||||
sanitizeDurationMs: Date.now() - startedAt,
|
||||
svgLength: svg.length,
|
||||
templateCacheSize: this.sanitizedSvgTemplates.size
|
||||
})
|
||||
return template
|
||||
}
|
||||
|
||||
|
|
@ -399,17 +245,6 @@ export class PumlerBlockRenderer {
|
|||
}
|
||||
}
|
||||
|
||||
function createRenderLogData(options: RenderDiagramOptions, renderDigest: string): Record<string, unknown> {
|
||||
return {
|
||||
provider: options.provider,
|
||||
type: options.type,
|
||||
theme: options.theme,
|
||||
sourceLength: options.source.length,
|
||||
sourceDigest: createLogDigest(options.source),
|
||||
renderDigest
|
||||
}
|
||||
}
|
||||
|
||||
const elementDebounceKeys = new WeakMap<HTMLElement, string>()
|
||||
let nextElementDebounceKey = 0
|
||||
|
||||
|
|
@ -425,7 +260,11 @@ function createElementDebounceKey(element: HTMLElement): string {
|
|||
return key
|
||||
}
|
||||
|
||||
function createDiagramFigure(svgElement: SVGElement, metadata: { provider: string, type: string, title?: string }): HTMLElement {
|
||||
function createDiagramFigure(
|
||||
svgElement: SVGElement,
|
||||
metadata: { provider: string, type: string, title?: string },
|
||||
registerModalCleanup: ((cleanup: () => void) => void) | undefined
|
||||
): HTMLElement {
|
||||
const figure = document.createElement('div')
|
||||
figure.className = 'pumler-render__figure'
|
||||
|
||||
|
|
@ -434,7 +273,7 @@ function createDiagramFigure(svgElement: SVGElement, metadata: { provider: strin
|
|||
viewport.appendChild(svgElement)
|
||||
|
||||
let isExpanded = true
|
||||
const summary = createCollapsedSummary(metadata, svgElement, () => {
|
||||
const summary = createCollapsedSummary(metadata, svgElement, registerModalCleanup, () => {
|
||||
setExpanded(!isExpanded)
|
||||
})
|
||||
const setExpanded = (expanded: boolean) => {
|
||||
|
|
@ -457,6 +296,7 @@ function createDiagramFigure(svgElement: SVGElement, metadata: { provider: strin
|
|||
function createCollapsedSummary(
|
||||
metadata: { provider: string, type: string, title?: string },
|
||||
svgElement: SVGElement,
|
||||
registerModalCleanup: ((cleanup: () => void) => void) | undefined,
|
||||
onToggle: () => void
|
||||
): { button: HTMLButtonElement, stateIcon: HTMLSpanElement } {
|
||||
const button = document.createElement('button')
|
||||
|
|
@ -477,13 +317,13 @@ function createCollapsedSummary(
|
|||
openButton.appendChild(createMagnifierIcon())
|
||||
openButton.addEventListener('click', event => {
|
||||
event.stopPropagation()
|
||||
openLargePreview(svgElement)
|
||||
openRegisteredLargePreview(svgElement, registerModalCleanup)
|
||||
})
|
||||
openButton.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openLargePreview(svgElement)
|
||||
openRegisteredLargePreview(svgElement, registerModalCleanup)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -511,6 +351,14 @@ function createCollapsedSummary(
|
|||
return { button, stateIcon: expandIcon }
|
||||
}
|
||||
|
||||
function openRegisteredLargePreview(
|
||||
svgElement: SVGElement,
|
||||
registerModalCleanup: ((cleanup: () => void) => void) | undefined
|
||||
): void {
|
||||
const cleanup = openLargePreview(svgElement)
|
||||
registerModalCleanup?.(cleanup)
|
||||
}
|
||||
|
||||
function createMagnifierIcon(): SVGElement {
|
||||
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
icon.setAttribute('class', 'pumler-render__open-icon')
|
||||
|
|
@ -611,7 +459,7 @@ function createBaseIcon(className: string): SVGElement {
|
|||
return icon
|
||||
}
|
||||
|
||||
function openLargePreview(svgElement: SVGElement): void {
|
||||
function openLargePreview(svgElement: SVGElement): () => void {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'pumler-render__modal'
|
||||
overlay.setAttribute('role', 'dialog')
|
||||
|
|
@ -747,7 +595,12 @@ function openLargePreview(svgElement: SVGElement): void {
|
|||
})
|
||||
applyZoom()
|
||||
|
||||
let closed = false
|
||||
const close = () => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
closed = true
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
overlay.remove()
|
||||
}
|
||||
|
|
@ -771,6 +624,8 @@ function openLargePreview(svgElement: SVGElement): void {
|
|||
overlay.appendChild(preview)
|
||||
document.body.appendChild(overlay)
|
||||
closeButton.focus()
|
||||
|
||||
return close
|
||||
}
|
||||
|
||||
function getPointerDistance(pointers: Map<number, { x: number, y: number }>): number {
|
||||
|
|
|
|||
|
|
@ -30,14 +30,14 @@ describe('sanitizeSvg', () => {
|
|||
<use href="https://example.com/sprite.svg#x" />
|
||||
<rect class="safe" style="fill: url(#safe); background-image: url(https://example.com/bad.png)" />
|
||||
</svg>
|
||||
`)
|
||||
`, 'diagram')
|
||||
|
||||
expect(svg.querySelector('image')).toBeNull()
|
||||
expect(svg.querySelector('use')?.getAttribute('href')).toBeNull()
|
||||
expect(svg.querySelector('style')?.textContent).not.toContain('@import')
|
||||
expect(svg.querySelector('style')?.textContent).toContain('.safe { fill: url(#safe); stroke: #fff }')
|
||||
expect(svg.querySelector('style')?.textContent).toContain('#diagram .safe { fill: url(#diagram-safe); stroke: #fff }')
|
||||
expect(svg.querySelector('style')?.textContent).not.toContain('https://example.com')
|
||||
expect(svg.querySelector('rect')?.getAttribute('style')).toBe('fill: url(#safe)')
|
||||
expect(svg.querySelector('rect')?.getAttribute('style')).toBe('fill: url(#diagram-safe)')
|
||||
})
|
||||
|
||||
test('normalizes percent dimensions to width-only responsive SVG', () => {
|
||||
|
|
@ -76,7 +76,55 @@ describe('sanitizeSvg', () => {
|
|||
expect(svg.querySelector('rect.actor')?.getAttribute('fill')).toBe('url(#diagram-1-actor-bg)')
|
||||
expect(svg.querySelector('rect.actor')?.getAttribute('clip-path')).toBe('url(#diagram-1-clip)')
|
||||
expect(svg.querySelector('use')?.getAttribute('href')).toBe('#diagram-1-clip')
|
||||
expect(svg.querySelector('style')?.textContent).toContain('url(#diagram-1-actor-bg)')
|
||||
expect(svg.querySelector('style')?.textContent).toContain('#diagram-1 .actor { fill: url(#diagram-1-actor-bg) }')
|
||||
})
|
||||
|
||||
test('keeps stylesheet rules scoped to the root SVG id', () => {
|
||||
const svg = sanitizeSvg(`
|
||||
<svg id="container" viewBox="0 0 10 10">
|
||||
<style>
|
||||
#container { fill: #111; }
|
||||
.node rect, text.label { stroke: #222; }
|
||||
p { margin: 0; }
|
||||
@keyframes dash { to { stroke-dashoffset: 0; } }
|
||||
body { color: red; }
|
||||
</style>
|
||||
<g class="node"><rect /></g>
|
||||
<text class="label">A</text>
|
||||
</svg>
|
||||
`, 'diagram')
|
||||
|
||||
const style = svg.querySelector('style')?.textContent
|
||||
expect(svg.getAttribute('id')).toBe('diagram-container')
|
||||
expect(style).toContain('#diagram-container { fill: #111 }')
|
||||
expect(style).toContain('#diagram-container .node rect, #diagram-container text.label { stroke: #222 }')
|
||||
expect(style).toContain('#diagram-container p { margin: 0 }')
|
||||
expect(style).toContain('#diagram-container body { color: red }')
|
||||
expect(style).not.toContain('keyframes')
|
||||
expect(style).not.toContain('to { stroke-dashoffset')
|
||||
})
|
||||
|
||||
test('preserves sanitized Mermaid foreignObject labels', () => {
|
||||
const svg = sanitizeSvg(`
|
||||
<svg id="container" viewBox="0 0 204 70">
|
||||
<g class="node default">
|
||||
<foreignObject width="9.171875" height="24">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" onclick="bad()" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center; background-image: url(https://example.com/bad.png);">
|
||||
<span class="nodeLabel"><p>A</p></span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
</svg>
|
||||
`, 'diagram')
|
||||
|
||||
const foreignObject = Array.from(svg.querySelectorAll('*')).find(element => element.localName === 'foreignObject')
|
||||
const label = svg.querySelector('.nodeLabel')
|
||||
const labelContainer = svg.querySelector('div')
|
||||
|
||||
expect(foreignObject).not.toBeNull()
|
||||
expect(label?.textContent).toBe('A')
|
||||
expect(labelContainer?.getAttribute('onclick')).toBeNull()
|
||||
expect(labelContainer?.getAttribute('style')).toBe('display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center')
|
||||
})
|
||||
|
||||
test('rejects invalid SVG', () => {
|
||||
|
|
|
|||
116
src/svg.ts
116
src/svg.ts
|
|
@ -1,4 +1,5 @@
|
|||
const SIZING_STYLE_PROPERTIES = ['width', 'height', 'max-width', 'max-height', 'min-width', 'min-height']
|
||||
const CSS_SELECTOR_SEPARATOR_PATTERN = /,(?![^\[]*\])/
|
||||
|
||||
const ALLOWED_ELEMENTS = new Set([
|
||||
'svg',
|
||||
|
|
@ -16,6 +17,11 @@ const ALLOWED_ELEMENTS = new Set([
|
|||
'text',
|
||||
'tspan',
|
||||
'textpath',
|
||||
'foreignobject',
|
||||
'div',
|
||||
'span',
|
||||
'p',
|
||||
'br',
|
||||
'use',
|
||||
'marker',
|
||||
'clippath',
|
||||
|
|
@ -63,7 +69,8 @@ const GLOBAL_ATTRIBUTES = new Set([
|
|||
'marker-end',
|
||||
'color',
|
||||
'display',
|
||||
'visibility'
|
||||
'visibility',
|
||||
'white-space'
|
||||
])
|
||||
const ELEMENT_ATTRIBUTES = new Map([
|
||||
['svg', ['viewbox', 'width', 'height', 'x', 'y', 'preserveaspectratio', 'version', 'xmlns']],
|
||||
|
|
@ -77,6 +84,7 @@ const ELEMENT_ATTRIBUTES = new Map([
|
|||
['text', ['x', 'y', 'dx', 'dy', 'rotate', 'textlength', 'lengthadjust', 'text-anchor', 'dominant-baseline', 'alignment-baseline', 'font-family', 'font-size', 'font-weight', 'font-style', 'font-stretch', 'letter-spacing', 'word-spacing']],
|
||||
['tspan', ['x', 'y', 'dx', 'dy', 'rotate', 'textlength', 'lengthadjust', 'text-anchor', 'dominant-baseline', 'alignment-baseline', 'font-family', 'font-size', 'font-weight', 'font-style', 'font-stretch', 'letter-spacing', 'word-spacing']],
|
||||
['textpath', ['href', 'xlink:href', 'startoffset', 'method', 'spacing']],
|
||||
['foreignobject', ['x', 'y', 'width', 'height']],
|
||||
['use', ['href', 'xlink:href', 'x', 'y', 'width', 'height']],
|
||||
['marker', ['refx', 'refy', 'markerwidth', 'markerheight', 'markerunits', 'orient', 'viewbox', 'preserveaspectratio']],
|
||||
['clippath', ['clippathunits']],
|
||||
|
|
@ -100,6 +108,10 @@ const ELEMENT_ATTRIBUTES = new Map([
|
|||
const URL_ATTRIBUTES = new Set(['href', 'xlink:href'])
|
||||
const CSS_PROPERTIES = new Set([
|
||||
'alignment-baseline',
|
||||
'background',
|
||||
'background-color',
|
||||
'border',
|
||||
'border-radius',
|
||||
'clip-path',
|
||||
'clip-rule',
|
||||
'color',
|
||||
|
|
@ -116,11 +128,21 @@ const CSS_PROPERTIES = new Set([
|
|||
'font-style',
|
||||
'font-weight',
|
||||
'letter-spacing',
|
||||
'line-height',
|
||||
'margin',
|
||||
'margin-bottom',
|
||||
'margin-left',
|
||||
'margin-right',
|
||||
'margin-top',
|
||||
'marker-end',
|
||||
'marker-mid',
|
||||
'marker-start',
|
||||
'mask',
|
||||
'opacity',
|
||||
'overflow',
|
||||
'padding',
|
||||
'pointer-events',
|
||||
'position',
|
||||
'stop-color',
|
||||
'stop-opacity',
|
||||
'stroke',
|
||||
|
|
@ -132,8 +154,12 @@ const CSS_PROPERTIES = new Set([
|
|||
'stroke-opacity',
|
||||
'stroke-width',
|
||||
'text-anchor',
|
||||
'text-align',
|
||||
'vertical-align',
|
||||
'visibility',
|
||||
'white-space',
|
||||
'word-spacing',
|
||||
'z-index',
|
||||
...SIZING_STYLE_PROPERTIES
|
||||
])
|
||||
|
||||
|
|
@ -154,10 +180,6 @@ export function sanitizeSvg(svg: string, idScope?: string): SVGElement {
|
|||
return
|
||||
}
|
||||
|
||||
if (elementName === 'style') {
|
||||
element.textContent = sanitizeStyleSheet(element.textContent ?? '')
|
||||
}
|
||||
|
||||
for (const attribute of Array.from(element.attributes)) {
|
||||
const name = attribute.name.toLowerCase()
|
||||
const value = attribute.value.trim()
|
||||
|
|
@ -192,6 +214,7 @@ export function sanitizeSvg(svg: string, idScope?: string): SVGElement {
|
|||
if (idScope) {
|
||||
scopeSvgIds(normalizedSvg, idScope)
|
||||
}
|
||||
scopeStyleElementsToRoot(normalizedSvg, idScope)
|
||||
normalizeSvgLayout(normalizedSvg)
|
||||
return normalizedSvg
|
||||
}
|
||||
|
|
@ -199,6 +222,7 @@ export function sanitizeSvg(svg: string, idScope?: string): SVGElement {
|
|||
export function cloneSanitizedSvgWithIdScope(svgElement: SVGElement, idScope: string): SVGElement {
|
||||
const clone = svgElement.cloneNode(true) as SVGElement
|
||||
scopeSvgIds(clone, idScope)
|
||||
scopeStyleElementsToRoot(clone, idScope)
|
||||
return clone
|
||||
}
|
||||
|
||||
|
|
@ -230,24 +254,53 @@ function hasSafeAttributeValue(value: string): boolean {
|
|||
return hasOnlySafeCssUrls(value)
|
||||
}
|
||||
|
||||
function sanitizeStyleSheet(styleSheet: string): string {
|
||||
function sanitizeStyleSheet(styleSheet: string, rootSelector: string): string {
|
||||
const rules = []
|
||||
const styleSheetWithoutImports = styleSheet.replace(/@import[^;]+;/gi, '')
|
||||
const styleSheetWithoutImports = stripCssAtRules(styleSheet.replace(/@import[^;]+;/gi, ''))
|
||||
const rulePattern = /([^{}]+)\{([^{}]*)\}/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = rulePattern.exec(styleSheetWithoutImports)) !== null) {
|
||||
const selector = match[1]?.trim()
|
||||
const declarations = sanitizeStyleDeclarations(match[2] ?? '')
|
||||
if (selector && declarations && isSafeCssSelector(selector)) {
|
||||
rules.push(`${selector} { ${declarations} }`)
|
||||
const scopedSelector = selector ? scopeCssSelector(selector, rootSelector) : null
|
||||
if (scopedSelector && declarations) {
|
||||
rules.push(`${scopedSelector} { ${declarations} }`)
|
||||
}
|
||||
}
|
||||
|
||||
return rules.join('\n')
|
||||
}
|
||||
|
||||
function isSafeCssSelector(selector: string): boolean {
|
||||
return !selector.includes('@') && /^[a-zA-Z0-9\s.#:[\],>+~*="'()_-]+$/.test(selector)
|
||||
function stripCssAtRules(styleSheet: string): string {
|
||||
return styleSheet.replace(/@[\w-]+\s+[^{]*\{(?:[^{}]|\{[^{}]*\})*\}/g, '')
|
||||
}
|
||||
|
||||
function scopeCssSelector(selector: string, rootSelector: string): string | null {
|
||||
if (selector.includes('@')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const scopedSelectors = selector
|
||||
.split(CSS_SELECTOR_SEPARATOR_PATTERN)
|
||||
.map(part => part.trim())
|
||||
.filter(Boolean)
|
||||
.map(part => scopeSingleCssSelector(part, rootSelector))
|
||||
.filter((part): part is string => Boolean(part))
|
||||
|
||||
return scopedSelectors.length > 0 ? scopedSelectors.join(', ') : null
|
||||
}
|
||||
|
||||
function scopeSingleCssSelector(selector: string, rootSelector: string): string | null {
|
||||
if (!/^[a-zA-Z0-9\s.#:[\],>+~*="'()_-]+$/.test(selector)) {
|
||||
return null
|
||||
}
|
||||
if (selector === ':root') {
|
||||
return rootSelector
|
||||
}
|
||||
if (selector.startsWith(rootSelector)) {
|
||||
return selector
|
||||
}
|
||||
return `${rootSelector} ${selector}`
|
||||
}
|
||||
|
||||
function sanitizeStyleDeclarations(style: string): string {
|
||||
|
|
@ -333,6 +386,7 @@ function rewriteSvgIdReferences(value: string, scopedIds: Map<string, string>):
|
|||
scopedIds.forEach((scopedId, id) => {
|
||||
const escapedId = escapeRegExp(id)
|
||||
rewrittenValue = rewrittenValue.replace(new RegExp(`url\\(\\s*(["']?)#${escapedId}\\1\\s*\\)`, 'g'), `url(#${scopedId})`)
|
||||
rewrittenValue = rewrittenValue.replace(new RegExp(`#${escapedId}(?![a-zA-Z0-9_-])`, 'g'), `#${scopedId}`)
|
||||
if (rewrittenValue === `#${id}`) {
|
||||
rewrittenValue = `#${scopedId}`
|
||||
}
|
||||
|
|
@ -354,6 +408,46 @@ function normalizeSvgLayout(svgElement: SVGElement): void {
|
|||
svgElement.setAttribute('focusable', 'false')
|
||||
}
|
||||
|
||||
let nextGeneratedSvgScopeId = 0
|
||||
|
||||
function scopeStyleElementsToRoot(svgElement: SVGElement, idScope?: string): void {
|
||||
const styleElements = Array.from(svgElement.querySelectorAll('style'))
|
||||
if (styleElements.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const rootSelector = `#${ensureRootSvgId(svgElement, idScope)}`
|
||||
styleElements.forEach(element => {
|
||||
const sanitizedStyleSheet = sanitizeStyleSheet(element.textContent ?? '', rootSelector)
|
||||
if (sanitizedStyleSheet) {
|
||||
element.textContent = sanitizedStyleSheet
|
||||
} else {
|
||||
element.remove()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function ensureRootSvgId(svgElement: SVGElement, idScope?: string): string {
|
||||
const existingId = svgElement.getAttribute('id')
|
||||
if (existingId) {
|
||||
return existingId
|
||||
}
|
||||
|
||||
const nextId = idScope ? sanitizeId(idScope) : createGeneratedSvgScopeId()
|
||||
svgElement.setAttribute('id', nextId)
|
||||
return nextId
|
||||
}
|
||||
|
||||
function createGeneratedSvgScopeId(): string {
|
||||
nextGeneratedSvgScopeId += 1
|
||||
return `pumler-svg-scope-${nextGeneratedSvgScopeId}`
|
||||
}
|
||||
|
||||
function sanitizeId(value: string): string {
|
||||
const sanitized = value.replace(/[^a-zA-Z0-9_-]/g, '-')
|
||||
return sanitized || createGeneratedSvgScopeId()
|
||||
}
|
||||
|
||||
function ensureViewBox(svgElement: SVGElement): void {
|
||||
if (svgElement.getAttribute('viewBox')) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"0.1.0": "1.0.0"
|
||||
"1.0.0": "1.0.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue