@file-viewer/renderer-text 2.4.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/largeText.js CHANGED
@@ -8,6 +8,7 @@ const LARGE_TEXT_INDEX_YIELD_BYTES = 4 * 1024 * 1024;
8
8
  const LARGE_TEXT_SEARCH_CHUNK_BYTES = 256 * 1024;
9
9
  const LARGE_TEXT_MAX_SCROLL_HEIGHT = 8000000;
10
10
  const LARGE_TEXT_BASE_LINE_HEIGHT = 22.1;
11
+ const LARGE_TEXT_MEASUREMENT_BLOCK_LINES = 256;
11
12
  const clamp = (value, minimum, maximum) => {
12
13
  return Number.isFinite(value)
13
14
  ? Math.max(minimum, Math.min(maximum, value))
@@ -234,8 +235,6 @@ export const shouldVirtualizeMarkdownBuffer = (buffer, context) => {
234
235
  const largeTextStyle = `
235
236
  .code-viewer--virtual{height:100%;min-height:240px;display:flex;flex-direction:column;overflow:hidden}
236
237
  .code-viewer--virtual .code-toolbar{flex:0 0 42px}
237
- .code-toolbar-meta{display:inline-flex;min-width:0;align-items:center;justify-content:flex-end;gap:10px;white-space:nowrap}
238
- .code-toolbar-meta span{overflow:hidden;text-overflow:ellipsis}
239
238
  .code-virtual-scroll{position:relative;flex:1 1 auto;min-width:0;min-height:0;overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable;contain:strict;background:var(--code-bg)}
240
239
  .code-virtual-spacer{position:relative;min-width:100%}
241
240
  .code-virtual-window{position:absolute;top:0;left:0;min-width:100%;will-change:transform}
@@ -248,9 +247,15 @@ const largeTextStyle = `
248
247
  .code-line-segments button{width:22px;height:18px;padding:0;border:1px solid var(--code-border);border-radius:4px;background:var(--code-bg);color:var(--code-muted);font:700 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;cursor:pointer}
249
248
  .code-line-segments button:disabled{cursor:not-allowed;opacity:.4}
250
249
  .code-line-segments span{min-width:64px;color:var(--code-muted);font-size:11px;line-height:1;text-align:center}
250
+ .code-viewer--virtual.code-viewer--wrap-lines .code-virtual-scroll{overflow-x:hidden}
251
+ .code-viewer--virtual.code-viewer--wrap-lines .code-virtual-spacer,.code-viewer--virtual.code-viewer--wrap-lines .code-virtual-window{width:100%;min-width:0}
252
+ .code-viewer--virtual.code-viewer--wrap-lines .code-virtual-line{width:100%;height:auto;min-height:var(--code-line-height,22.1px);min-width:0;align-items:flex-start;white-space:normal;contain:layout paint style}
253
+ .code-viewer--virtual.code-viewer--wrap-lines .code-virtual-number{align-self:stretch}
254
+ .code-viewer--virtual.code-viewer--wrap-lines .code-line-segments{flex:0 0 auto;align-self:stretch;height:auto}
255
+ .code-viewer--virtual.code-viewer--wrap-lines .code-virtual-content{display:block;min-width:0;flex:1 1 auto;padding:0 18px;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}
251
256
  `;
252
257
  export default async function renderLargeText(buffer, target, type = 'txt', context) {
253
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
258
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
254
259
  const t = createFileViewerTranslator(context === null || context === void 0 ? void 0 : context.options);
255
260
  const documentRef = target.ownerDocument;
256
261
  const sourceBytes = new Uint8Array(buffer);
@@ -268,9 +273,11 @@ export default async function renderLargeText(buffer, target, type = 'txt', cont
268
273
  // Undefined preserves the large-text renderer's pre-option behavior. An
269
274
  // explicit boolean has the same meaning in both regular and virtual views.
270
275
  const showLineNumbers = ((_k = (_j = context === null || context === void 0 ? void 0 : context.options) === null || _j === void 0 ? void 0 : _j.text) === null || _k === void 0 ? void 0 : _k.lineNumbers) !== false;
276
+ const wrapLongLines = ((_m = (_l = context === null || context === void 0 ? void 0 : context.options) === null || _l === void 0 ? void 0 : _l.text) === null || _m === void 0 ? void 0 : _m.wrapLongLines) === true;
271
277
  let disposed = false;
272
278
  let zoom = 1;
273
279
  let scheduledFrame = 0;
280
+ let measurementFrame = 0;
274
281
  let lastWindowStart = -1;
275
282
  let activeLine = -1;
276
283
  let searchGeneration = 0;
@@ -279,13 +286,17 @@ export default async function renderLargeText(buffer, target, type = 'txt', cont
279
286
  const style = documentRef.createElement('style');
280
287
  style.textContent = `${codeStyle}\n${largeTextStyle}`;
281
288
  const root = documentRef.createElement('div');
282
- root.className = showLineNumbers
283
- ? 'code-viewer code-viewer--virtual code-viewer--line-numbers'
284
- : 'code-viewer code-viewer--virtual';
289
+ root.className = [
290
+ 'code-viewer',
291
+ 'code-viewer--virtual',
292
+ showLineNumbers ? 'code-viewer--line-numbers' : '',
293
+ wrapLongLines ? 'code-viewer--wrap-lines' : ''
294
+ ].filter(Boolean).join(' ');
285
295
  root.dataset.viewerZoomProvider = 'code';
286
296
  root.dataset.viewerSearchProvider = 'code-virtual';
287
297
  root.dataset.textToolbar = String(showToolbar);
288
298
  root.dataset.lineNumbers = String(showLineNumbers);
299
+ root.dataset.wrapLongLines = String(wrapLongLines);
289
300
  root.dataset.textEncoding = source.encoding;
290
301
  const toolbar = documentRef.createElement('div');
291
302
  toolbar.className = 'code-toolbar';
@@ -302,7 +313,7 @@ export default async function renderLargeText(buffer, target, type = 'txt', cont
302
313
  root.append(toolbar);
303
314
  }
304
315
  target.replaceChildren(style, root);
305
- (_l = context === null || context === void 0 ? void 0 : context.onProgressiveRender) === null || _l === void 0 ? void 0 : _l.call(context);
316
+ (_o = context === null || context === void 0 ? void 0 : context.onProgressiveRender) === null || _o === void 0 ? void 0 : _o.call(context);
306
317
  const index = await buildLargeTextIndex(bytes, source.encoding, target, progress => {
307
318
  if (!disposed) {
308
319
  status.textContent = t('text.code.indexingLargeFile', { progress });
@@ -329,29 +340,154 @@ export default async function renderLargeText(buffer, target, type = 'txt', cont
329
340
  root.append(viewport);
330
341
  const getLineHeight = () => LARGE_TEXT_BASE_LINE_HEIGHT * zoom;
331
342
  const getViewportHeight = () => Math.max(240, viewport.clientHeight || 600);
343
+ const measuredLineHeights = new Map();
344
+ const measuredLineHeightsByBlock = new Map();
345
+ const heightCorrectionTree = new Float64Array(Math.ceil(index.lineCount / LARGE_TEXT_MEASUREMENT_BLOCK_LINES) + 1);
346
+ const updateHeightCorrection = (blockIndex, delta) => {
347
+ for (let treeIndex = blockIndex + 1; treeIndex < heightCorrectionTree.length; treeIndex += treeIndex & -treeIndex) {
348
+ heightCorrectionTree[treeIndex] += delta;
349
+ }
350
+ };
351
+ const getHeightCorrectionBeforeBlock = (blockIndex) => {
352
+ let correction = 0;
353
+ for (let treeIndex = blockIndex; treeIndex > 0; treeIndex -= treeIndex & -treeIndex) {
354
+ correction += heightCorrectionTree[treeIndex];
355
+ }
356
+ return correction;
357
+ };
358
+ const getOffsetForLine = (requestedLine) => {
359
+ const lineIndex = clamp(Math.trunc(requestedLine), 0, index.lineCount);
360
+ if (!wrapLongLines || lineIndex === 0) {
361
+ return lineIndex * getLineHeight();
362
+ }
363
+ const blockIndex = Math.floor(lineIndex / LARGE_TEXT_MEASUREMENT_BLOCK_LINES);
364
+ let correction = getHeightCorrectionBeforeBlock(blockIndex);
365
+ const blockMeasurements = measuredLineHeightsByBlock.get(blockIndex);
366
+ if (blockMeasurements) {
367
+ for (const [measuredLine, height] of blockMeasurements) {
368
+ if (measuredLine >= lineIndex) {
369
+ continue;
370
+ }
371
+ correction += height - getLineHeight();
372
+ }
373
+ }
374
+ return (lineIndex * getLineHeight()) + correction;
375
+ };
376
+ const getTotalContentHeight = () => getOffsetForLine(index.lineCount);
332
377
  const getWindowLineCount = () => Math.min(index.lineCount, Math.ceil(getViewportHeight() / getLineHeight()) + (overscan * 2) + 2);
333
- const getSpacerHeight = () => Math.min(LARGE_TEXT_MAX_SCROLL_HEIGHT, Math.max(getViewportHeight(), index.lineCount * getLineHeight()));
334
- const usesCappedScrollHeight = () => index.lineCount * getLineHeight() > LARGE_TEXT_MAX_SCROLL_HEIGHT;
378
+ const getSpacerHeight = () => Math.min(LARGE_TEXT_MAX_SCROLL_HEIGHT, Math.max(getViewportHeight(), getTotalContentHeight()));
379
+ const usesCappedScrollHeight = () => getTotalContentHeight() > LARGE_TEXT_MAX_SCROLL_HEIGHT;
335
380
  const updateSpacerHeight = () => {
336
381
  root.style.setProperty('--code-font-size', `${13 * zoom}px`);
337
382
  root.style.setProperty('--code-line-height', `${getLineHeight()}px`);
338
383
  spacer.style.height = `${getSpacerHeight()}px`;
339
384
  };
385
+ const getLineAtOffset = (requestedOffset) => {
386
+ const offset = clamp(requestedOffset, 0, Math.max(0, getTotalContentHeight() - 1));
387
+ let low = 0;
388
+ let high = Math.max(0, index.lineCount - 1);
389
+ while (low < high) {
390
+ const middle = Math.ceil((low + high) / 2);
391
+ if (getOffsetForLine(middle) <= offset) {
392
+ low = middle;
393
+ }
394
+ else {
395
+ high = middle - 1;
396
+ }
397
+ }
398
+ return low;
399
+ };
340
400
  const getFirstVisibleLine = () => {
341
401
  if (!usesCappedScrollHeight()) {
342
- return clamp(Math.floor(viewport.scrollTop / getLineHeight()), 0, index.lineCount - 1);
402
+ return wrapLongLines
403
+ ? getLineAtOffset(viewport.scrollTop)
404
+ : clamp(Math.floor(viewport.scrollTop / getLineHeight()), 0, index.lineCount - 1);
343
405
  }
344
406
  const maxScrollTop = Math.max(1, getSpacerHeight() - getViewportHeight());
345
407
  return clamp(Math.round((viewport.scrollTop / maxScrollTop) * (index.lineCount - 1)), 0, index.lineCount - 1);
346
408
  };
347
409
  const getWindowOffset = (startLine, renderedLineCount) => {
348
410
  if (!usesCappedScrollHeight()) {
349
- return startLine * getLineHeight();
411
+ return getOffsetForLine(startLine);
350
412
  }
351
413
  const maxStart = Math.max(1, index.lineCount - renderedLineCount);
352
414
  const maxOffset = Math.max(0, getSpacerHeight() - (renderedLineCount * getLineHeight()));
353
415
  return (startLine / maxStart) * maxOffset;
354
416
  };
417
+ const setMeasuredLineHeight = (lineIndex, measuredHeight) => {
418
+ var _a;
419
+ const nextHeight = Math.max(getLineHeight(), measuredHeight);
420
+ const previousHeight = (_a = measuredLineHeights.get(lineIndex)) !== null && _a !== void 0 ? _a : getLineHeight();
421
+ if (Math.abs(nextHeight - previousHeight) < 0.5) {
422
+ return false;
423
+ }
424
+ const blockIndex = Math.floor(lineIndex / LARGE_TEXT_MEASUREMENT_BLOCK_LINES);
425
+ measuredLineHeights.set(lineIndex, nextHeight);
426
+ let blockMeasurements = measuredLineHeightsByBlock.get(blockIndex);
427
+ if (!blockMeasurements) {
428
+ blockMeasurements = new Map();
429
+ measuredLineHeightsByBlock.set(blockIndex, blockMeasurements);
430
+ }
431
+ blockMeasurements.set(lineIndex, nextHeight);
432
+ updateHeightCorrection(blockIndex, nextHeight - previousHeight);
433
+ return true;
434
+ };
435
+ const clearMeasuredLineHeights = () => {
436
+ measuredLineHeights.clear();
437
+ measuredLineHeightsByBlock.clear();
438
+ heightCorrectionTree.fill(0);
439
+ };
440
+ const scheduleWrappedMeasurement = (startLine, renderedLineCount) => {
441
+ var _a, _b, _c;
442
+ if (!wrapLongLines || disposed || renderedLineCount === 0) {
443
+ return;
444
+ }
445
+ const view = getWindow(target);
446
+ if (measurementFrame && (view === null || view === void 0 ? void 0 : view.cancelAnimationFrame)) {
447
+ view.cancelAnimationFrame(measurementFrame);
448
+ }
449
+ else if (measurementFrame) {
450
+ (_a = view === null || view === void 0 ? void 0 : view.clearTimeout) === null || _a === void 0 ? void 0 : _a.call(view, measurementFrame);
451
+ }
452
+ const measure = () => {
453
+ measurementFrame = 0;
454
+ if (disposed) {
455
+ return;
456
+ }
457
+ const anchorLine = getFirstVisibleLine();
458
+ const anchorOffset = usesCappedScrollHeight()
459
+ ? 0
460
+ : viewport.scrollTop - getOffsetForLine(anchorLine);
461
+ let changed = false;
462
+ const rows = Array.from(windowElement.querySelectorAll('.code-virtual-line'));
463
+ for (const row of rows) {
464
+ const lineIndex = Number(row.dataset.line) - 1;
465
+ if (!Number.isInteger(lineIndex) || lineIndex < 0) {
466
+ continue;
467
+ }
468
+ const measuredHeight = row.getBoundingClientRect().height || row.offsetHeight || 0;
469
+ if (measuredHeight) {
470
+ changed = setMeasuredLineHeight(lineIndex, measuredHeight) || changed;
471
+ }
472
+ }
473
+ if (!changed) {
474
+ return;
475
+ }
476
+ updateSpacerHeight();
477
+ if (!usesCappedScrollHeight()) {
478
+ viewport.scrollTop = getOffsetForLine(anchorLine) + anchorOffset;
479
+ }
480
+ windowElement.style.transform = `translateY(${getWindowOffset(startLine, renderedLineCount)}px)`;
481
+ lastWindowStart = -1;
482
+ scheduleRender();
483
+ };
484
+ if (view === null || view === void 0 ? void 0 : view.requestAnimationFrame) {
485
+ measurementFrame = view.requestAnimationFrame(measure);
486
+ }
487
+ else {
488
+ measurementFrame = Number((_c = (_b = view === null || view === void 0 ? void 0 : view.setTimeout) === null || _b === void 0 ? void 0 : _b.call(view, measure, 0)) !== null && _c !== void 0 ? _c : setTimeout(measure, 0));
489
+ }
490
+ };
355
491
  const appendHighlightedContent = (content, text, query) => {
356
492
  if (!query) {
357
493
  content.textContent = text || ' ';
@@ -383,6 +519,7 @@ export default async function renderLargeText(buffer, target, type = 'txt', cont
383
519
  const row = documentRef.createElement('div');
384
520
  row.className = 'code-virtual-line';
385
521
  row.dataset.line = String(line.lineIndex + 1);
522
+ row.dataset.logicalLine = String(line.lineIndex + 1);
386
523
  if (line.lineIndex === activeLine) {
387
524
  row.classList.add('code-virtual-line--match');
388
525
  }
@@ -439,6 +576,7 @@ export default async function renderLargeText(buffer, target, type = 'txt', cont
439
576
  }
440
577
  windowElement.replaceChildren(fragment);
441
578
  windowElement.style.transform = `translateY(${getWindowOffset(startLine, lines.length)}px)`;
579
+ scheduleWrappedMeasurement(startLine, lines.length);
442
580
  };
443
581
  const scheduleRender = () => {
444
582
  var _a, _b;
@@ -470,7 +608,7 @@ export default async function renderLargeText(buffer, target, type = 'txt', cont
470
608
  : 0;
471
609
  }
472
610
  else {
473
- viewport.scrollTop = lineIndex * getLineHeight();
611
+ viewport.scrollTop = getOffsetForLine(lineIndex);
474
612
  }
475
613
  lastWindowStart = -1;
476
614
  renderWindow(true);
@@ -592,6 +730,9 @@ export default async function renderLargeText(buffer, target, type = 'txt', cont
592
730
  const setZoom = (scale) => {
593
731
  const firstVisibleLine = getFirstVisibleLine();
594
732
  zoom = clampZoom(scale);
733
+ if (wrapLongLines) {
734
+ clearMeasuredLineHeights();
735
+ }
595
736
  updateSpacerHeight();
596
737
  scrollToLine(firstVisibleLine);
597
738
  zoomEmitter.emit();
@@ -612,7 +753,7 @@ export default async function renderLargeText(buffer, target, type = 'txt', cont
612
753
  getState: getZoomState,
613
754
  subscribe: zoomEmitter.subscribe
614
755
  });
615
- (_m = context === null || context === void 0 ? void 0 : context.registerExportAdapter) === null || _m === void 0 ? void 0 : _m.call(context, { print: false, exportHtml: false });
756
+ (_p = context === null || context === void 0 ? void 0 : context.registerExportAdapter) === null || _p === void 0 ? void 0 : _p.call(context, { print: false, exportHtml: false });
616
757
  viewport.addEventListener('scroll', scheduleRender, { passive: true });
617
758
  viewport.addEventListener('click', event => {
618
759
  var _a, _b;
@@ -638,9 +779,16 @@ export default async function renderLargeText(buffer, target, type = 'txt', cont
638
779
  lineSegments.set(lineIndex, clamp(next, 0, segmentCount - 1));
639
780
  renderWindow(true);
640
781
  });
641
- const ResizeObserverCtor = (_o = getWindow(target)) === null || _o === void 0 ? void 0 : _o.ResizeObserver;
782
+ const ResizeObserverCtor = (_q = getWindow(target)) === null || _q === void 0 ? void 0 : _q.ResizeObserver;
642
783
  const resizeObserver = ResizeObserverCtor
643
784
  ? new ResizeObserverCtor(() => {
785
+ if (wrapLongLines) {
786
+ const firstVisibleLine = getFirstVisibleLine();
787
+ clearMeasuredLineHeights();
788
+ updateSpacerHeight();
789
+ scrollToLine(firstVisibleLine);
790
+ return;
791
+ }
644
792
  updateSpacerHeight();
645
793
  renderWindow(true);
646
794
  })
@@ -651,7 +799,7 @@ export default async function renderLargeText(buffer, target, type = 'txt', cont
651
799
  return {
652
800
  $el: target,
653
801
  unmount() {
654
- var _a, _b;
802
+ var _a, _b, _c;
655
803
  disposed = true;
656
804
  searchGeneration += 1;
657
805
  const view = getWindow(target);
@@ -661,11 +809,17 @@ export default async function renderLargeText(buffer, target, type = 'txt', cont
661
809
  else if (scheduledFrame) {
662
810
  (_a = view === null || view === void 0 ? void 0 : view.clearTimeout) === null || _a === void 0 ? void 0 : _a.call(view, scheduledFrame);
663
811
  }
812
+ if (measurementFrame && (view === null || view === void 0 ? void 0 : view.cancelAnimationFrame)) {
813
+ view.cancelAnimationFrame(measurementFrame);
814
+ }
815
+ else if (measurementFrame) {
816
+ (_b = view === null || view === void 0 ? void 0 : view.clearTimeout) === null || _b === void 0 ? void 0 : _b.call(view, measurementFrame);
817
+ }
664
818
  resizeObserver === null || resizeObserver === void 0 ? void 0 : resizeObserver.disconnect();
665
819
  viewport.removeEventListener('scroll', scheduleRender);
666
820
  unregisterFileViewerSearchProvider(root);
667
821
  unregisterFileViewerZoomProvider(root);
668
- (_b = context === null || context === void 0 ? void 0 : context.registerExportAdapter) === null || _b === void 0 ? void 0 : _b.call(context, null);
822
+ (_c = context === null || context === void 0 ? void 0 : context.registerExportAdapter) === null || _c === void 0 ? void 0 : _c.call(context, null);
669
823
  target.replaceChildren();
670
824
  }
671
825
  };
@@ -1,3 +1,4 @@
1
1
  import { type FileRenderContext, type FileViewerRenderedInstance } from '@file-viewer/core';
2
+ export declare const sanitizeMermaidSvg: (documentRef: Document, svg: string) => SVGSVGElement;
2
3
  export declare const stripMarkdownFrontmatter: (text: string) => string;
3
4
  export default function renderMarkdown(buffer: ArrayBuffer, target: HTMLDivElement, context?: FileRenderContext): Promise<FileViewerRenderedInstance>;
package/dist/markdown.js CHANGED
@@ -1,27 +1,22 @@
1
1
  import { marked } from 'marked';
2
2
  import createDOMPurify from 'dompurify';
3
- import { createFileViewerZoomChangeEmitter as createZoomChangeEmitter, decodeFileViewerTextBuffer, registerFileViewerZoomProvider, resolveFileViewerFitScale, unregisterFileViewerZoomProvider, } from '@file-viewer/core';
4
- const createSafeTextFragment = (documentRef, value) => {
5
- const fragment = documentRef.createDocumentFragment();
6
- fragment.append(documentRef.createTextNode(value));
7
- return fragment;
8
- };
9
- const sanitizeMarkdownHtml = (documentRef, html) => {
3
+ import { createFileViewerZoomChangeEmitter as createZoomChangeEmitter, assertFileViewerMermaidSourceHasNoExternalResources, decodeFileViewerTextBuffer, registerFileViewerZoomProvider, resolveFileViewerFitScale, unregisterFileViewerZoomProvider, sanitizeFileViewerSvgResources, } from '@file-viewer/core';
4
+ import { getFileViewerMermaidLoader } from './optionalCapabilities.js';
5
+ import { sanitizeFileViewerRichHtml } from './sanitizeHtml.js';
6
+ const sanitizeMarkdownHtml = sanitizeFileViewerRichHtml;
7
+ const purifierByDocument = new WeakMap();
8
+ const getMarkdownPurifier = (documentRef) => {
9
+ const cached = purifierByDocument.get(documentRef);
10
+ if (cached)
11
+ return cached;
10
12
  const windowRef = documentRef.defaultView;
11
- if (!windowRef) {
12
- return createSafeTextFragment(documentRef, html);
13
- }
13
+ if (!windowRef)
14
+ return null;
14
15
  const purifier = createDOMPurify(windowRef);
15
- if (!purifier.isSupported) {
16
- return createSafeTextFragment(documentRef, html);
17
- }
18
- return purifier.sanitize(html, {
19
- RETURN_DOM_FRAGMENT: true,
20
- USE_PROFILES: { html: true },
21
- ADD_ATTR: ['target'],
22
- FORBID_TAGS: ['style', 'iframe', 'object', 'embed', 'form'],
23
- FORBID_ATTR: ['style', 'srcdoc'],
24
- });
16
+ if (!purifier.isSupported)
17
+ return null;
18
+ purifierByDocument.set(documentRef, purifier);
19
+ return purifier;
25
20
  };
26
21
  const hardenMarkdownLinks = (root) => {
27
22
  root.querySelectorAll('a[target]').forEach(anchor => {
@@ -108,35 +103,38 @@ const isDarkTheme = (documentRef, theme) => {
108
103
  }
109
104
  return Boolean((_b = (_a = documentRef.defaultView) === null || _a === void 0 ? void 0 : _a.matchMedia) === null || _b === void 0 ? void 0 : _b.call(_a, '(prefers-color-scheme: dark)').matches);
110
105
  };
111
- const sanitizeMermaidSvg = (documentRef, svg) => {
112
- var _a;
113
- const Parser = ((_a = documentRef.defaultView) === null || _a === void 0 ? void 0 : _a.DOMParser) || DOMParser;
114
- const parsed = new Parser().parseFromString(svg, 'image/svg+xml');
115
- const parseError = parsed.querySelector('parsererror');
116
- if (parseError) {
117
- throw new Error(parseError.textContent || 'Unable to parse the Mermaid SVG.');
106
+ export const sanitizeMermaidSvg = (documentRef, svg) => {
107
+ const purifier = getMarkdownPurifier(documentRef);
108
+ if (!purifier) {
109
+ throw new Error('Unable to initialize the Mermaid SVG sanitizer.');
118
110
  }
119
- parsed.querySelectorAll('script,iframe,object,embed').forEach(node => node.remove());
120
- parsed.querySelectorAll('*').forEach(node => {
121
- for (const attribute of Array.from(node.attributes)) {
122
- if (/^on/i.test(attribute.name)) {
123
- node.removeAttribute(attribute.name);
124
- }
125
- else if (/^(?:href|xlink:href|src)$/i.test(attribute.name) && /^\s*javascript:/i.test(attribute.value)) {
126
- node.removeAttribute(attribute.name);
127
- }
128
- }
111
+ const fragment = purifier.sanitize(svg, {
112
+ RETURN_DOM_FRAGMENT: true,
113
+ USE_PROFILES: { html: true, svg: true, svgFilters: true },
114
+ FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form'],
115
+ FORBID_ATTR: ['srcdoc'],
129
116
  });
130
- return documentRef.importNode(parsed.documentElement, true);
117
+ const root = fragment.querySelector('svg');
118
+ if (!root) {
119
+ throw new Error('Unable to parse the Mermaid SVG.');
120
+ }
121
+ sanitizeFileViewerSvgResources(fragment);
122
+ return documentRef.importNode(root, true);
131
123
  };
132
124
  const renderMermaidSvg = async (documentRef, source, theme) => {
133
125
  const render = async () => {
134
- const mermaidModule = await import('mermaid');
126
+ assertFileViewerMermaidSourceHasNoExternalResources(source);
127
+ const loadMermaid = getFileViewerMermaidLoader();
128
+ if (!loadMermaid) {
129
+ throw new Error('Mermaid support is opt-in. Run `npx file-viewer-cli add mermaid-markdown --write`, then `npx file-viewer-cli install --yes`.');
130
+ }
131
+ const mermaidModule = await loadMermaid();
135
132
  const mermaid = mermaidModule.default;
136
133
  const id = `file-viewer-markdown-mermaid-${Date.now()}-${mermaidRenderSequence += 1}`;
137
134
  mermaid.initialize({
138
135
  startOnLoad: false,
139
136
  securityLevel: 'strict',
137
+ htmlLabels: false,
140
138
  theme: isDarkTheme(documentRef, theme) ? 'dark' : 'default',
141
139
  });
142
140
  const rendered = await mermaid.render(id, source);
@@ -171,7 +169,9 @@ const renderEmbeddedMermaid = async (article, theme) => {
171
169
  const message = documentRef.createElement('p');
172
170
  message.className = 'markdown-mermaid-error';
173
171
  message.setAttribute('role', 'alert');
174
- message.textContent = 'Mermaid diagram could not be rendered. The source is shown above.';
172
+ message.textContent = error instanceof Error && error.message.includes('npx file-viewer-cli')
173
+ ? error.message
174
+ : 'Mermaid diagram could not be rendered. The source is shown above.';
175
175
  pre.after(message);
176
176
  console.warn('[file-viewer] Unable to render embedded Mermaid diagram.', error);
177
177
  }
@@ -0,0 +1,21 @@
1
+ export type FileViewerMermaidModule = {
2
+ default: {
3
+ initialize(options: Record<string, unknown>): void;
4
+ render(id: string, source: string): Promise<{
5
+ svg: string;
6
+ }>;
7
+ };
8
+ };
9
+ export type FileViewerMermaidLoader = () => Promise<FileViewerMermaidModule>;
10
+ export type FileViewerDiffToHtml = (input: string, options: Record<string, unknown>) => string;
11
+ export type FileViewerPakoModule = typeof import('pako');
12
+ /**
13
+ * Enables embedded Mermaid blocks without making Mermaid part of the base text
14
+ * renderer dependency closure. Capability packs call this once at startup.
15
+ */
16
+ export declare const registerFileViewerMermaidLoader: (loader: FileViewerMermaidLoader | null) => void;
17
+ export declare const getFileViewerMermaidLoader: () => FileViewerMermaidLoader | null;
18
+ export declare const registerFileViewerDiffToHtml: (renderer: FileViewerDiffToHtml | null) => void;
19
+ export declare const getFileViewerDiffToHtml: () => FileViewerDiffToHtml | null;
20
+ export declare const registerFileViewerPakoLoader: (loader: (() => Promise<FileViewerPakoModule>) | null) => void;
21
+ export declare const getFileViewerPakoLoader: () => (() => Promise<FileViewerPakoModule>) | null;
@@ -0,0 +1,19 @@
1
+ let mermaidLoader = null;
2
+ let diffToHtml = null;
3
+ let pakoLoader = null;
4
+ /**
5
+ * Enables embedded Mermaid blocks without making Mermaid part of the base text
6
+ * renderer dependency closure. Capability packs call this once at startup.
7
+ */
8
+ export const registerFileViewerMermaidLoader = (loader) => {
9
+ mermaidLoader = loader;
10
+ };
11
+ export const getFileViewerMermaidLoader = () => mermaidLoader;
12
+ export const registerFileViewerDiffToHtml = (renderer) => {
13
+ diffToHtml = renderer;
14
+ };
15
+ export const getFileViewerDiffToHtml = () => diffToHtml;
16
+ export const registerFileViewerPakoLoader = (loader) => {
17
+ pakoLoader = loader;
18
+ };
19
+ export const getFileViewerPakoLoader = () => pakoLoader;
package/dist/patch.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createFileViewerZoomChangeEmitter as createZoomChangeEmitter, decodeFileViewerTextBuffer, registerFileViewerZoomProvider, unregisterFileViewerZoomProvider } from '@file-viewer/core';
2
- import { html as diffToHtml } from 'diff2html';
2
+ import { getFileViewerDiffToHtml } from './optionalCapabilities.js';
3
+ import { sanitizeFileViewerRichHtml } from './sanitizeHtml.js';
3
4
  const patchStyle = `
4
5
  .patch-viewer{min-height:100%;--patch-bg:#f6f8fa;--patch-surface:#fff;--patch-border:rgba(31,35,40,.12);--patch-text:#24292f;--patch-muted:#57606a;--patch-add:#dafbe1;--patch-del:#ffebe9;--patch-info:#ddf4ff;--patch-font-size:13px;background:var(--patch-bg);color:var(--patch-text);box-sizing:border-box}
5
6
  .patch-toolbar{position:sticky;top:0;z-index:2;display:flex;height:46px;align-items:center;justify-content:space-between;gap:12px;padding:0 16px;border-bottom:1px solid var(--patch-border);background:rgba(255,255,255,.92);backdrop-filter:blur(12px);box-sizing:border-box}
@@ -52,18 +53,6 @@ const countFiles = (text) => {
52
53
  }
53
54
  return ((_a = text.match(/^---\s+/gm)) === null || _a === void 0 ? void 0 : _a.length) || 1;
54
55
  };
55
- const escapeHtml = (value) => {
56
- return value.replace(/[&<>"']/g, char => {
57
- const entities = {
58
- '&': '&amp;',
59
- '<': '&lt;',
60
- '>': '&gt;',
61
- '"': '&quot;',
62
- "'": '&#39;'
63
- };
64
- return entities[char];
65
- });
66
- };
67
56
  export default async function renderPatch(buffer, target, type = 'patch', context) {
68
57
  var _a, _b;
69
58
  const documentRef = target.ownerDocument || document;
@@ -76,16 +65,21 @@ export default async function renderPatch(buffer, target, type = 'patch', contex
76
65
  toolbar.append(createElement(documentRef, 'span', undefined, type.toUpperCase()), createElement(documentRef, 'strong', undefined, `${countFiles(text)} files · side-by-side`));
77
66
  const body = createElement(documentRef, 'div', 'patch-body');
78
67
  try {
79
- body.innerHTML = diffToHtml(text, {
68
+ const diffToHtml = getFileViewerDiffToHtml();
69
+ if (!diffToHtml) {
70
+ throw new Error('Patch side-by-side rendering is opt-in. Run `npx file-viewer-cli add text-tools --write`, then `npx file-viewer-cli install --yes`.');
71
+ }
72
+ const rendered = diffToHtml(text, {
80
73
  drawFileList: true,
81
74
  matching: 'lines',
82
75
  outputFormat: 'side-by-side',
83
76
  renderNothingWhenEmpty: false
84
77
  });
78
+ body.replaceChildren(sanitizeFileViewerRichHtml(documentRef, rendered, { allowSvg: true }));
85
79
  }
86
80
  catch {
87
81
  const fallback = createElement(documentRef, 'pre', 'patch-fallback');
88
- fallback.innerHTML = escapeHtml(text);
82
+ fallback.textContent = text;
89
83
  body.replaceChildren(fallback);
90
84
  }
91
85
  root.append(toolbar, body);
@@ -0,0 +1,35 @@
1
+ import type { FileViewerTextOptions } from '@file-viewer/core';
2
+ export declare const DEFAULT_PRETTY_PRINT_MAX_BYTES: number;
3
+ export type FileViewerPrettyPrintReason = 'formatted' | 'disabled' | 'unsupported' | 'too-large' | 'whitespace-sensitive' | 'failed' | 'aborted';
4
+ export interface FileViewerPrettyPrintResult {
5
+ text: string;
6
+ formatted: boolean;
7
+ reason: FileViewerPrettyPrintReason;
8
+ parser?: string;
9
+ sourceByteLength: number;
10
+ maxBytes: number;
11
+ }
12
+ type PrettierPlugin = Record<string, unknown>;
13
+ type PrettierRuntime = {
14
+ format: (source: string, options: Record<string, unknown>) => string | Promise<string>;
15
+ plugins: PrettierPlugin[];
16
+ };
17
+ type PrettierPluginName = 'babel' | 'estree' | 'typescript' | 'postcss' | 'html' | 'markdown' | 'yaml' | 'graphql' | 'xml';
18
+ interface PrettierLanguageDefinition {
19
+ parser: string;
20
+ plugins: readonly PrettierPluginName[];
21
+ resolvePlugins?: (source: string) => readonly PrettierPluginName[];
22
+ options?: Readonly<Record<string, unknown>>;
23
+ }
24
+ export type FileViewerPrettierRuntimeLoader = (definition: PrettierLanguageDefinition, source: string) => Promise<PrettierRuntime>;
25
+ export declare const resolveFileViewerPrettyPrintMaxBytes: (options?: FileViewerTextOptions) => number;
26
+ export declare const supportsFileViewerPrettyPrint: (extension: string) => boolean;
27
+ /**
28
+ * Formats a decoded display representation without mutating the source buffer.
29
+ *
30
+ * Parser support and byte limits are resolved before the Prettier runtime or
31
+ * any parser plugin is imported. Failures intentionally return the original
32
+ * source so malformed or unsupported uploads remain previewable.
33
+ */
34
+ export declare const formatFileViewerTextForDisplay: (source: string, extension: string, options?: FileViewerTextOptions, signal?: AbortSignal, runtimeLoader?: FileViewerPrettierRuntimeLoader) => Promise<FileViewerPrettyPrintResult>;
35
+ export {};