omni-viewer-core 0.20.0 → 0.21.0

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/README.md CHANGED
@@ -13,7 +13,7 @@ models, viewers mount into a DOM element, and everything host-specific
13
13
  ## Supported formats
14
14
 
15
15
  - **Documents** — PDF, Word (DOCX and legacy DOC), HWP, PowerPoint (PPTX and
16
- legacy PPT), Markdown, Jupyter Notebook (.ipynb), EPUB e-books (2 and 3),
16
+ legacy PPT), HTML, Markdown, Jupyter Notebook (.ipynb), EPUB e-books (2 and 3),
17
17
  LaTeX (structure and math preview, not typesetting)
18
18
  - **Data & spreadsheets** — Excel, CSV/TSV, JSON, JSONL/NDJSON, YAML, TOML, XML,
19
19
  Parquet, Avro, HDF5, MATLAB MAT, NumPy (NPY/NPZ), Safetensors, GGUF, ONNX, TFLite/LiteRT,
@@ -28,6 +28,12 @@ models, viewers mount into a DOM element, and everything host-specific
28
28
  - **Diagrams & GIS** — Mermaid, PlantUML, ESRI Shapefile
29
29
  - **Archives** — ZIP-family listing and safe entry preview
30
30
 
31
+ The HTML viewer handles `.html` and `.htm` files with preview and source tabs.
32
+ The preview runs in a sandboxed iframe with scripts, external resources, and
33
+ navigation removed or blocked; inline styles and embedded data images remain.
34
+ Files over 8 MiB show a bounded source preview. Very dense markup opens in
35
+ source view to keep parsing responsive.
36
+
31
37
  ## Install
32
38
 
