omni-viewer-core 0.11.1 → 0.12.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.
@@ -115,7 +115,12 @@ export interface CsvController {
115
115
  /** Whole document in original order as normalized CSV (save/writeback —
116
116
  * ignores search and sort so hidden rows are never dropped). */
117
117
  toDocumentCsv(): string;
118
- /** Mark the current edit depth as the saved point (clears dirty). */
118
+ /**
119
+ * Mark the current edit depth as the saved point (clears dirty). Writes are
120
+ * async and the grid stays editable during one, so the caller must confirm
121
+ * that `toDocumentCsv()` still equals what it wrote before calling this —
122
+ * otherwise an edit made mid-write is recorded as being on disk.
123
+ */
119
124
  markSaved(): void;
120
125
  /** Decoded source text (raw view). */
121
126
  readonly rawText: string;
@@ -216,13 +216,25 @@ export async function mountCsvViewer(input, container, ctx, options = {}) {
216
216
  saveAsBtn.title = t('common.noFileSave');
217
217
  }
218
218
  const dirtyBadge = el('span', 'omni-csv__dirty');
219
+ /**
220
+ * Clear dirty only if the document still equals the bytes that reached the
221
+ * file. The grid stays editable across the async write, so a cell changed
222
+ * mid-write would otherwise be recorded as saved — and a host trusting
223
+ * `isDirty()` would discard it on re-mount. When it differs, dirty is left
224
+ * set: the next save resolves it, and erring toward "unsaved" cannot lose
225
+ * work.
226
+ */
227
+ function markSavedIfUnchanged(written) {
228
+ if (controller.toDocumentCsv() === written)
229
+ controller.markSaved();
230
+ }
219
231
  async function saveDocument(service) {
220
232
  try {
221
233
  // Save covers the whole document in original order — never the
222
234
  // filtered/sorted display result (docs §6).
223
- const bytes = new TextEncoder().encode(controller.toDocumentCsv());
224
- await service.write(bytes);
225
- controller.markSaved();
235
+ const written = controller.toDocumentCsv();
236
+ await service.write(new TextEncoder().encode(written));
237
+ markSavedIfUnchanged(written);
226
238
  showToast(t('common.savedToOriginal'));
227
239
  }
228
240
  catch (error) {
@@ -237,9 +249,9 @@ export async function mountCsvViewer(input, container, ctx, options = {}) {
237
249
  const name = saveAsFileName(input.fileName, state.delimiter);
238
250
  const mime = state.delimiter === '\t' ? 'text/tab-separated-values' : 'text/csv';
239
251
  try {
240
- const bytes = new TextEncoder().encode(controller.toDocumentCsv());
241
- await service.saveFile(name, bytes, mime);
242
- controller.markSaved();
252
+ const written = controller.toDocumentCsv();
253
+ await service.saveFile(name, new TextEncoder().encode(written), mime);
254
+ markSavedIfUnchanged(written);
243
255
  showToast(t('common.saved', { name }));
244
256
  }
245
257
  catch (error) {
@@ -19,8 +19,14 @@ export type DiagramAction = {
19
19
  type: 'undo';
20
20
  } | {
21
21
  type: 'redo';
22
- } | {
22
+ }
23
+ /** `source` is the text that actually reached the file. Writes are async and
24
+ * the editor stays live during one, so the current source at completion is
25
+ * not necessarily what was written (mirrors the latex/markdown controllers).
26
+ * Omitted = "what is in the editor now". */
27
+ | {
23
28
  type: 'mark-saved';
29
+ source?: string;
24
30
  };
25
31
  export interface DiagramViewState {
26
32
  mode: DiagramViewMode;
@@ -38,7 +38,8 @@ export function createDiagramController(text, theme) {
38
38
  setSource(redo.pop());
39
39
  }
40
40
  else if (action.type === 'mark-saved') {
41
- state = { ...state, savedSource: state.source, dirty: false };
41
+ const savedSource = action.source ?? state.source;
42
+ state = { ...state, savedSource, dirty: state.source !== savedSource };
42
43
  }
43
44
  emit();
44
45
  },
@@ -8,6 +8,11 @@ export type DiagramViewerContext = HostContext & {
8
8
  writeback?: FileWritebackService;
9
9
  save?: FileSaveService;
10
10
  };
11
+ /** Diagram mount handle: `isDirty()` lets a host guard a re-mount against
12
+ * discarding unsaved source edits (DESIGN.md §3-②). */
13
+ export interface DiagramViewerHandle extends ViewerHandle {
14
+ isDirty(): boolean;
15
+ }
11
16
  export interface DiagramMountOptions extends MountOptions {
12
17
  renderMermaid?(id: string, source: string, theme?: DiagramTheme): Promise<string>;
13
18
  renderPlantUml?(source: string, document: Document, theme?: DiagramTheme): SVGElement | string;
@@ -20,5 +25,5 @@ export interface DiagramMountOptions extends MountOptions {
20
25
  export declare function mountDiagramViewer(kind: DiagramKind, parsed: {
21
26
  source: string;
22
27
  warnings: string[];
23
- }, input: ViewerInput, container: HTMLElement, ctx: DiagramViewerContext, options?: DiagramMountOptions): Promise<ViewerHandle>;
28
+ }, input: ViewerInput, container: HTMLElement, ctx: DiagramViewerContext, options?: DiagramMountOptions): Promise<DiagramViewerHandle>;
24
29
  export declare function sanitizeSvg(svg: string, maxBytes?: number, maxNodes?: number): SVGElement;
@@ -181,13 +181,20 @@ export async function mountDiagramViewer(kind, parsed, input, container, ctx, op
181
181
  }
182
182
  };
183
183
  const saveSource = async () => {
184
- const bytes = new TextEncoder().encode(controller.state.source);
184
+ // Snapshot once: the write is async and the editor stays live, so
185
+ // `controller.state.source` after the await may already be a later edit
186
+ // than the bytes that reached the file.
187
+ const saved = controller.state.source;
188
+ const bytes = new TextEncoder().encode(saved);
185
189
  if (ctx.writeback) {
190
+ // The caption is the dirty indicator: leave syncState's verdict alone
191
+ // when an edit arrived mid-write and the source is still unsaved.
186
192
  try {
187
193
  await ctx.writeback.write(bytes);
188
- controller.dispatch({ type: 'mark-saved' });
194
+ controller.dispatch({ type: 'mark-saved', source: saved });
189
195
  setStatus('common.savedToOriginal', 'valid');
190
- sourceCaption.textContent = t('common.savedToOriginal');
196
+ if (!controller.state.dirty)
197
+ sourceCaption.textContent = t('common.savedToOriginal');
191
198
  }
192
199
  catch (error) {
193
200
  ctx.logger.log('error', `diagram save failed: ${String(error)}`);
@@ -296,7 +303,11 @@ export async function mountDiagramViewer(kind, parsed, input, container, ctx, op
296
303
  container.classList.remove(VIEWER_ROOT_CLASS, `omni-viewer--${kind}`);
297
304
  else
298
305
  root.replaceChildren();
299
- }
306
+ },
307
+ // The controller, not the textarea: `mark-saved` (a successful writeback)
308
+ // is the only thing that clears it, so a failed save or a Save As copy
309
+ // stays dirty.
310
+ isDirty: () => controller.state.dirty
300
311
  };
301
312
  }
302
313
  let nextId = 1;
@@ -12,8 +12,17 @@ export type LatexAction = {
12
12
  type: 'undo';
13
13
  } | {
14
14
  type: 'redo';
15
- } | {
15
+ }
16
+ /**
17
+ * `source` is the text that actually reached the file. Writes are async and
18
+ * the editor stays live during one, so the current source at completion is
19
+ * not necessarily what was written — omitting it would mark an edit made
20
+ * mid-write as saved and let a host discard it. Omitted = "what is in the
21
+ * editor now", which is only correct for synchronous saves.
22
+ */
23
+ | {
16
24
  type: 'mark-saved';
25
+ source?: string;
17
26
  };
18
27
  export interface LatexViewState {
19
28
  mode: LatexViewMode;
@@ -52,7 +52,8 @@ export function createLatexController(text, mode = 'preview') {
52
52
  setSource(redo.pop());
53
53
  }
54
54
  else if (action.type === 'mark-saved') {
55
- state = { ...state, savedSource: state.source, dirty: false };
55
+ const savedSource = action.source ?? state.source;
56
+ state = { ...state, savedSource, dirty: state.source !== savedSource };
56
57
  }
57
58
  emit();
58
59
  },
@@ -28,6 +28,12 @@ export interface LatexViewerDeps {
28
28
  math?: MathRenderer;
29
29
  createDOMPurify?(window: Window): DomPurify;
30
30
  }
31
+ /** LaTeX mount handle: dirty inspection lets hosts guard a re-mount (file
32
+ * change, refresh) against discarding unsaved edits, without reaching into the
33
+ * viewer's DOM (csv/image/pdf expose the same method). */
34
+ export interface LatexViewerHandle extends ViewerHandle {
35
+ isDirty(): boolean;
36
+ }
31
37
  export interface LatexMountOptions extends MountOptions {
32
38
  /** Forwarded to the parser (limits, cancellation). */
33
39
  parse?: LatexParseOptions;
@@ -57,7 +63,7 @@ export declare const LATEX_VIEWER_META: {
57
63
  optionalServices: readonly ["clipboard", "navigation", "writeback", "save"];
58
64
  inputOwnership: "borrows";
59
65
  };
60
- export declare function mountLatexViewer(input: ViewerInput, container: HTMLElement, ctx: LatexViewerContext, options?: LatexMountOptions): Promise<ViewerHandle>;
66
+ export declare function mountLatexViewer(input: ViewerInput, container: HTMLElement, ctx: LatexViewerContext, options?: LatexMountOptions): Promise<LatexViewerHandle>;
61
67
  /**
62
68
  * Commands that produce no glyphs but that math engines do not implement.
63
69
  * Collecting the document's own macros (L18) means a text-mode macro can now
@@ -543,11 +543,15 @@ export async function mountLatexViewer(input, container, ctx, options = {}) {
543
543
  }
544
544
  };
545
545
  const saveSource = async () => {
546
- const bytes = new TextEncoder().encode(controller.state.source);
546
+ // Snapshot once: the write is async and the editor stays live, so
547
+ // `controller.state.source` after the await may already be a later edit
548
+ // than the bytes that reached the file.
549
+ const saved = controller.state.source;
550
+ const bytes = new TextEncoder().encode(saved);
547
551
  if (ctx.writeback) {
548
552
  try {
549
553
  await ctx.writeback.write(bytes);
550
- controller.dispatch({ type: 'mark-saved' });
554
+ controller.dispatch({ type: 'mark-saved', source: saved });
551
555
  setStatus('common.savedToOriginal', 'valid');
552
556
  }
553
557
  catch (error) {
@@ -641,7 +645,10 @@ export async function mountLatexViewer(input, container, ctx, options = {}) {
641
645
  teardown();
642
646
  throw new MountAbortedError();
643
647
  }
644
- return { dispose: teardown };
648
+ // Reads the controller rather than the textarea: the source of truth for
649
+ // "unsaved" is `source !== savedSource`, which undo restores and `mark-saved`
650
+ // clears. A Save As copy never dispatches `mark-saved`, so it stays dirty.
651
+ return { dispose: teardown, isDirty: () => controller.state.dirty };
645
652
  }
646
653
  function errorText(error, fallback) {
647
654
  return error instanceof Error ? error.message : fallback;
@@ -18,8 +18,13 @@ export type MarkdownAction = {
18
18
  type: 'undo';
19
19
  } | {
20
20
  type: 'redo';
21
- } | {
21
+ }
22
+ /** `source` is the text that actually reached the file. Writes are async and
23
+ * the editor stays live during one, so the current source at completion is
24
+ * not necessarily what was written. Omitted = "what is in the editor now". */
25
+ | {
22
26
  type: 'mark-saved';
27
+ source?: string;
23
28
  } | {
24
29
  type: 'copy-code';
25
30
  code: string;
@@ -52,7 +52,8 @@ export function createMarkdownController(text, _headings) {
52
52
  setSource(redo.pop());
53
53
  }
54
54
  else if (action.type === 'mark-saved') {
55
- state = { ...state, savedSource: state.source, dirty: false };
55
+ const savedSource = action.source ?? state.source;
56
+ state = { ...state, savedSource, dirty: state.source !== savedSource };
56
57
  }
57
58
  emit();
58
59
  },
@@ -56,6 +56,11 @@ export type MarkdownViewerContext = HostContext & {
56
56
  writeback?: FileWritebackService;
57
57
  save?: FileSaveService;
58
58
  };
59
+ /** Markdown mount handle: `isDirty()` lets a host guard a re-mount against
60
+ * discarding unsaved source edits (DESIGN.md §3-②). */
61
+ export interface MarkdownViewerHandle extends ViewerHandle {
62
+ isDirty(): boolean;
63
+ }
59
64
  export interface MarkdownMountOptions extends MountOptions {
60
65
  limits?: ResourceLimits;
61
66
  markdownLimits?: MarkdownParseOptions['markdownLimits'];
@@ -64,4 +69,4 @@ export interface MarkdownMountOptions extends MountOptions {
64
69
  * wants it configurable should drive this from its own settings. */
65
70
  scrollSync?: boolean;
66
71
  }
67
- export declare function mountMarkdownViewer(input: ViewerInput, container: HTMLElement, ctx: MarkdownViewerContext, deps: MarkdownViewerDeps, options?: MarkdownMountOptions): Promise<ViewerHandle>;
72
+ export declare function mountMarkdownViewer(input: ViewerInput, container: HTMLElement, ctx: MarkdownViewerContext, deps: MarkdownViewerDeps, options?: MarkdownMountOptions): Promise<MarkdownViewerHandle>;
@@ -546,13 +546,20 @@ export async function mountMarkdownViewer(input, container, ctx, deps, options =
546
546
  }
547
547
  };
548
548
  const saveSource = async () => {
549
- const bytes = new TextEncoder().encode(controller.state.source);
549
+ // Snapshot once: the write is async and the editor stays live, so
550
+ // `controller.state.source` after the await may already be a later edit
551
+ // than the bytes that reached the file.
552
+ const saved = controller.state.source;
553
+ const bytes = new TextEncoder().encode(saved);
550
554
  if (ctx.writeback) {
555
+ // The caption is the dirty indicator: leave syncState's verdict alone
556
+ // when an edit arrived mid-write and the source is still unsaved.
551
557
  try {
552
558
  await ctx.writeback.write(bytes);
553
- controller.dispatch({ type: 'mark-saved' });
559
+ controller.dispatch({ type: 'mark-saved', source: saved });
554
560
  setStatus('common.savedToOriginal', 'valid');
555
- sourceCaption.textContent = t('common.savedToOriginal');
561
+ if (!controller.state.dirty)
562
+ sourceCaption.textContent = t('common.savedToOriginal');
556
563
  }
557
564
  catch (error) {
558
565
  ctx.logger.log('error', `markdown save failed: ${String(error)}`);
@@ -739,9 +746,15 @@ export async function mountMarkdownViewer(input, container, ctx, deps, options =
739
746
  releaseAssets();
740
747
  throw new MountAbortedError();
741
748
  }
742
- return { dispose() { disposed = true; renderVersion++; if (liveTimer)
749
+ return {
750
+ dispose() { disposed = true; renderVersion++; if (liveTimer)
743
751
  clearTimeout(liveTimer); releaseAssets(); for (const dispose of disposers.splice(0))
744
- dispose(); shell.remove(); } };
752
+ dispose(); shell.remove(); },
753
+ // The controller, not the textarea: `mark-saved` (a successful writeback)
754
+ // is the only thing that clears it, so a failed save or a download copy
755
+ // stays dirty.
756
+ isDirty: () => controller.state.dirty
757
+ };
745
758
  }
746
759
  /** Flat text for an outline entry. Rendered math contributes twice to
747
760
  * `textContent` — KaTeX emits a visual copy alongside a MathML twin — so the
@@ -1,9 +1,10 @@
1
- import { diagramViewerCss, type DiagramMountOptions, type DiagramViewerContext } from '../diagram.js';
2
- import type { ViewerHandle, ViewerInput } from '../types.js';
1
+ import { diagramViewerCss, type DiagramMountOptions, type DiagramViewerContext, type DiagramViewerHandle } from '../diagram.js';
2
+ import type { ViewerInput } from '../types.js';
3
3
  export { parseMermaid } from '../../parsers/mermaid/index.js';
4
4
  export { diagramViewerCss as mermaidViewerCss };
5
5
  export type MermaidViewerContext = DiagramViewerContext;
6
6
  export type MermaidMountOptions = DiagramMountOptions;
7
+ export type MermaidViewerHandle = DiagramViewerHandle;
7
8
  export declare const MERMAID_VIEWER_META: {
8
9
  id: string;
9
10
  displayNameKey: string;
@@ -13,4 +14,4 @@ export declare const MERMAID_VIEWER_META: {
13
14
  optionalServices: readonly ["clipboard", "writeback", "save"];
14
15
  inputOwnership: "borrows";
15
16
  };
16
- export declare function mountMermaidViewer(input: ViewerInput, container: HTMLElement, ctx: MermaidViewerContext, options?: MermaidMountOptions): Promise<ViewerHandle>;
17
+ export declare function mountMermaidViewer(input: ViewerInput, container: HTMLElement, ctx: MermaidViewerContext, options?: MermaidMountOptions): Promise<MermaidViewerHandle>;
@@ -123,8 +123,18 @@ export type PdfAction = {
123
123
  type: 'undo';
124
124
  } | {
125
125
  type: 'redo';
126
- } | {
126
+ }
127
+ /**
128
+ * `savedState` is the page order and annotations that actually reached the
129
+ * file. Builds and writes are async and the document stays editable during
130
+ * one, so the state at completion is not necessarily what was written —
131
+ * omitting it would mark an edit made mid-save as saved and let a host
132
+ * discard it. Omitted = "the current state", which is only correct for a
133
+ * synchronous save.
134
+ */
135
+ | {
127
136
  type: 'mark-saved';
137
+ savedState?: Pick<PdfViewState, 'pageOrder' | 'annotations'>;
128
138
  };
129
139
  export interface PdfController {
130
140
  readonly state: PdfViewState;
@@ -217,7 +217,14 @@ export function createPdfController(pageCount, seed, options = {}) {
217
217
  selectedAnnotationId = action.id;
218
218
  break;
219
219
  case 'mark-saved':
220
- savedSignature = signature();
220
+ // Same shape as `signature()` so an unchanged document still
221
+ // compares equal (state copies preserve key order).
222
+ savedSignature = action.savedState
223
+ ? JSON.stringify({
224
+ pageOrder: action.savedState.pageOrder,
225
+ annotations: action.savedState.annotations
226
+ })
227
+ : signature();
221
228
  break;
222
229
  }
223
230
  if (before && beforeSignature !== signature()) {
@@ -106,6 +106,9 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
106
106
  let thumbRecords = [];
107
107
  let renderEpoch = 0;
108
108
  let currentPage = 1;
109
+ /** Page requested by an explicit navigation, held against the scroll event
110
+ * that navigation queues. Cleared once the reader scrolls themselves. */
111
+ let navigatedTo;
109
112
  let tool = 'view';
110
113
  let highlightColor = '#ffeb3b';
111
114
  let underlineColor = '#000000';
@@ -1359,12 +1362,29 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
1359
1362
  // coordinate-based movement is enough. Combining this with
1360
1363
  // scrollIntoView applied the same offset twice.
1361
1364
  pages.scrollTop = Math.max(0, pages.scrollTop + page.top - viewport.top);
1365
+ // The scrollport clamps at its end, so pages inside the final screenful
1366
+ // — the pages appended by a merge, typically — never reach the top of
1367
+ // the viewport. Pin the requested page against the scroll event this
1368
+ // assignment queues; without it the listener re-selects the page that
1369
+ // does straddle the top and the click appears to land one page early.
1370
+ // Read the value back so the pin carries the clamped landing position.
1371
+ navigatedTo = { pageNumber: record.pageNumber, scrollTop: pages.scrollTop };
1362
1372
  requestAnimationFrame(() => {
1363
1373
  ctx.logger.log('info', `[pdf navigation] result page=${record.pageNumber} scrollTop=${pages.scrollTop} ` +
1364
1374
  `delta=${pages.scrollTop - before}`);
1365
1375
  });
1366
1376
  }
1367
1377
  function syncCurrentPageFromViewport() {
1378
+ if (navigatedTo) {
1379
+ // Still resting where the navigation left us: honour the request.
1380
+ // Any other offset means the reader has scrolled since, so the
1381
+ // viewport takes over again.
1382
+ if (Math.abs(pages.scrollTop - navigatedTo.scrollTop) <= 1) {
1383
+ setCurrentPage(navigatedTo.pageNumber);
1384
+ return;
1385
+ }
1386
+ navigatedTo = undefined;
1387
+ }
1368
1388
  const viewport = pages.getBoundingClientRect();
1369
1389
  const visible = records.filter((record) => {
1370
1390
  const box = record.wrapper.getBoundingClientRect();
@@ -1387,6 +1407,9 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
1387
1407
  async function rebuildLayout() {
1388
1408
  if (!doc || disposed)
1389
1409
  return;
1410
+ // Page boxes are about to move (merge, reorder, delete, zoom), so a
1411
+ // pin taken against the old layout no longer describes anything.
1412
+ navigatedTo = undefined;
1390
1413
  // Preserve the source page the reader is actually viewing. `currentPage`
1391
1414
  // alone is not sufficient here: the lazy-render observer deliberately
1392
1415
  // observes pages outside the viewport as well.
@@ -1757,13 +1780,20 @@ export async function mountPdfViewer(input, container, ctx, deps, options = {})
1757
1780
  async function save() {
1758
1781
  if (!ctx.writeback || saveBtn.disabled)
1759
1782
  return;
1783
+ // Pin what is being written before the build starts: only the save/merge
1784
+ // buttons are disabled while an operation runs, so pages and annotations
1785
+ // can still change across the await. Taking the state at completion would
1786
+ // mark those changes as saved and let a host discard them on re-mount.
1787
+ // `stateForSave()` only rasterizes text for output, so the pre-raster
1788
+ // state is what this write represents.
1789
+ const { pageOrder, annotations } = controller.state;
1760
1790
  const result = await runOperation('save', async (control) => {
1761
1791
  const data = await buildOutput(stateForSave(), control);
1762
1792
  await ctx.writeback.write(data);
1763
1793
  });
1764
1794
  if (result.status === 'succeeded') {
1765
1795
  mergedSinceSave = false;
1766
- controller.dispatch({ type: 'mark-saved' });
1796
+ controller.dispatch({ type: 'mark-saved', savedState: { pageOrder, annotations } });
1767
1797
  refreshChrome();
1768
1798
  status.textContent = t('common.savedToOriginal');
1769
1799
  }
@@ -1,9 +1,10 @@
1
- import { diagramViewerCss, type DiagramMountOptions, type DiagramViewerContext } from '../diagram.js';
2
- import type { ViewerHandle, ViewerInput } from '../types.js';
1
+ import { diagramViewerCss, type DiagramMountOptions, type DiagramViewerContext, type DiagramViewerHandle } from '../diagram.js';
2
+ import type { ViewerInput } from '../types.js';
3
3
  export { parsePlantUml } from '../../parsers/plantuml/index.js';
4
4
  export { diagramViewerCss as plantumlViewerCss };
5
5
  export type PlantUmlViewerContext = DiagramViewerContext;
6
6
  export type PlantUmlMountOptions = DiagramMountOptions;
7
+ export type PlantUmlViewerHandle = DiagramViewerHandle;
7
8
  export declare const PLANTUML_VIEWER_META: {
8
9
  id: string;
9
10
  displayNameKey: string;
@@ -13,4 +14,4 @@ export declare const PLANTUML_VIEWER_META: {
13
14
  optionalServices: readonly ["clipboard", "writeback", "save"];
14
15
  inputOwnership: "borrows";
15
16
  };
16
- export declare function mountPlantUmlViewer(input: ViewerInput, container: HTMLElement, ctx: PlantUmlViewerContext, options?: PlantUmlMountOptions): Promise<ViewerHandle>;
17
+ export declare function mountPlantUmlViewer(input: ViewerInput, container: HTMLElement, ctx: PlantUmlViewerContext, options?: PlantUmlMountOptions): Promise<PlantUmlViewerHandle>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omni-viewer-core",
3
- "version": "0.11.1",
3
+ "version": "0.12.1",
4
4
  "description": "Shared parsing + rendering core for Omni Viewer (vscode / chrome / obsidian / web)",
5
5
  "type": "module",
6
6
  "license": "MIT",