diff --git a/.gitignore b/.gitignore index 3b97664..8a8ab27 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ node_modules/ .idea +.qodo +.DS_Store +main.js +cache/ diff --git a/src/cache.ts b/src/cache.ts index 6085a87..edb763a 100644 --- a/src/cache.ts +++ b/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 { - 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 { - 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(operation: () => Promise): Promise { @@ -96,7 +58,7 @@ export class PumlerDiskSvgCache implements PumlerSvgCache { return result } - private async readEntry(options: RenderDiagramOptions, cacheSeedDigest: string): Promise { + private async readEntry(options: RenderDiagramOptions): Promise { 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 { + private async writeEntry(options: RenderDiagramOptions, svg: string): Promise { 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 { @@ -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 { @@ -209,16 +154,6 @@ export class PumlerDiskSvgCache implements PumlerSvgCache { } } -function createCacheLogData(options: RenderDiagramOptions, cacheSeedDigest: string): Record { - return { - provider: options.provider, - type: options.type, - theme: options.theme, - sourceLength: options.source.length, - cacheSeedDigest - } -} - function normalizeVaultPath(path: string): string { return path .replace(/\\/g, '/') diff --git a/src/client.ts b/src/client.ts index 4fc44f7..00086ca 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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 { 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 } } diff --git a/src/logger.ts b/src/logger.ts deleted file mode 100644 index 9dd6cb8..0000000 --- a/src/logger.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { DataAdapter } from 'obsidian' - -type PumlerLogLevel = 'debug' | 'info' | 'warn' | 'error' - -export type PumlerLogData = Record - -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 = 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 { - if (!this.logDir || await this.adapter.exists(this.logDir)) { - return - } - - await this.adapter.mkdir(this.logDir) - } - - private async ensureLogFile(): Promise { - 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 -} diff --git a/src/main.ts b/src/main.ts index 1eac8e3..f7d9308 100644 --- a/src/main.ts +++ b/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 { - 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 { - 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 { - return { - activeLeaf: describeLeaf(app.workspace.activeLeaf), - markdownLeafCount: app.workspace.getLeavesOfType('markdown').length - } -} - -function describeLeaf(leaf: WorkspaceLeaf | null): Record | 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 - } -} diff --git a/src/renderer.test.ts b/src/renderer.test.ts index 335b459..8265d20 100644 --- a/src/renderer.test.ts +++ b/src/renderer.test.ts @@ -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 () => '')) + const element = document.createElement('div') + const cleanups: Array<() => void> = [] + + await renderer.render(validBlock(), element, { + debounceMs: 0, + registerModalCleanup: cleanup => cleanups.push(cleanup) + }) + element.querySelector('.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 () => '')) const element = document.createElement('div') diff --git a/src/renderer.ts b/src/renderer.ts index d91092e..399aaf7 100644 --- a/src/renderer.ts +++ b/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 { - 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 @@ -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 { 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 { 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 { - return { - provider: options.provider, - type: options.type, - theme: options.theme, - sourceLength: options.source.length, - sourceDigest: createLogDigest(options.source), - renderDigest - } -} - const elementDebounceKeys = new WeakMap() 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 { diff --git a/src/svg.test.ts b/src/svg.test.ts index 1608c80..72318ea 100644 --- a/src/svg.test.ts +++ b/src/svg.test.ts @@ -30,14 +30,14 @@ describe('sanitizeSvg', () => { - `) + `, '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(` + + + + A + + `, '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(` + + + +
+

A

+
+
+
+
+ `, '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', () => { diff --git a/src/svg.ts b/src/svg.ts index 79a968a..f70568a 100644 --- a/src/svg.ts +++ b/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): 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 diff --git a/versions.json b/versions.json index a9471ba..608a97a 100644 --- a/versions.json +++ b/versions.json @@ -1,3 +1,3 @@ { - "0.1.0": "1.0.0" + "1.0.0": "1.0.0" }