33
39
  ```sh
@@ -1221,6 +1221,12 @@ export const CATALOG_EN = {
1221
1221
  'image.spriteDetection.failed': 'Sprite detection failed.',
1222
1222
  'image.saveAsPng': 'Save as PNG',
1223
1223
  'image.saveAsPng.note': 'The edited image is saved as PNG (.png).',
1224
+ // HTML viewer
1225
+ 'html.title': 'HTML',
1226
+ 'html.preview': 'Preview',
1227
+ 'html.source': 'Source',
1228
+ 'html.tooLarge': 'Preview is unavailable above {limit} bytes. Showing the first part of the source.',
1229
+ 'html.tooComplex': 'This document is too complex to preview safely. Showing the source.',
1224
1230
  // Fallback viewer
1225
1231
  'fallback.title.text': 'Text preview',
1226
1232
  'fallback.title.hex': 'Hex preview',
@@ -1076,6 +1076,9 @@ export const CATALOG_JA = {
1076
1076
  'image.tool.detectSprites.unavailable': 'この環境ではスプライト検出を利用できません',
1077
1077
  'image.spriteDetection.working': 'スプライトを検出中…', 'image.spriteDetection.failed': 'スプライトの検出に失敗しました。',
1078
1078
  'image.saveAsPng': 'PNGとして保存', 'image.saveAsPng.note': '編集した画像はPNG(.png)として保存されます。',
1079
+ 'html.title': 'HTML', 'html.preview': 'プレビュー', 'html.source': 'ソース',
1080
+ 'html.tooLarge': '{limit}バイトを超えるためプレビューできません。ソースの先頭を表示します。',
1081
+ 'html.tooComplex': '文書が複雑すぎるため安全にプレビューできません。ソースを表示します。',
1079
1082
  'fallback.title.text': 'テキストプレビュー', 'fallback.title.hex': '16進数プレビュー',
1080
1083
  'fallback.truncated': 'プレビューは先頭{bytes}バイトまでに切り詰められました。',
1081
1084
  'fallback.reason.missing-dependency': 'この環境では完全なビューアーを読み込めませんでした。',
@@ -1202,6 +1202,9 @@ export const CATALOG_KO = {
1202
1202
  'image.saveAsPng': 'PNG로 저장',
1203
1203
  'image.saveAsPng.note': '편집된 이미지는 PNG(.png)로 저장됩니다.',
1204
1204
  // 폴백 뷰어
1205
+ 'html.title': 'HTML', 'html.preview': '미리보기', 'html.source': '소스',
1206
+ 'html.tooLarge': '{limit}바이트를 초과해 미리보기를 표시할 수 없습니다. 소스의 앞부분을 표시합니다.',
1207
+ 'html.tooComplex': '문서가 너무 복잡해 미리보기를 표시할 수 없습니다. 소스를 표시합니다.',
1205
1208
  'fallback.title.text': '텍스트 미리보기',
1206
1209
  'fallback.title.hex': '16진수 미리보기',
1207
1210
  'fallback.truncated': '미리보기가 처음 {bytes}바이트로 잘렸습니다.',
@@ -1042,6 +1042,9 @@ export const CATALOG_ZH_CN = {
1042
1042
  'image.tool.detectSprites': '检测精灵图', 'image.tool.detectSprites.unavailable': '此平台无法检测精灵图',
1043
1043
  'image.spriteDetection.working': '正在检测精灵图…', 'image.spriteDetection.failed': '精灵图检测失败。',
1044
1044
  'image.saveAsPng': '另存为 PNG', 'image.saveAsPng.note': '编辑后的图像将保存为 PNG(.png)。',
1045
+ 'html.title': 'HTML', 'html.preview': '预览', 'html.source': '源代码',
1046
+ 'html.tooLarge': '文件超过 {limit} 字节,无法预览。正在显示源代码的开头部分。',
1047
+ 'html.tooComplex': '文档过于复杂,无法安全预览。正在显示源代码。',
1045
1048
  'fallback.title.text': '文本预览', 'fallback.title.hex': '十六进制预览',
1046
1049
  'fallback.truncated': '预览已截断,仅显示前 {bytes} 字节。',
1047
1050
  'fallback.reason.missing-dependency': '此平台无法加载完整查看器。',
@@ -124,11 +124,12 @@ export declare const HWP_VIEWER_DESCRIPTOR: ViewerDescriptor;
124
124
  */
125
125
  export declare const IMAGE_VIEWER_DESCRIPTOR: ViewerDescriptor;
126
126
  export declare const MARKDOWN_VIEWER_DESCRIPTOR: ViewerDescriptor;
127
+ export declare const HTML_VIEWER_DESCRIPTOR: ViewerDescriptor;
127
128
  export declare const ARCHIVE_MAGIC_SIGNATURES: readonly MagicSignature[];
128
129
  /** Archive is intentionally lower priority than Office container viewers. */
129
130
  export declare const ARCHIVE_VIEWER_DESCRIPTOR: ViewerDescriptor;
130
131
  export declare const CORE_VIEWER_DESCRIPTORS: readonly ViewerDescriptor[];
131
- export { looksLikeHar, looksLikeNotebook, looksLikeJsonDocument, looksLikeJsonl, looksLikeLatex, looksLikeOpenVinoIr, looksLikeProto, looksLikeXml, sniffTextViewer } from './sniff.js';
132
+ export { looksLikeHar, looksLikeNotebook, looksLikeJsonDocument, looksLikeJsonl, looksLikeHtml, looksLikeLatex, looksLikeOpenVinoIr, looksLikeProto, looksLikeXml, sniffTextViewer } from './sniff.js';
132
133
  export { CONTAINER_SNIFF_BYTES, OFFICE_CONTAINER_BY_EXT, OLE_MAGIC, ZIP_MAGIC, sniffContainer, type ContainerKind } from './container.js';
133
134
  export { probeContainer } from './probe.js';
134
135
  export interface DetectionResult {
@@ -240,6 +240,10 @@ export const MARKDOWN_VIEWER_DESCRIPTOR = {
240
240
  extensions: ['md', 'markdown', 'mdown', 'mkdn', 'mkd'], priority: 10,
241
241
  requiredServices: [], optionalServices: ['clipboard', 'navigation', 'documentAssets', 'writeback', 'save']
242
242
  };
243
+ export const HTML_VIEWER_DESCRIPTOR = {
244
+ id: 'html', displayNameKey: 'html.title', extensions: ['html', 'htm'], priority: 10,
245
+ requiredServices: [], optionalServices: []
246
+ };
243
247
  export const ARCHIVE_MAGIC_SIGNATURES = [
244
248
  [{ offset: 0, bytes: [0x50, 0x4b, 0x03, 0x04] }], [{ offset: 0, bytes: [0x50, 0x4b, 0x05, 0x06] }],
245
249
  [{ offset: 257, bytes: [0x75, 0x73, 0x74, 0x61, 0x72] }], [{ offset: 0, bytes: [0x1f, 0x8b] }],
@@ -294,6 +298,7 @@ export const CORE_VIEWER_DESCRIPTORS = [
294
298
  EXCEL_VIEWER_DESCRIPTOR,
295
299
  IMAGE_VIEWER_DESCRIPTOR,
296
300
  MARKDOWN_VIEWER_DESCRIPTOR,
301
+ HTML_VIEWER_DESCRIPTOR,
297
302
  NOTEBOOK_VIEWER_DESCRIPTOR,
298
303
  ARCHIVE_VIEWER_DESCRIPTOR,
299
304
  CSV_VIEWER_DESCRIPTOR,
@@ -306,7 +311,7 @@ export const CORE_VIEWER_DESCRIPTORS = [
306
311
  LATEX_VIEWER_DESCRIPTOR,
307
312
  FALLBACK_VIEWER_DESCRIPTOR
308
313
  ];
309
- export { looksLikeHar, looksLikeNotebook, looksLikeJsonDocument, looksLikeJsonl, looksLikeLatex, looksLikeOpenVinoIr, looksLikeProto, looksLikeXml, sniffTextViewer } from './sniff.js';
314
+ export { looksLikeHar, looksLikeNotebook, looksLikeJsonDocument, looksLikeJsonl, looksLikeHtml, looksLikeLatex, looksLikeOpenVinoIr, looksLikeProto, looksLikeXml, sniffTextViewer } from './sniff.js';
310
315
  export { CONTAINER_SNIFF_BYTES, OFFICE_CONTAINER_BY_EXT, OLE_MAGIC, ZIP_MAGIC, sniffContainer } from './container.js';
311
316
  export { probeContainer } from './probe.js';
312
317
  /**
@@ -28,6 +28,8 @@ export declare function looksLikeProto(text: string): boolean;
28
28
  * plain TeX files the viewer cannot read structurally (docs/viewers/latex.md §1).
29
29
  */
30
30
  export declare function looksLikeLatex(text: string): boolean;
31
+ /** Claim only recognizable HTML documents; fragments need a file extension. */
32
+ export declare function looksLikeHtml(text: string): boolean;
31
33
  /**
32
34
  * True if `text` starts like an XML document: after a BOM, whitespace, the
33
35
  * declaration, comments, processing instructions and a DOCTYPE comes a start
@@ -43,4 +45,4 @@ export declare function looksLikeXml(text: string): boolean;
43
45
  * multi-line object streams so a `{…}\n{…}` file is never mis-claimed as JSON;
44
46
  * if no jsonl viewer is registered the caller falls through to fallback.
45
47
  */
46
- export declare function sniffTextViewer(text: string): 'jsonl' | 'har' | 'notebook' | 'json' | 'openvino' | 'xml' | 'proto' | 'latex' | null;
48
+ export declare function sniffTextViewer(text: string): 'jsonl' | 'har' | 'notebook' | 'json' | 'html' | 'openvino' | 'xml' | 'proto' | 'latex' | null;
@@ -86,6 +86,12 @@ export function looksLikeLatex(text) {
86
86
  const sample = text.slice(0, 64 * 1024).replace(/(^|[^\\])%.*$/gm, '$1');
87
87
  return /(^|[^\\])\\documentclass\s*(\[[^\]]*\])?\s*\{[^}]+\}/.test(sample);
88
88
  }
89
+ /** Claim only recognizable HTML documents; fragments need a file extension. */
90
+ export function looksLikeHtml(text) {
91
+ const sample = text.slice(0, 64 * 1024).replace(/^\uFEFF?\s*/, '');
92
+ const start = sample.replace(/^(?:<!--[\s\S]*?-->\s*)*/, '');
93
+ return /^<!doctype\s+html(?:\s|>|\[)/i.test(start) || /^<html(?:\s|>)/i.test(start);
94
+ }
89
95
  /**
90
96
  * True if `text` starts like an XML document: after a BOM, whitespace, the
91
97
  * declaration, comments, processing instructions and a DOCTYPE comes a start
@@ -158,6 +164,8 @@ export function sniffTextViewer(text) {
158
164
  return 'notebook';
159
165
  if (looksLikeJsonDocument(text))
160
166
  return 'json';
167
+ if (looksLikeHtml(text))
168
+ return 'html';
161
169
  // An OpenVINO IR is `.xml`, an extension no viewer claims, so its `<net
