Fix Obsidian release review issues

This commit is contained in:
Alex 2026-05-01 18:17:34 +02:00
parent a33b20dddb
commit 489b85bfd5
10 changed files with 245 additions and 549 deletions

4
.gitignore vendored
View file

@ -1,3 +1,7 @@
node_modules/
.idea
.qodo
.DS_Store
main.js
cache/

View file

@ -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, '/')

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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')

View file

@ -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 {

View file

@ -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', () => {

View file

@ -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

View file

@ -1,3 +1,3 @@
{
"0.1.0": "1.0.0"
"1.0.0": "1.0.0"
}