162
170
  // version><layers>` root is the only route to the viewer.
163
171
  if (looksLikeOpenVinoIr(text))
@@ -0,0 +1,15 @@
1
+
2
+ .omni-viewer--html{all:initial}
3
+ :host,.omni-viewer--html{display:block;height:100%;box-sizing:border-box;color:var(--omni-fg,#d4d4d4);background:var(--omni-bg,#1e1e1e);font:var(--omni-font-size,13px)/1.4 var(--omni-font,system-ui,sans-serif)}
4
+ .omni-html,.omni-html *{box-sizing:border-box}
5
+ .omni-html{display:flex;flex-direction:column;height:100%;min-height:0}
6
+ .omni-html__header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;border-bottom:1px solid var(--omni-border,#3c3c3c)}
7
+ .omni-html__title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
8
+ .omni-html__tabs{display:flex;gap:4px;flex:none}
9
+ .omni-html__tabs button{font:inherit;color:inherit;background:transparent;border:1px solid var(--omni-border,#3c3c3c);border-radius:4px;padding:4px 10px;cursor:pointer}
10
+ .omni-html__tabs button[aria-pressed=true]{background:var(--omni-accent,#0e639c);color:var(--omni-accent-fg,#fff)}
11
+ .omni-html__tabs button:disabled{opacity:.5;cursor:default}
12
+ .omni-html__preview{display:block;flex:1;min-height:0;width:100%;border:0;background:#fff}
13
+ .omni-html__source{flex:1;min-height:0;overflow:auto;margin:0;padding:12px;font:13px/1.5 var(--omni-font-mono,monospace);white-space:pre;tab-size:4}
14
+ .omni-html__note{margin:0;padding:8px 12px;color:var(--omni-warning-fg,#cca700);border-bottom:1px solid var(--omni-border,#3c3c3c)}
15
+ .omni-html [hidden]{display:none!important}
@@ -0,0 +1,19 @@
1
+ import type { HostContext } from '../../host/index.js';
2
+ import { type MountOptions, type ViewerHandle, type ViewerInput } from '../types.js';
3
+ export { htmlViewerCss } from './styles.js';
4
+ export declare const HTML_VIEWER_META: {
5
+ id: string;
6
+ displayNameKey: string;
7
+ extensions: string[];
8
+ priority: number;
9
+ requiredServices: readonly [];
10
+ optionalServices: readonly [];
11
+ inputOwnership: "borrows";
12
+ };
13
+ /** Avoid decoding and embedding an unbounded document in both the source pane and iframe. */
14
+ export declare const HTML_PREVIEW_MAX_BYTES: number;
15
+ export declare const HTML_PREVIEW_MAX_DELIMITERS = 20000;
16
+ /** Honor a BOM or an early HTML charset declaration before decoding bytes. */
17
+ export declare function decodeHtmlText(data: Uint8Array): string;
18
+ export declare function htmlPreviewDocument(source: string): string;
19
+ export declare function mountHtmlViewer(input: ViewerInput, container: HTMLElement, ctx: HostContext, options?: MountOptions): Promise<ViewerHandle>;
@@ -0,0 +1,254 @@
1
+ import { MountAbortedError, VIEWER_ROOT_CLASS } from '../types.js';
2
+ import { htmlViewerCss } from './styles.js';
3
+ export { htmlViewerCss } from './styles.js';
4
+ export const HTML_VIEWER_META = {
5
+ id: 'html', displayNameKey: 'html.title', extensions: ['html', 'htm'], priority: 10,
6
+ requiredServices: [], optionalServices: [],
7
+ inputOwnership: 'borrows'
8
+ };
9
+ /** Avoid decoding and embedding an unbounded document in both the source pane and iframe. */
10
+ export const HTML_PREVIEW_MAX_BYTES = 8 * 1024 * 1024;
11
+ export const HTML_PREVIEW_MAX_DELIMITERS = 20_000;
12
+ const SOURCE_HEAD_BYTES = 256 * 1024;
13
+ function hasExcessiveMarkup(source) {
14
+ // Every parsed element needs source markup. Count delimiters before any
15
+ // DOM parsing so dense, small files cannot create an unbounded DOM tree.
16
+ let count = 0;
17
+ for (let index = source.indexOf('<'); index !== -1; index = source.indexOf('<', index + 1)) {
18
+ if (++count > HTML_PREVIEW_MAX_DELIMITERS)
19
+ return true;
20
+ }
21
+ return false;
22
+ }
23
+ // The iframe has an opaque origin and no sandbox permissions. This policy is
24
+ // deliberately inside srcdoc as well: host CSPs cannot be relied on to protect
25
+ // the frame in every adapter. Inline CSS and embedded images remain usable.
26
+ const PREVIEW_CSP = "default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'; navigate-to 'none'";
27
+ function tagEnd(source, from) {
28
+ let quote = '';
29
+ for (let index = from; index < source.length; index++) {
30
+ const char = source[index];
31
+ if (quote) {
32
+ if (char === quote)
33
+ quote = '';
34
+ }
35
+ else if (char === '"' || char === "'")
36
+ quote = char;
37
+ else if (char === '>')
38
+ return index;
39
+ }
40
+ return -1;
41
+ }
42
+ /** Scan opening tags without mistaking comments, attributes, or raw text for markup. */
43
+ function* openingTags(source) {
44
+ let index = 0;
45
+ let templateDepth = 0;
46
+ while (index < source.length) {
47
+ const start = source.indexOf('<', index);
48
+ if (start < 0)
49
+ return;
50
+ if (source.startsWith('<!--', start)) {
51
+ const end = source.indexOf('-->', start + 4);
52
+ index = end < 0 ? source.length : end + 3;
53
+ continue;
54
+ }
55
+ const templateEnd = /^<\/template(?=[\s/>])/i.exec(source.slice(start, start + 32));
56
+ if (templateEnd && templateDepth > 0) {
57
+ const end = tagEnd(source, start + templateEnd[0].length);
58
+ if (end < 0)
59
+ return;
60
+ templateDepth--;
61
+ index = end + 1;
62
+ continue;
63
+ }
64
+ const found = /^<([a-z][\w:-]*)\b/i.exec(source.slice(start, start + 64));
65
+ if (!found) {
66
+ index = start + 1;
67
+ continue;
68
+ }
69
+ const name = found[1].toLowerCase();
70
+ const end = tagEnd(source, start + found[0].length);
71
+ if (end < 0)
72
+ return;
73
+ index = end + 1;
74
+ if (name === 'template') {
75
+ templateDepth++;
76
+ continue;
77
+ }
78
+ if (templateDepth === 0)
79
+ yield { name, attributes: source.slice(start + found[0].length, end) };
80
+ if (['script', 'style', 'textarea', 'title', 'xmp', 'noscript', 'noembed', 'noframes'].includes(name)) {
81
+ const closing = new RegExp(`</${name}(?=[\\s/>])`, 'ig');
82
+ closing.lastIndex = index;
83
+ const match = closing.exec(source);
84
+ const closeEnd = match ? tagEnd(source, closing.lastIndex) : -1;
85
+ index = closeEnd < 0 ? source.length : closeEnd + 1;
86
+ }
87
+ }
88
+ }
89
+ function safeRootAttributes(source, tag) {
90
+ // Parsing a complete document in a template drops its root elements. Read
91
+ // their opening tags separately so selectors like body.page still work.
92
+ let match;
93
+ for (const candidate of openingTags(source)) {
94
+ if (candidate.name === tag) {
95
+ match = candidate;
96
+ break;
97
+ }
98
+ }
99
+ if (!match)
100
+ return '';
101
+ const holder = document.createElement('template');
102
+ holder.innerHTML = `<div${match.attributes}></div>`;
103
+ const element = holder.content.firstElementChild;
104
+ if (!element)
105
+ return '';
106
+ return [...element.attributes]
107
+ .filter(({ name }) => ['class', 'id', 'lang', 'dir', 'style'].includes(name) || name.startsWith('data-'))
108
+ .map(({ name, value }) => ` ${name}="${value.replace(/&/g, '&amp;').replace(/"/g, '&quot;')}\"`)
109
+ .join('');
110
+ }
111
+ /** Honor a BOM or an early HTML charset declaration before decoding bytes. */
112
+ export function decodeHtmlText(data) {
113
+ let encoding = 'utf-8';
114
+ if (data[0] === 0xff && data[1] === 0xfe)
115
+ encoding = 'utf-16le';
116
+ else if (data[0] === 0xfe && data[1] === 0xff)
117
+ encoding = 'utf-16be';
118
+ else if (!(data[0] === 0xef && data[1] === 0xbb && data[2] === 0xbf)) {
119
+ const head = Array.from(data.subarray(0, 1024), byte => String.fromCharCode(byte)).join('');
120
+ for (const tag of openingTags(head)) {
121
+ if (tag.name !== 'meta')
122
+ continue;
123
+ const holder = document.createElement('template');
124
+ holder.innerHTML = `<meta${tag.attributes}>`;
125
+ const meta = holder.content.firstElementChild;
126
+ const contentCharset = /charset\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s;]+))/i.exec(meta?.getAttribute('content') ?? '');
127
+ const declared = meta?.getAttribute('charset') ??
128
+ (meta?.getAttribute('http-equiv')?.toLowerCase() === 'content-type'
129
+ ? contentCharset?.[1] ?? contentCharset?.[2] ?? contentCharset?.[3]
130
+ : undefined);
131
+ if (declared) {
132
+ encoding = declared;
133
+ break;
134
+ }
135
+ }
136
+ }
137
+ try {
138
+ return new TextDecoder(encoding).decode(data);
139
+ }
140
+ catch {
141
+ return new TextDecoder('utf-8').decode(data);
142
+ }
143
+ }
144
+ export function htmlPreviewDocument(source) {
145
+ // Template contents are inert and do not load resources while being parsed.
146
+ // Strip navigation and resource attributes too: frame self-navigation is
147
+ // possible in some engines even with an empty sandbox and navigate-to CSP.
148
+ const template = document.createElement('template');
149
+ template.innerHTML = source;
150
+ // <noscript> changes parsing mode when the sandbox disables scripts: its
151
+ // text can become active markup in the iframe, so remove the whole node.
152
+ // Nested templates can become declarative shadow roots when reparsed in
153
+ // srcdoc, and their children are invisible to querySelectorAll here.
154
+ template.content.querySelectorAll('script,noscript,template,base,link,meta,iframe,frame,frameset,object,embed,form,portal').forEach(node => node.remove());
155
+ template.content.querySelectorAll('*').forEach(node => {
156
+ for (const attribute of [...node.attributes]) {
157
+ const name = attribute.name.toLowerCase();
158
+ const value = attribute.value.trim();
159
+ if (name.startsWith('on') || ['srcdoc', 'srcset', 'ping', 'action', 'formaction', 'poster', 'background'].includes(name)) {
160
+ node.removeAttributeNode(attribute);
161
+ }
162
+ else if (name === 'href' || name === 'xlink:href') {
163
+ if (!value.startsWith('#'))
164
+ node.removeAttributeNode(attribute);
165
+ }
166
+ else if (name === 'src') {
167
+ if (node.localName !== 'img' || !/^data:image\/(?:png|jpeg|gif|webp|svg\+xml)(?:;[^,]*)?,/i.test(value))
168
+ node.removeAttributeNode(attribute);
169
+ }
170
+ }
171
+ });
172
+ // The policy is before all untrusted content, including malformed heads.
173
+ return `<!doctype html><html${safeRootAttributes(source, 'html')}><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${PREVIEW_CSP}"></head><body${safeRootAttributes(source, 'body')}>${template.innerHTML}</body></html>`;
174
+ }
175
+ export async function mountHtmlViewer(input, container, ctx, options = {}) {
176
+ if (options.signal?.aborted)
177
+ throw new MountAbortedError();
178
+ const isolation = options.styleIsolation ?? 'shadow';
179
+ let root;
180
+ if (isolation === 'shadow' && typeof container.attachShadow === 'function') {
181
+ root = container.shadowRoot ?? container.attachShadow({ mode: 'open' });
182
+ const style = document.createElement('style');
183
+ style.textContent = htmlViewerCss;
184
+ root.append(style);
185
+ }
186
+ else {
187
+ container.classList.add(VIEWER_ROOT_CLASS, 'omni-viewer--html');
188
+ root = container;
189
+ }
190
+ const t = ctx.i18n.t.bind(ctx.i18n);
191
+ const frame = document.createElement('section');
192
+ frame.className = 'omni-html';
193
+ const header = document.createElement('header');
194
+ header.className = 'omni-html__header';
195
+ const title = document.createElement('strong');
196
+ title.className = 'omni-html__title';
197
+ title.textContent = input.fileName;
198
+ header.append(title);
199
+ const tabs = document.createElement('div');
200
+ tabs.className = 'omni-html__tabs';
201
+ const previewButton = document.createElement('button');
202
+ previewButton.type = 'button';
203
+ previewButton.textContent = t('html.preview');
204
+ const sourceButton = document.createElement('button');
205
+ sourceButton.type = 'button';
206
+ sourceButton.textContent = t('html.source');
207
+ tabs.append(previewButton, sourceButton);
208
+ header.append(tabs);
209
+ frame.append(header);
210
+ const tooLarge = input.data.byteLength > HTML_PREVIEW_MAX_BYTES;
211
+ const bytes = tooLarge ? input.data.subarray(0, SOURCE_HEAD_BYTES) : input.data;
212
+ const source = decodeHtmlText(bytes);
213
+ const tooComplex = !tooLarge && hasExcessiveMarkup(source);
214
+ const sourceOnly = tooLarge || tooComplex;
215
+ const preview = document.createElement('iframe');
216
+ preview.className = 'omni-html__preview';
217
+ preview.title = t('html.preview');
218
+ preview.setAttribute('sandbox', '');
219
+ preview.referrerPolicy = 'no-referrer';
220
+ // An oversized document is source-only, avoiding partial active markup.
221
+ if (!sourceOnly)
222
+ preview.srcdoc = htmlPreviewDocument(source);
223
+ const sourcePane = document.createElement('pre');
224
+ sourcePane.className = 'omni-html__source';
225
+ sourcePane.textContent = source;
226
+ if (sourceOnly) {
227
+ const note = document.createElement('p');
228
+ note.className = 'omni-html__note';
229
+ note.textContent = tooLarge
230
+ ? t('html.tooLarge', { limit: HTML_PREVIEW_MAX_BYTES })
231
+ : t('html.tooComplex');
232
+ frame.append(note);
233
+ }
234
+ frame.append(preview, sourcePane);
235
+ root.append(frame);
236
+ const setView = (view) => {
237
+ preview.hidden = view !== 'preview';
238
+ sourcePane.hidden = view !== 'source';
239
+ previewButton.setAttribute('aria-pressed', String(view === 'preview'));
240
+ sourceButton.setAttribute('aria-pressed', String(view === 'source'));
241
+ };
242
+ previewButton.disabled = sourceOnly;
243
+ previewButton.addEventListener('click', () => setView('preview'));
244
+ sourceButton.addEventListener('click', () => setView('source'));
245
+ setView(sourceOnly ? 'source' : 'preview');
246
+ return {
247
+ dispose() {
248
+ preview.removeAttribute('srcdoc');
249
+ root.replaceChildren();
250
+ if (root === container)
251
+ container.classList.remove(VIEWER_ROOT_CLASS, 'omni-viewer--html');
252
+ }
253
+ };
254
+ }
@@ -0,0 +1 @@
1
+ export declare const htmlViewerCss = "\n.omni-viewer--html{all:initial}\n:host,.omni-viewer--html{display:block;height:100%;box-sizing:border-box;color:var(--omni-fg,#d4d4d4);background:var(--omni-bg,#1e1e1e);font:var(--omni-font-size,13px)/1.4 var(--omni-font,system-ui,sans-serif)}\n.omni-html,.omni-html *{box-sizing:border-box}\n.omni-html{display:flex;flex-direction:column;height:100%;min-height:0}\n.omni-html__header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;border-bottom:1px solid var(--omni-border,#3c3c3c)}\n.omni-html__title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.omni-html__tabs{display:flex;gap:4px;flex:none}\n.omni-html__tabs button{font:inherit;color:inherit;background:transparent;border:1px solid var(--omni-border,#3c3c3c);border-radius:4px;padding:4px 10px;cursor:pointer}\n.omni-html__tabs button[aria-pressed=true]{background:var(--omni-accent,#0e639c);color:var(--omni-accent-fg,#fff)}\n.omni-html__tabs button:disabled{opacity:.5;cursor:default}\n.omni-html__preview{display:block;flex:1;min-height:0;width:100%;border:0;background:#fff}\n.omni-html__source{flex:1;min-height:0;overflow:auto;margin:0;padding:12px;font:13px/1.5 var(--omni-font-mono,monospace);white-space:pre;tab-size:4}\n.omni-html__note{margin:0;padding:8px 12px;color:var(--omni-warning-fg,#cca700);border-bottom:1px solid var(--omni-border,#3c3c3c)}\n.omni-html [hidden]{display:none!important}\n";
@@ -0,0 +1,16 @@
1
+ export const htmlViewerCss = `
2
+ .omni-viewer--html{all:initial}
3
+ :host,.omni-viewer--html{display:block;height:100%;box-sizing:border-box;color:var(--omni-fg,#d4d4d4);background:var(--omni-bg,#1e1e1e);font:var(--omni-font-size,13px)/1.4 var(--omni-font,system-ui,sans-serif)}
4
+ .omni-html,.omni-html *{box-sizing:border-box}
5
+ .omni-html{display:flex;flex-direction:column;height:100%;min-height:0}
6
+ .omni-html__header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;border-bottom:1px solid var(--omni-border,#3c3c3c)}
7
+ .omni-html__title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
8
+ .omni-html__tabs{display:flex;gap:4px;flex:none}
9
+ .omni-html__tabs button{font:inherit;color:inherit;background:transparent;border:1px solid var(--omni-border,#3c3c3c);border-radius:4px;padding:4px 10px;cursor:pointer}
10
+ .omni-html__tabs button[aria-pressed=true]{background:var(--omni-accent,#0e639c);color:var(--omni-accent-fg,#fff)}
11
+ .omni-html__tabs button:disabled{opacity:.5;cursor:default}
12
+ .omni-html__preview{display:block;flex:1;min-height:0;width:100%;border:0;background:#fff}
13
+ .omni-html__source{flex:1;min-height:0;overflow:auto;margin:0;padding:12px;font:13px/1.5 var(--omni-font-mono,monospace);white-space:pre;tab-size:4}
14
+ .omni-html__note{margin:0;padding:8px 12px;color:var(--omni-warning-fg,#cca700);border-bottom:1px solid var(--omni-border,#3c3c3c)}
15
+ .omni-html [hidden]{display:none!important}
16
+ `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omni-viewer-core",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Shared parsing + rendering core for Omni Viewer (vscode / chrome / obsidian / web)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -399,6 +399,10 @@
399
399
  "types": "./dist/viewers/fallback/index.d.ts",
400
400
  "default": "./dist/viewers/fallback/index.js"
401
401
  },
402
+ "./viewers/html": {
403
+ "types": "./dist/viewers/html/index.d.ts",
404
+ "default": "./dist/viewers/html/index.js"
405
+ },
402
406
  "./viewers/pdf": {
403
407
  "types": "./dist/viewers/pdf/index.d.ts",
404
408
  "default": "./dist/viewers/pdf/index.js"