@lamberl-lee/file-preview 0.2.0 → 0.3.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.
Files changed (48) hide show
  1. package/README.md +6 -0
  2. package/dist/LargeFileGate.d.ts +23 -3
  3. package/dist/LargeFileGate.js +44 -40
  4. package/dist/LargeFileGate.js.map +1 -1
  5. package/dist/PluginPreviewRenderer.d.ts +20 -1
  6. package/dist/PluginPreviewRenderer.js +51 -10
  7. package/dist/PluginPreviewRenderer.js.map +1 -1
  8. package/dist/PreviewErrorBoundary.d.ts +2 -0
  9. package/dist/PreviewErrorBoundary.js +11 -2
  10. package/dist/PreviewErrorBoundary.js.map +1 -1
  11. package/dist/core/detect-meta.d.ts +73 -0
  12. package/dist/core/detect-meta.js +81 -0
  13. package/dist/core/detect-meta.js.map +1 -0
  14. package/dist/core/magic-bytes.d.ts +56 -0
  15. package/dist/core/magic-bytes.js +97 -0
  16. package/dist/core/magic-bytes.js.map +1 -0
  17. package/dist/core/plugin.d.ts +2 -2
  18. package/dist/core/plugin.js +5 -3
  19. package/dist/core/plugin.js.map +1 -1
  20. package/dist/core/preview-error.d.ts +35 -0
  21. package/dist/core/preview-error.js +39 -0
  22. package/dist/core/preview-error.js.map +1 -0
  23. package/dist/core/registry.d.ts +1 -0
  24. package/dist/index.d.ts +4 -1
  25. package/dist/index.js +21 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/plugins/audio-plugin.d.ts +1 -0
  28. package/dist/plugins/builtin-plugins.d.ts +1 -0
  29. package/dist/plugins/csv-plugin.d.ts +1 -0
  30. package/dist/plugins/docx-plugin.d.ts +1 -0
  31. package/dist/plugins/epub-plugin.d.ts +1 -0
  32. package/dist/plugins/html-plugin.d.ts +1 -0
  33. package/dist/plugins/image-plugin.d.ts +1 -0
  34. package/dist/plugins/markdown-plugin.d.ts +1 -0
  35. package/dist/plugins/pdf-plugin.d.ts +1 -0
  36. package/dist/plugins/pptx-plugin.d.ts +1 -0
  37. package/dist/plugins/rtf-plugin.d.ts +1 -0
  38. package/dist/plugins/source-code-plugin.d.ts +1 -0
  39. package/dist/plugins/svg-plugin.d.ts +1 -0
  40. package/dist/plugins/text-plugin.d.ts +1 -0
  41. package/dist/plugins/video-plugin.d.ts +1 -0
  42. package/dist/plugins/xlsx-plugin.d.ts +1 -0
  43. package/dist/plugins/zip-plugin.d.ts +1 -0
  44. package/dist/remote-url.d.ts +20 -5
  45. package/dist/remote-url.js +52 -128
  46. package/dist/remote-url.js.map +1 -1
  47. package/docs/supported-formats.md +143 -0
  48. package/package.json +12 -11
package/README.md CHANGED
@@ -148,6 +148,12 @@ A registry built from a subset of plugins also means the bundler will tree-shake
148
148
 
149
149
  PDF · DOCX · PPTX · XLSX · EPUB · RTF · Markdown · HTML · code (Shiki-highlighted) · plain text · CSV · JSON · SVG · images · video · audio · ZIP listing.
150
150
 
151
+ > **Before integrating, read [`docs/supported-formats.md`](./docs/supported-formats.md).**
152
+ > It documents *what each format actually renders* — and, more importantly, what it
153
+ > doesn't (no PowerPoint animations, no XLSX formula recomputation, DOC/PPT/XLS
154
+ > legacy formats are downgrades, etc.). Reading this prevents the most common
155
+ > integration disappointments.
156
+
151
157
  ## Browser support
152
158
 
153
159
  | Browser | Minimum |
@@ -3,10 +3,30 @@ import { FileInfo } from './core/types.js';
3
3
 
4
4
  interface LargeFileGateProps {
5
5
  file: FileInfo;
6
- confirmed: boolean;
7
- onConfirm: () => void;
8
6
  children: React.ReactNode;
7
+ /**
8
+ * Bypass the gate entirely and render children as-is.
9
+ *
10
+ * Useful when a consumer wants to manage large-file policy themselves
11
+ * (or disable it for trusted, internal-only previews). The default
12
+ * `PluginPreviewRenderer` already applies this gate, so most consumers
13
+ * never instantiate `LargeFileGate` directly.
14
+ */
15
+ disabled?: boolean;
9
16
  }
10
- declare function LargeFileGate({ file, confirmed, onConfirm, children, }: LargeFileGateProps): react.JSX.Element;
17
+ /**
18
+ * Self-contained large-file gate.
19
+ *
20
+ * Wraps a preview and, based on `file.size`, shows:
21
+ * - 20 MB+ : a non-blocking "may be slower" banner above the preview
22
+ * - 50 MB+ : a confirm prompt (user must click "Preview anyway")
23
+ * - 100 MB+: blocks preview entirely, offers download only
24
+ *
25
+ * The confirm state is internal and resets when `file.id` changes, so the
26
+ * gate is a drop-in wrapper — no external state plumbing required.
27
+ *
28
+ * Thresholds live in `PREVIEW_SIZE_LIMITS` (performance-limits.ts).
29
+ */
30
+ declare function LargeFileGate({ file, children, disabled, }: LargeFileGateProps): react.JSX.Element;
11
31
 
12
32
  export { LargeFileGate };
@@ -1,4 +1,5 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
2
3
  import { AlertTriangleIcon, DownloadIcon } from "./icons";
3
4
  import { formatFileSize } from "./utils";
4
5
  import { getPreviewSizePolicy } from "./performance-limits";
@@ -6,26 +7,44 @@ import { downloadSource } from "./core/download";
6
7
  import "./styles/LargeFileGate.css";
7
8
  function LargeFileGate({
8
9
  file,
9
- confirmed,
10
- onConfirm,
11
- children
10
+ children,
11
+ disabled = false
12
12
  }) {
13
+ const [confirmed, setConfirmed] = useState(false);
14
+ useEffect(() => {
15
+ setConfirmed(false);
16
+ }, [file.id]);
13
17
  const policy = getPreviewSizePolicy({
14
18
  size: file.size,
15
19
  fileType: file.fileType
16
20
  });
17
- if (!policy.shouldWarn) {
21
+ if (disabled || !policy.shouldWarn) {
18
22
  return /* @__PURE__ */ jsx(Fragment, { children });
19
23
  }
20
- if (policy.level === "warning") {
21
- return /* @__PURE__ */ jsx("div", { className: "fv-gate-warning", children: /* @__PURE__ */ jsxs("div", { className: "fv-gate-warning__inner", children: [
22
- /* @__PURE__ */ jsx(AlertTriangleIcon, { size: 14 }),
23
- /* @__PURE__ */ jsxs("span", { children: [
24
- "Large file: ",
25
- formatFileSize(file.size),
26
- ". Preview may be slower."
27
- ] })
28
- ] }) });
24
+ if (policy.shouldBlock) {
25
+ return /* @__PURE__ */ jsxs("div", { className: "fv-gate-confirm", children: [
26
+ /* @__PURE__ */ jsx(AlertTriangleIcon, { size: 48, className: "fv-gate-confirm__icon fv-gate-confirm__icon--block" }),
27
+ /* @__PURE__ */ jsxs("div", { className: "fv-gate-confirm__body", children: [
28
+ /* @__PURE__ */ jsx("h3", { className: "fv-gate-confirm__title", children: "File too large to preview" }),
29
+ /* @__PURE__ */ jsx("p", { className: "fv-gate-confirm__desc", children: policy.message }),
30
+ /* @__PURE__ */ jsxs("p", { className: "fv-gate-confirm__meta", children: [
31
+ file.name,
32
+ " \xB7 ",
33
+ formatFileSize(file.size)
34
+ ] })
35
+ ] }),
36
+ /* @__PURE__ */ jsxs(
37
+ "button",
38
+ {
39
+ className: "fv-btn fv-btn--outline",
40
+ onClick: () => downloadSource(file.source, file.name, file.type),
41
+ children: [
42
+ /* @__PURE__ */ jsx(DownloadIcon, { size: 16 }),
43
+ " Download original file"
44
+ ]
45
+ }
46
+ )
47
+ ] });
29
48
  }
30
49
  if (policy.shouldConfirm && !confirmed) {
31
50
  return /* @__PURE__ */ jsxs("div", { className: "fv-gate-confirm", children: [
@@ -40,7 +59,7 @@ function LargeFileGate({
40
59
  ] })
41
60
  ] }),
42
61
  /* @__PURE__ */ jsxs("div", { className: "fv-gate-confirm__actions", children: [
43
- /* @__PURE__ */ jsx("button", { className: "fv-btn fv-btn--primary", onClick: onConfirm, children: "Preview anyway" }),
62
+ /* @__PURE__ */ jsx("button", { className: "fv-btn fv-btn--primary", onClick: () => setConfirmed(true), children: "Preview anyway" }),
44
63
  /* @__PURE__ */ jsxs(
45
64
  "button",
46
65
  {
@@ -55,32 +74,17 @@ function LargeFileGate({
55
74
  ] })
56
75
  ] });
57
76
  }
58
- if (policy.shouldBlock) {
59
- return /* @__PURE__ */ jsxs("div", { className: "fv-gate-confirm", children: [
60
- /* @__PURE__ */ jsx(AlertTriangleIcon, { size: 48, className: "fv-gate-confirm__icon fv-gate-confirm__icon--block" }),
61
- /* @__PURE__ */ jsxs("div", { className: "fv-gate-confirm__body", children: [
62
- /* @__PURE__ */ jsx("h3", { className: "fv-gate-confirm__title", children: "File too large to preview" }),
63
- /* @__PURE__ */ jsx("p", { className: "fv-gate-confirm__desc", children: policy.message }),
64
- /* @__PURE__ */ jsxs("p", { className: "fv-gate-confirm__meta", children: [
65
- file.name,
66
- " \xB7 ",
67
- formatFileSize(file.size)
68
- ] })
69
- ] }),
70
- /* @__PURE__ */ jsxs(
71
- "button",
72
- {
73
- className: "fv-btn fv-btn--outline",
74
- onClick: () => downloadSource(file.source, file.name, file.type),
75
- children: [
76
- /* @__PURE__ */ jsx(DownloadIcon, { size: 16 }),
77
- " Download original file"
78
- ]
79
- }
80
- )
81
- ] });
82
- }
83
- return /* @__PURE__ */ jsx(Fragment, { children });
77
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
78
+ /* @__PURE__ */ jsx("div", { className: "fv-gate-warning", children: /* @__PURE__ */ jsxs("div", { className: "fv-gate-warning__inner", children: [
79
+ /* @__PURE__ */ jsx(AlertTriangleIcon, { size: 14 }),
80
+ /* @__PURE__ */ jsxs("span", { children: [
81
+ "Large file: ",
82
+ formatFileSize(file.size),
83
+ ". Preview may be slower."
84
+ ] })
85
+ ] }) }),
86
+ children
87
+ ] });
84
88
  }
85
89
  export {
86
90
  LargeFileGate
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/LargeFileGate.tsx"],"sourcesContent":["import { AlertTriangleIcon, DownloadIcon } from \"./icons\";\nimport type { FileInfo } from \"./utils\";\nimport { formatFileSize } from \"./utils\";\nimport { getPreviewSizePolicy } from \"./performance-limits\";\nimport { downloadSource } from \"./core/download\";\nimport \"./styles/LargeFileGate.css\";\n\ninterface LargeFileGateProps {\n file: FileInfo;\n confirmed: boolean;\n onConfirm: () => void;\n children: React.ReactNode;\n}\n\nexport function LargeFileGate({\n file,\n confirmed,\n onConfirm,\n children,\n}: LargeFileGateProps) {\n const policy = getPreviewSizePolicy({\n size: file.size,\n fileType: file.fileType,\n });\n\n if (!policy.shouldWarn) {\n return <>{children}</>;\n }\n\n if (policy.level === \"warning\") {\n return (\n <div className=\"fv-gate-warning\">\n <div className=\"fv-gate-warning__inner\">\n <AlertTriangleIcon size={14} />\n <span>\n Large file: {formatFileSize(file.size)}. Preview may be slower.\n </span>\n </div>\n </div>\n );\n }\n\n if (policy.shouldConfirm && !confirmed) {\n return (\n <div className=\"fv-gate-confirm\">\n <AlertTriangleIcon size={48} className=\"fv-gate-confirm__icon\" />\n <div className=\"fv-gate-confirm__body\">\n <h3 className=\"fv-gate-confirm__title\">Large file preview</h3>\n <p className=\"fv-gate-confirm__desc\">\n {policy.message}\n </p>\n <p className=\"fv-gate-confirm__meta\">\n {file.name} · {formatFileSize(file.size)}\n </p>\n </div>\n <div className=\"fv-gate-confirm__actions\">\n <button className=\"fv-btn fv-btn--primary\" onClick={onConfirm}>Preview anyway</button>\n <button\n className=\"fv-btn fv-btn--outline\"\n onClick={() => downloadSource(file.source, file.name, file.type)}\n >\n <DownloadIcon size={16} /> Download\n </button>\n </div>\n </div>\n );\n }\n\n if (policy.shouldBlock) {\n return (\n <div className=\"fv-gate-confirm\">\n <AlertTriangleIcon size={48} className=\"fv-gate-confirm__icon fv-gate-confirm__icon--block\" />\n <div className=\"fv-gate-confirm__body\">\n <h3 className=\"fv-gate-confirm__title\">File too large to preview</h3>\n <p className=\"fv-gate-confirm__desc\">\n {policy.message}\n </p>\n <p className=\"fv-gate-confirm__meta\">\n {file.name} · {formatFileSize(file.size)}\n </p>\n </div>\n <button\n className=\"fv-btn fv-btn--outline\"\n onClick={() => downloadSource(file.source, file.name, file.type)}\n >\n <DownloadIcon size={16} /> Download original file\n </button>\n </div>\n );\n }\n\n return <>{children}</>;\n}\n"],"mappings":"AA0BW,wBAQD,YARC;AA1BX,SAAS,mBAAmB,oBAAoB;AAEhD,SAAS,sBAAsB;AAC/B,SAAS,4BAA4B;AACrC,SAAS,sBAAsB;AAC/B,OAAO;AASA,SAAS,cAAc;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAAuB;AACrB,QAAM,SAAS,qBAAqB;AAAA,IAClC,MAAM,KAAK;AAAA,IACX,UAAU,KAAK;AAAA,EACjB,CAAC;AAED,MAAI,CAAC,OAAO,YAAY;AACtB,WAAO,gCAAG,UAAS;AAAA,EACrB;AAEA,MAAI,OAAO,UAAU,WAAW;AAC9B,WACE,oBAAC,SAAI,WAAU,mBACb,+BAAC,SAAI,WAAU,0BACb;AAAA,0BAAC,qBAAkB,MAAM,IAAI;AAAA,MAC7B,qBAAC,UAAK;AAAA;AAAA,QACS,eAAe,KAAK,IAAI;AAAA,QAAE;AAAA,SACzC;AAAA,OACF,GACF;AAAA,EAEJ;AAEA,MAAI,OAAO,iBAAiB,CAAC,WAAW;AACtC,WACE,qBAAC,SAAI,WAAU,mBACb;AAAA,0BAAC,qBAAkB,MAAM,IAAI,WAAU,yBAAwB;AAAA,MAC/D,qBAAC,SAAI,WAAU,yBACb;AAAA,4BAAC,QAAG,WAAU,0BAAyB,gCAAkB;AAAA,QACzD,oBAAC,OAAE,WAAU,yBACV,iBAAO,SACV;AAAA,QACA,qBAAC,OAAE,WAAU,yBACV;AAAA,eAAK;AAAA,UAAK;AAAA,UAAI,eAAe,KAAK,IAAI;AAAA,WACzC;AAAA,SACF;AAAA,MACA,qBAAC,SAAI,WAAU,4BACb;AAAA,4BAAC,YAAO,WAAU,0BAAyB,SAAS,WAAW,4BAAc;AAAA,QAC7E;AAAA,UAAC;AAAA;AAAA,YACC,WAAU;AAAA,YACV,SAAS,MAAM,eAAe,KAAK,QAAQ,KAAK,MAAM,KAAK,IAAI;AAAA,YAE/D;AAAA,kCAAC,gBAAa,MAAM,IAAI;AAAA,cAAE;AAAA;AAAA;AAAA,QAC5B;AAAA,SACF;AAAA,OACF;AAAA,EAEJ;AAEA,MAAI,OAAO,aAAa;AACtB,WACE,qBAAC,SAAI,WAAU,mBACb;AAAA,0BAAC,qBAAkB,MAAM,IAAI,WAAU,sDAAqD;AAAA,MAC5F,qBAAC,SAAI,WAAU,yBACb;AAAA,4BAAC,QAAG,WAAU,0BAAyB,uCAAyB;AAAA,QAChE,oBAAC,OAAE,WAAU,yBACV,iBAAO,SACV;AAAA,QACA,qBAAC,OAAE,WAAU,yBACV;AAAA,eAAK;AAAA,UAAK;AAAA,UAAI,eAAe,KAAK,IAAI;AAAA,WACzC;AAAA,SACF;AAAA,MACA;AAAA,QAAC;AAAA;AAAA,UACC,WAAU;AAAA,UACV,SAAS,MAAM,eAAe,KAAK,QAAQ,KAAK,MAAM,KAAK,IAAI;AAAA,UAE/D;AAAA,gCAAC,gBAAa,MAAM,IAAI;AAAA,YAAE;AAAA;AAAA;AAAA,MAC5B;AAAA,OACF;AAAA,EAEJ;AAEA,SAAO,gCAAG,UAAS;AACrB;","names":[]}
1
+ {"version":3,"sources":["../src/LargeFileGate.tsx"],"sourcesContent":["import { useEffect, useState } from \"react\";\nimport { AlertTriangleIcon, DownloadIcon } from \"./icons\";\nimport type { FileInfo } from \"./utils\";\nimport { formatFileSize } from \"./utils\";\nimport { getPreviewSizePolicy } from \"./performance-limits\";\nimport { downloadSource } from \"./core/download\";\nimport \"./styles/LargeFileGate.css\";\n\ninterface LargeFileGateProps {\n file: FileInfo;\n children: React.ReactNode;\n /**\n * Bypass the gate entirely and render children as-is.\n *\n * Useful when a consumer wants to manage large-file policy themselves\n * (or disable it for trusted, internal-only previews). The default\n * `PluginPreviewRenderer` already applies this gate, so most consumers\n * never instantiate `LargeFileGate` directly.\n */\n disabled?: boolean;\n}\n\n/**\n * Self-contained large-file gate.\n *\n * Wraps a preview and, based on `file.size`, shows:\n * - 20 MB+ : a non-blocking \"may be slower\" banner above the preview\n * - 50 MB+ : a confirm prompt (user must click \"Preview anyway\")\n * - 100 MB+: blocks preview entirely, offers download only\n *\n * The confirm state is internal and resets when `file.id` changes, so the\n * gate is a drop-in wrapper — no external state plumbing required.\n *\n * Thresholds live in `PREVIEW_SIZE_LIMITS` (performance-limits.ts).\n */\nexport function LargeFileGate({\n file,\n children,\n disabled = false,\n}: LargeFileGateProps) {\n const [confirmed, setConfirmed] = useState(false);\n\n // Reset the confirm decision whenever the user switches to a different\n // file — confirming one large file must not auto-confirm the next.\n useEffect(() => {\n setConfirmed(false);\n }, [file.id]);\n\n const policy = getPreviewSizePolicy({\n size: file.size,\n fileType: file.fileType,\n });\n\n if (disabled || !policy.shouldWarn) {\n return <>{children}</>;\n }\n\n // Block: never render the preview, only offer download.\n if (policy.shouldBlock) {\n return (\n <div className=\"fv-gate-confirm\">\n <AlertTriangleIcon size={48} className=\"fv-gate-confirm__icon fv-gate-confirm__icon--block\" />\n <div className=\"fv-gate-confirm__body\">\n <h3 className=\"fv-gate-confirm__title\">File too large to preview</h3>\n <p className=\"fv-gate-confirm__desc\">{policy.message}</p>\n <p className=\"fv-gate-confirm__meta\">\n {file.name} · {formatFileSize(file.size)}\n </p>\n </div>\n <button\n className=\"fv-btn fv-btn--outline\"\n onClick={() => downloadSource(file.source, file.name, file.type)}\n >\n <DownloadIcon size={16} /> Download original file\n </button>\n </div>\n );\n }\n\n // Confirm: require an explicit \"Preview anyway\" before rendering.\n if (policy.shouldConfirm && !confirmed) {\n return (\n <div className=\"fv-gate-confirm\">\n <AlertTriangleIcon size={48} className=\"fv-gate-confirm__icon\" />\n <div className=\"fv-gate-confirm__body\">\n <h3 className=\"fv-gate-confirm__title\">Large file preview</h3>\n <p className=\"fv-gate-confirm__desc\">{policy.message}</p>\n <p className=\"fv-gate-confirm__meta\">\n {file.name} · {formatFileSize(file.size)}\n </p>\n </div>\n <div className=\"fv-gate-confirm__actions\">\n <button className=\"fv-btn fv-btn--primary\" onClick={() => setConfirmed(true)}>\n Preview anyway\n </button>\n <button\n className=\"fv-btn fv-btn--outline\"\n onClick={() => downloadSource(file.source, file.name, file.type)}\n >\n <DownloadIcon size={16} /> Download\n </button>\n </div>\n </div>\n );\n }\n\n // Warning (or confirmed): render the preview with a non-blocking banner.\n return (\n <>\n <div className=\"fv-gate-warning\">\n <div className=\"fv-gate-warning__inner\">\n <AlertTriangleIcon size={14} />\n <span>\n Large file: {formatFileSize(file.size)}. Preview may be slower.\n </span>\n </div>\n </div>\n {children}\n </>\n );\n}\n"],"mappings":"AAsDW,wBAWD,YAXC;AAtDX,SAAS,WAAW,gBAAgB;AACpC,SAAS,mBAAmB,oBAAoB;AAEhD,SAAS,sBAAsB;AAC/B,SAAS,4BAA4B;AACrC,SAAS,sBAAsB;AAC/B,OAAO;AA6BA,SAAS,cAAc;AAAA,EAC5B;AAAA,EACA;AAAA,EACA,WAAW;AACb,GAAuB;AACrB,QAAM,CAAC,WAAW,YAAY,IAAI,SAAS,KAAK;AAIhD,YAAU,MAAM;AACd,iBAAa,KAAK;AAAA,EACpB,GAAG,CAAC,KAAK,EAAE,CAAC;AAEZ,QAAM,SAAS,qBAAqB;AAAA,IAClC,MAAM,KAAK;AAAA,IACX,UAAU,KAAK;AAAA,EACjB,CAAC;AAED,MAAI,YAAY,CAAC,OAAO,YAAY;AAClC,WAAO,gCAAG,UAAS;AAAA,EACrB;AAGA,MAAI,OAAO,aAAa;AACtB,WACE,qBAAC,SAAI,WAAU,mBACb;AAAA,0BAAC,qBAAkB,MAAM,IAAI,WAAU,sDAAqD;AAAA,MAC5F,qBAAC,SAAI,WAAU,yBACb;AAAA,4BAAC,QAAG,WAAU,0BAAyB,uCAAyB;AAAA,QAChE,oBAAC,OAAE,WAAU,yBAAyB,iBAAO,SAAQ;AAAA,QACrD,qBAAC,OAAE,WAAU,yBACV;AAAA,eAAK;AAAA,UAAK;AAAA,UAAI,eAAe,KAAK,IAAI;AAAA,WACzC;AAAA,SACF;AAAA,MACA;AAAA,QAAC;AAAA;AAAA,UACC,WAAU;AAAA,UACV,SAAS,MAAM,eAAe,KAAK,QAAQ,KAAK,MAAM,KAAK,IAAI;AAAA,UAE/D;AAAA,gCAAC,gBAAa,MAAM,IAAI;AAAA,YAAE;AAAA;AAAA;AAAA,MAC5B;AAAA,OACF;AAAA,EAEJ;AAGA,MAAI,OAAO,iBAAiB,CAAC,WAAW;AACtC,WACE,qBAAC,SAAI,WAAU,mBACb;AAAA,0BAAC,qBAAkB,MAAM,IAAI,WAAU,yBAAwB;AAAA,MAC/D,qBAAC,SAAI,WAAU,yBACb;AAAA,4BAAC,QAAG,WAAU,0BAAyB,gCAAkB;AAAA,QACzD,oBAAC,OAAE,WAAU,yBAAyB,iBAAO,SAAQ;AAAA,QACrD,qBAAC,OAAE,WAAU,yBACV;AAAA,eAAK;AAAA,UAAK;AAAA,UAAI,eAAe,KAAK,IAAI;AAAA,WACzC;AAAA,SACF;AAAA,MACA,qBAAC,SAAI,WAAU,4BACb;AAAA,4BAAC,YAAO,WAAU,0BAAyB,SAAS,MAAM,aAAa,IAAI,GAAG,4BAE9E;AAAA,QACA;AAAA,UAAC;AAAA;AAAA,YACC,WAAU;AAAA,YACV,SAAS,MAAM,eAAe,KAAK,QAAQ,KAAK,MAAM,KAAK,IAAI;AAAA,YAE/D;AAAA,kCAAC,gBAAa,MAAM,IAAI;AAAA,cAAE;AAAA;AAAA;AAAA,QAC5B;AAAA,SACF;AAAA,OACF;AAAA,EAEJ;AAGA,SACE,iCACE;AAAA,wBAAC,SAAI,WAAU,mBACb,+BAAC,SAAI,WAAU,0BACb;AAAA,0BAAC,qBAAkB,MAAM,IAAI;AAAA,MAC7B,qBAAC,UAAK;AAAA;AAAA,QACS,eAAe,KAAK,IAAI;AAAA,QAAE;AAAA,SACzC;AAAA,OACF,GACF;AAAA,IACC;AAAA,KACH;AAEJ;","names":[]}
@@ -1,13 +1,32 @@
1
1
  import * as react from 'react';
2
2
  import { FileInfo } from './core/types.js';
3
3
  import { PreviewPluginRegistry } from './core/registry.js';
4
+ import { PreviewError } from './core/preview-error.js';
4
5
  import './core/plugin.js';
5
6
 
6
7
  interface PluginPreviewRendererProps {
7
8
  file: FileInfo;
8
9
  registry?: PreviewPluginRegistry;
9
10
  showPluginDebug?: boolean;
11
+ /**
12
+ * Called with a stable PreviewError whenever the renderer reaches a
13
+ * consumer-actionable failure path (unsupported file type, plugin load
14
+ * failure, render crash). Prefer switching on `error.code` over parsing
15
+ * `error.message`.
16
+ */
17
+ onError?: (error: PreviewError) => void;
18
+ /**
19
+ * Large-file protection policy.
20
+ *
21
+ * - `"default"` (default): the renderer wraps its output in an internal
22
+ * `LargeFileGate` that warns at 20 MB, requires confirmation at 50 MB,
23
+ * and blocks preview (download only) at 100 MB. This is the safe
24
+ * default — real users upload unpredictable files.
25
+ * - `"off"`: no gate. Use only when the caller enforces its own size
26
+ * policy or is previewing trusted, size-bounded content.
27
+ */
28
+ largeFilePolicy?: "default" | "off";
10
29
  }
11
- declare function PluginPreviewRenderer({ file, registry, showPluginDebug, }: PluginPreviewRendererProps): react.JSX.Element;
30
+ declare function PluginPreviewRenderer({ file, registry, showPluginDebug, onError, largeFilePolicy, }: PluginPreviewRendererProps): react.JSX.Element;
12
31
 
13
32
  export { PluginPreviewRenderer, type PluginPreviewRendererProps };
@@ -1,17 +1,23 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
- import { Suspense, use, useMemo, useState, useCallback } from "react";
2
+ import { useEffect, useMemo, useState, useCallback } from "react";
3
3
  import { createBuiltinPreviewRegistry } from "./plugins/builtin-plugins";
4
4
  import { UnsupportedPluginPreview } from "./preview-adapters/UnsupportedPluginPreview";
5
5
  import { getPreviewSupportMeta } from "./support-status";
6
6
  import { PreviewErrorBoundary } from "./PreviewErrorBoundary";
7
7
  import { PreviewLoading } from "./PreviewLoading";
8
+ import { LargeFileGate } from "./LargeFileGate";
9
+ import { PreviewError, isPreviewError } from "./core/preview-error";
8
10
  import "./styles/PluginDebugBar.css";
9
- class PreviewPluginLoadError extends Error {
11
+ class PreviewPluginLoadError extends PreviewError {
10
12
  constructor(pluginId, pluginName, cause) {
11
- super(`Failed to load preview plugin: ${pluginName}`);
13
+ const code = isPreviewError(cause) ? cause.code : "RENDER_FAILED";
14
+ super(code, `Failed to load preview plugin: ${pluginName}`, {
15
+ cause,
16
+ pluginId,
17
+ pluginName
18
+ });
12
19
  this.pluginId = pluginId;
13
20
  this.pluginName = pluginName;
14
- this.cause = cause;
15
21
  this.name = "PreviewPluginLoadError";
16
22
  }
17
23
  }
@@ -29,14 +35,36 @@ function invalidatePluginPromise(plugin) {
29
35
  promiseCache.delete(plugin);
30
36
  }
31
37
  function PluginContent({ plugin, file }) {
32
- const mod = use(getPluginPromise(plugin));
33
- const Component = mod.default;
34
- return /* @__PURE__ */ jsx(Component, { file });
38
+ const [state, setState] = useState({ status: "loading" });
39
+ useEffect(() => {
40
+ let cancelled = false;
41
+ setState({ status: "loading" });
42
+ getPluginPromise(plugin).then((mod) => {
43
+ if (!cancelled) {
44
+ setState({ status: "ready", Component: mod.default });
45
+ }
46
+ }).catch((error) => {
47
+ if (!cancelled) {
48
+ setState({
49
+ status: "error",
50
+ error: error instanceof Error ? error : new Error(String(error))
51
+ });
52
+ }
53
+ });
54
+ return () => {
55
+ cancelled = true;
56
+ };
57
+ }, [plugin]);
58
+ if (state.status === "loading") return /* @__PURE__ */ jsx(PreviewLoading, {});
59
+ if (state.status === "error") throw state.error;
60
+ return /* @__PURE__ */ jsx(state.Component, { file });
35
61
  }
36
62
  function PluginPreviewRenderer({
37
63
  file,
38
64
  registry,
39
- showPluginDebug = false
65
+ showPluginDebug = false,
66
+ onError,
67
+ largeFilePolicy = "default"
40
68
  }) {
41
69
  const [retryKey, setRetryKey] = useState(0);
42
70
  const finalRegistry = useMemo(() => {
@@ -46,6 +74,16 @@ function PluginPreviewRenderer({
46
74
  () => finalRegistry.resolve(file),
47
75
  [finalRegistry, file]
48
76
  );
77
+ useEffect(() => {
78
+ if (plugin) return;
79
+ onError?.(
80
+ new PreviewError(
81
+ "UNSUPPORTED_FILE_TYPE",
82
+ `Unsupported file type: ${file.fileType}`,
83
+ { fileName: file.name, details: { fileType: file.fileType } }
84
+ )
85
+ );
86
+ }, [file.fileType, file.name, onError, plugin]);
49
87
  const handleRetry = useCallback(() => {
50
88
  if (plugin) {
51
89
  invalidatePluginPromise(plugin);
@@ -63,7 +101,7 @@ function PluginPreviewRenderer({
63
101
  }
64
102
  );
65
103
  }
66
- return /* @__PURE__ */ jsxs("div", { className: "fv-plugin-renderer", children: [
104
+ const content = /* @__PURE__ */ jsxs("div", { className: "fv-plugin-renderer", children: [
67
105
  showPluginDebug && /* @__PURE__ */ jsxs("div", { className: "fv-plugin-debug", children: [
68
106
  /* @__PURE__ */ jsx("span", { className: "fv-plugin-debug__label", children: "Plugin Renderer" }),
69
107
  /* @__PURE__ */ jsx("span", { children: "\u2192" }),
@@ -78,10 +116,13 @@ function PluginPreviewRenderer({
78
116
  pluginName: plugin.name,
79
117
  resetKey: `${file.id}:${plugin.id}:${retryKey}`,
80
118
  onRetry: handleRetry,
81
- children: /* @__PURE__ */ jsx(Suspense, { fallback: /* @__PURE__ */ jsx(PreviewLoading, {}), children: /* @__PURE__ */ jsx(PluginContent, { plugin, file }) })
119
+ onError,
120
+ children: /* @__PURE__ */ jsx(PluginContent, { plugin, file })
82
121
  }
83
122
  ) })
84
123
  ] });
124
+ if (largeFilePolicy === "off") return content;
125
+ return /* @__PURE__ */ jsx(LargeFileGate, { file, children: content });
85
126
  }
86
127
  export {
87
128
  PluginPreviewRenderer
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/PluginPreviewRenderer.tsx"],"sourcesContent":["import { Suspense, use, useMemo, useState, useCallback } from \"react\";\nimport type { ComponentType } from \"react\";\nimport type { FileInfo } from \"./utils\";\nimport type { PreviewPlugin } from \"./core/plugin\";\nimport type { PreviewPluginRegistry } from \"./core/registry\";\nimport { createBuiltinPreviewRegistry } from \"./plugins/builtin-plugins\";\nimport { UnsupportedPluginPreview } from \"./preview-adapters/UnsupportedPluginPreview\";\nimport { getPreviewSupportMeta } from \"./support-status\";\nimport { PreviewErrorBoundary } from \"./PreviewErrorBoundary\";\nimport { PreviewLoading } from \"./PreviewLoading\";\nimport \"./styles/PluginDebugBar.css\";\n\nclass PreviewPluginLoadError extends Error {\n constructor(\n public pluginId: string,\n public pluginName: string,\n public cause: unknown,\n ) {\n super(`Failed to load preview plugin: ${pluginName}`);\n this.name = \"PreviewPluginLoadError\";\n }\n}\n\ntype PluginModule = { default: ComponentType<{ file: FileInfo }> };\nconst promiseCache = new WeakMap<PreviewPlugin, Promise<PluginModule>>();\n\nfunction getPluginPromise(plugin: PreviewPlugin): Promise<PluginModule> {\n const cached = promiseCache.get(plugin);\n if (cached) return cached;\n\n const promise = plugin.load().catch((error) => {\n throw new PreviewPluginLoadError(plugin.id, plugin.name, error);\n });\n promiseCache.set(plugin, promise);\n return promise;\n}\n\nfunction invalidatePluginPromise(plugin: PreviewPlugin) {\n promiseCache.delete(plugin);\n}\n\ninterface PluginContentProps {\n plugin: PreviewPlugin;\n file: FileInfo;\n}\n\nfunction PluginContent({ plugin, file }: PluginContentProps) {\n const mod = use(getPluginPromise(plugin));\n const Component = mod.default;\n return <Component file={file} />;\n}\n\nexport interface PluginPreviewRendererProps {\n file: FileInfo;\n registry?: PreviewPluginRegistry;\n showPluginDebug?: boolean;\n}\n\nexport function PluginPreviewRenderer({\n file,\n registry,\n showPluginDebug = false,\n}: PluginPreviewRendererProps) {\n const [retryKey, setRetryKey] = useState(0);\n\n const finalRegistry = useMemo(() => {\n return registry ?? createBuiltinPreviewRegistry();\n }, [registry]);\n\n const plugin = useMemo(\n () => finalRegistry.resolve(file),\n [finalRegistry, file]\n );\n\n const handleRetry = useCallback(() => {\n if (plugin) {\n invalidatePluginPromise(plugin);\n }\n setRetryKey((value) => value + 1);\n }, [plugin]);\n\n if (!plugin) {\n const support = getPreviewSupportMeta(file.fileType);\n\n return (\n <UnsupportedPluginPreview\n file={file}\n title={support.status === \"legacy-only\" ? \"Not Migrated Yet\" : undefined}\n description={\n support.status === \"legacy-only\"\n ? `This file type (${file.fileType}) is currently only available in Legacy Renderer.`\n : support.status === \"degraded\"\n ? support.note ??\n `This file type (${file.fileType}) only has degraded legacy support and is not available in Plugin Renderer.`\n : support.note ??\n `This file type (${file.fileType}) cannot be previewed by the plugin renderer.`\n }\n />\n );\n }\n\n return (\n <div className=\"fv-plugin-renderer\">\n {showPluginDebug && (\n <div className=\"fv-plugin-debug\">\n <span className=\"fv-plugin-debug__label\">Plugin Renderer</span>\n <span>→</span>\n <span>{plugin.name}</span>\n <span className=\"fv-plugin-debug__id\">{plugin.id}</span>\n </div>\n )}\n\n <div className=\"fv-plugin-renderer__content\">\n <PreviewErrorBoundary\n file={file}\n pluginId={plugin.id}\n pluginName={plugin.name}\n resetKey={`${file.id}:${plugin.id}:${retryKey}`}\n onRetry={handleRetry}\n >\n <Suspense fallback={<PreviewLoading />}>\n <PluginContent plugin={plugin} file={file} />\n </Suspense>\n </PreviewErrorBoundary>\n </div>\n </div>\n );\n}\n"],"mappings":"AAiDS,cAuDD,YAvDC;AAjDT,SAAS,UAAU,KAAK,SAAS,UAAU,mBAAmB;AAK9D,SAAS,oCAAoC;AAC7C,SAAS,gCAAgC;AACzC,SAAS,6BAA6B;AACtC,SAAS,4BAA4B;AACrC,SAAS,sBAAsB;AAC/B,OAAO;AAEP,MAAM,+BAA+B,MAAM;AAAA,EACzC,YACS,UACA,YACA,OACP;AACA,UAAM,kCAAkC,UAAU,EAAE;AAJ7C;AACA;AACA;AAGP,SAAK,OAAO;AAAA,EACd;AACF;AAGA,MAAM,eAAe,oBAAI,QAA8C;AAEvE,SAAS,iBAAiB,QAA8C;AACtE,QAAM,SAAS,aAAa,IAAI,MAAM;AACtC,MAAI,OAAQ,QAAO;AAEnB,QAAM,UAAU,OAAO,KAAK,EAAE,MAAM,CAAC,UAAU;AAC7C,UAAM,IAAI,uBAAuB,OAAO,IAAI,OAAO,MAAM,KAAK;AAAA,EAChE,CAAC;AACD,eAAa,IAAI,QAAQ,OAAO;AAChC,SAAO;AACT;AAEA,SAAS,wBAAwB,QAAuB;AACtD,eAAa,OAAO,MAAM;AAC5B;AAOA,SAAS,cAAc,EAAE,QAAQ,KAAK,GAAuB;AAC3D,QAAM,MAAM,IAAI,iBAAiB,MAAM,CAAC;AACxC,QAAM,YAAY,IAAI;AACtB,SAAO,oBAAC,aAAU,MAAY;AAChC;AAQO,SAAS,sBAAsB;AAAA,EACpC;AAAA,EACA;AAAA,EACA,kBAAkB;AACpB,GAA+B;AAC7B,QAAM,CAAC,UAAU,WAAW,IAAI,SAAS,CAAC;AAE1C,QAAM,gBAAgB,QAAQ,MAAM;AAClC,WAAO,YAAY,6BAA6B;AAAA,EAClD,GAAG,CAAC,QAAQ,CAAC;AAEb,QAAM,SAAS;AAAA,IACb,MAAM,cAAc,QAAQ,IAAI;AAAA,IAChC,CAAC,eAAe,IAAI;AAAA,EACtB;AAEA,QAAM,cAAc,YAAY,MAAM;AACpC,QAAI,QAAQ;AACV,8BAAwB,MAAM;AAAA,IAChC;AACA,gBAAY,CAAC,UAAU,QAAQ,CAAC;AAAA,EAClC,GAAG,CAAC,MAAM,CAAC;AAEX,MAAI,CAAC,QAAQ;AACX,UAAM,UAAU,sBAAsB,KAAK,QAAQ;AAEnD,WACE;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA,OAAO,QAAQ,WAAW,gBAAgB,qBAAqB;AAAA,QAC/D,aACE,QAAQ,WAAW,gBACf,mBAAmB,KAAK,QAAQ,sDAChC,QAAQ,WAAW,aACjB,QAAQ,QACR,mBAAmB,KAAK,QAAQ,gFAChC,QAAQ,QACR,mBAAmB,KAAK,QAAQ;AAAA;AAAA,IAE1C;AAAA,EAEJ;AAEA,SACE,qBAAC,SAAI,WAAU,sBACZ;AAAA,uBACC,qBAAC,SAAI,WAAU,mBACb;AAAA,0BAAC,UAAK,WAAU,0BAAyB,6BAAe;AAAA,MACxD,oBAAC,UAAK,oBAAC;AAAA,MACP,oBAAC,UAAM,iBAAO,MAAK;AAAA,MACnB,oBAAC,UAAK,WAAU,uBAAuB,iBAAO,IAAG;AAAA,OACnD;AAAA,IAGF,oBAAC,SAAI,WAAU,+BACb;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA,UAAU,OAAO;AAAA,QACjB,YAAY,OAAO;AAAA,QACnB,UAAU,GAAG,KAAK,EAAE,IAAI,OAAO,EAAE,IAAI,QAAQ;AAAA,QAC7C,SAAS;AAAA,QAET,8BAAC,YAAS,UAAU,oBAAC,kBAAe,GAClC,8BAAC,iBAAc,QAAgB,MAAY,GAC7C;AAAA;AAAA,IACF,GACF;AAAA,KACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../src/PluginPreviewRenderer.tsx"],"sourcesContent":["import { useEffect, useMemo, useState, useCallback } from \"react\";\nimport type { ComponentType } from \"react\";\nimport type { FileInfo } from \"./utils\";\nimport type { PreviewPlugin } from \"./core/plugin\";\nimport type { PreviewPluginRegistry } from \"./core/registry\";\nimport { createBuiltinPreviewRegistry } from \"./plugins/builtin-plugins\";\nimport { UnsupportedPluginPreview } from \"./preview-adapters/UnsupportedPluginPreview\";\nimport { getPreviewSupportMeta } from \"./support-status\";\nimport { PreviewErrorBoundary } from \"./PreviewErrorBoundary\";\nimport { PreviewLoading } from \"./PreviewLoading\";\nimport { LargeFileGate } from \"./LargeFileGate\";\nimport { PreviewError, isPreviewError } from \"./core/preview-error\";\nimport type { PreviewErrorCode } from \"./core/preview-error\";\nimport \"./styles/PluginDebugBar.css\";\n\nclass PreviewPluginLoadError extends PreviewError {\n constructor(\n public pluginId: string,\n public pluginName: string,\n cause: unknown,\n ) {\n const code: PreviewErrorCode = isPreviewError(cause)\n ? cause.code\n : \"RENDER_FAILED\";\n super(code, `Failed to load preview plugin: ${pluginName}`, {\n cause,\n pluginId,\n pluginName,\n });\n this.name = \"PreviewPluginLoadError\";\n }\n}\n\ntype PluginModule = { default: ComponentType<{ file: FileInfo }> };\nconst promiseCache = new WeakMap<PreviewPlugin, Promise<PluginModule>>();\n\nfunction getPluginPromise(plugin: PreviewPlugin): Promise<PluginModule> {\n const cached = promiseCache.get(plugin);\n if (cached) return cached;\n\n const promise = plugin.load().catch((error) => {\n throw new PreviewPluginLoadError(plugin.id, plugin.name, error);\n });\n promiseCache.set(plugin, promise);\n return promise;\n}\n\nfunction invalidatePluginPromise(plugin: PreviewPlugin) {\n promiseCache.delete(plugin);\n}\n\ninterface PluginContentProps {\n plugin: PreviewPlugin;\n file: FileInfo;\n}\n\n// Load state for a plugin module. We use an explicit state machine instead of\n// React 19's `use(promise)` so the library stays compatible with React 18\n// (where `use` is unavailable). The three states map to:\n// loading → show <PreviewLoading />\n// error → throw so the surrounding <PreviewErrorBoundary> catches it\n// (this preserves the existing Retry path: invalidatePluginPromise\n// + resetKey bump forces a remount with a fresh promise)\n// ready → render the resolved component\ntype PluginContentState =\n | { status: \"loading\" }\n | { status: \"ready\"; Component: ComponentType<{ file: FileInfo }> }\n | { status: \"error\"; error: Error };\n\nfunction PluginContent({ plugin, file }: PluginContentProps) {\n const [state, setState] = useState<PluginContentState>({ status: \"loading\" });\n\n useEffect(() => {\n let cancelled = false;\n setState({ status: \"loading\" });\n\n getPluginPromise(plugin)\n .then((mod) => {\n if (!cancelled) {\n setState({ status: \"ready\", Component: mod.default });\n }\n })\n .catch((error: unknown) => {\n if (!cancelled) {\n setState({\n status: \"error\",\n error:\n error instanceof Error\n ? error\n : new Error(String(error)),\n });\n }\n });\n\n return () => {\n cancelled = true;\n };\n }, [plugin]);\n\n if (state.status === \"loading\") return <PreviewLoading />;\n if (state.status === \"error\") throw state.error;\n return <state.Component file={file} />;\n}\n\nexport interface PluginPreviewRendererProps {\n file: FileInfo;\n registry?: PreviewPluginRegistry;\n showPluginDebug?: boolean;\n /**\n * Called with a stable PreviewError whenever the renderer reaches a\n * consumer-actionable failure path (unsupported file type, plugin load\n * failure, render crash). Prefer switching on `error.code` over parsing\n * `error.message`.\n */\n onError?: (error: PreviewError) => void;\n /**\n * Large-file protection policy.\n *\n * - `\"default\"` (default): the renderer wraps its output in an internal\n * `LargeFileGate` that warns at 20 MB, requires confirmation at 50 MB,\n * and blocks preview (download only) at 100 MB. This is the safe\n * default — real users upload unpredictable files.\n * - `\"off\"`: no gate. Use only when the caller enforces its own size\n * policy or is previewing trusted, size-bounded content.\n */\n largeFilePolicy?: \"default\" | \"off\";\n}\n\nexport function PluginPreviewRenderer({\n file,\n registry,\n showPluginDebug = false,\n onError,\n largeFilePolicy = \"default\",\n}: PluginPreviewRendererProps) {\n const [retryKey, setRetryKey] = useState(0);\n\n const finalRegistry = useMemo(() => {\n return registry ?? createBuiltinPreviewRegistry();\n }, [registry]);\n\n const plugin = useMemo(\n () => finalRegistry.resolve(file),\n [finalRegistry, file]\n );\n\n useEffect(() => {\n if (plugin) return;\n onError?.(\n new PreviewError(\n \"UNSUPPORTED_FILE_TYPE\",\n `Unsupported file type: ${file.fileType}`,\n { fileName: file.name, details: { fileType: file.fileType } },\n ),\n );\n }, [file.fileType, file.name, onError, plugin]);\n\n const handleRetry = useCallback(() => {\n if (plugin) {\n invalidatePluginPromise(plugin);\n }\n setRetryKey((value) => value + 1);\n }, [plugin]);\n\n if (!plugin) {\n const support = getPreviewSupportMeta(file.fileType);\n\n return (\n <UnsupportedPluginPreview\n file={file}\n title={support.status === \"legacy-only\" ? \"Not Migrated Yet\" : undefined}\n description={\n support.status === \"legacy-only\"\n ? `This file type (${file.fileType}) is currently only available in Legacy Renderer.`\n : support.status === \"degraded\"\n ? support.note ??\n `This file type (${file.fileType}) only has degraded legacy support and is not available in Plugin Renderer.`\n : support.note ??\n `This file type (${file.fileType}) cannot be previewed by the plugin renderer.`\n }\n />\n );\n }\n\n const content = (\n <div className=\"fv-plugin-renderer\">\n {showPluginDebug && (\n <div className=\"fv-plugin-debug\">\n <span className=\"fv-plugin-debug__label\">Plugin Renderer</span>\n <span>→</span>\n <span>{plugin.name}</span>\n <span className=\"fv-plugin-debug__id\">{plugin.id}</span>\n </div>\n )}\n\n <div className=\"fv-plugin-renderer__content\">\n <PreviewErrorBoundary\n file={file}\n pluginId={plugin.id}\n pluginName={plugin.name}\n resetKey={`${file.id}:${plugin.id}:${retryKey}`}\n onRetry={handleRetry}\n onError={onError}\n >\n <PluginContent plugin={plugin} file={file} />\n </PreviewErrorBoundary>\n </div>\n </div>\n );\n\n // Default: protect against accidentally previewing huge files. The gate\n // is a no-op for files under the 20 MB warning threshold, so normal-size\n // previews render exactly as before.\n if (largeFilePolicy === \"off\") return content;\n return <LargeFileGate file={file}>{content}</LargeFileGate>;\n}\n"],"mappings":"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","names":[]}
@@ -1,5 +1,6 @@
1
1
  import react__default from 'react';
2
2
  import { FileInfo } from './core/types.js';
3
+ import { PreviewError } from './core/preview-error.js';
3
4
 
4
5
  interface PreviewErrorBoundaryProps {
5
6
  file: FileInfo;
@@ -7,6 +8,7 @@ interface PreviewErrorBoundaryProps {
7
8
  pluginName?: string;
8
9
  resetKey: string;
9
10
  onRetry: () => void;
11
+ onError?: (error: PreviewError) => void;
10
12
  children: react__default.ReactNode;
11
13
  }
12
14
  interface PreviewErrorBoundaryState {
@@ -1,6 +1,7 @@
1
1
  import { jsx } from "react/jsx-runtime";
2
2
  import React from "react";
3
3
  import { PreviewFallback } from "./PreviewFallback";
4
+ import { normalizePreviewError, isPreviewError } from "./core/preview-error";
4
5
  class PreviewErrorBoundary extends React.Component {
5
6
  constructor() {
6
7
  super(...arguments);
@@ -22,16 +23,24 @@ class PreviewErrorBoundary extends React.Component {
22
23
  return null;
23
24
  }
24
25
  componentDidCatch(error, info) {
26
+ const previewError = normalizePreviewError(error, {
27
+ code: "RENDER_FAILED",
28
+ message: "Preview rendering failed",
29
+ pluginId: this.props.pluginId,
30
+ pluginName: this.props.pluginName,
31
+ fileName: this.props.file.name
32
+ });
25
33
  console.warn("[preview-error-boundary]", {
26
- error,
34
+ error: previewError,
27
35
  info,
28
36
  file: this.props.file.name,
29
37
  pluginId: this.props.pluginId
30
38
  });
39
+ this.props.onError?.(previewError);
31
40
  }
32
41
  render() {
33
42
  if (this.state.error) {
34
- const kind = this.state.error.name === "PreviewPluginLoadError" ? "plugin-load-failed" : "render-failed";
43
+ const kind = this.state.error.name === "PreviewPluginLoadError" || isPreviewError(this.state.error) && this.state.error.code === "MISSING_PEER_DEPENDENCY" ? "plugin-load-failed" : "render-failed";
35
44
  return /* @__PURE__ */ jsx(
36
45
  PreviewFallback,
37
46
  {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/PreviewErrorBoundary.tsx"],"sourcesContent":["import React from \"react\";\nimport type { FileInfo } from \"./utils\";\nimport { PreviewFallback, type PreviewFallbackKind } from \"./PreviewFallback\";\n\ninterface PreviewErrorBoundaryProps {\n file: FileInfo;\n pluginId?: string;\n pluginName?: string;\n resetKey: string;\n onRetry: () => void;\n children: React.ReactNode;\n}\n\ninterface PreviewErrorBoundaryState {\n error: Error | null;\n previousResetKey: string;\n}\n\nexport class PreviewErrorBoundary extends React.Component<\n PreviewErrorBoundaryProps,\n PreviewErrorBoundaryState\n> {\n state: PreviewErrorBoundaryState = {\n error: null,\n previousResetKey: this.props.resetKey,\n };\n\n static getDerivedStateFromError(error: Error) {\n return { error };\n }\n\n static getDerivedStateFromProps(\n props: PreviewErrorBoundaryProps,\n state: PreviewErrorBoundaryState,\n ) {\n if (props.resetKey !== state.previousResetKey) {\n return {\n error: null,\n previousResetKey: props.resetKey,\n };\n }\n\n return null;\n }\n\n componentDidCatch(error: Error, info: React.ErrorInfo) {\n console.warn(\"[preview-error-boundary]\", {\n error,\n info,\n file: this.props.file.name,\n pluginId: this.props.pluginId,\n });\n }\n\n render() {\n if (this.state.error) {\n const kind: PreviewFallbackKind =\n this.state.error.name === \"PreviewPluginLoadError\"\n ? \"plugin-load-failed\"\n : \"render-failed\";\n\n return (\n <PreviewFallback\n kind={kind}\n file={this.props.file}\n error={this.state.error}\n pluginId={this.props.pluginId}\n pluginName={this.props.pluginName}\n onRetry={this.props.onRetry}\n />\n );\n }\n\n return this.props.children;\n }\n}\n"],"mappings":"AA8DQ;AA9DR,OAAO,WAAW;AAElB,SAAS,uBAAiD;AAgBnD,MAAM,6BAA6B,MAAM,UAG9C;AAAA,EAHK;AAAA;AAIL,iBAAmC;AAAA,MACjC,OAAO;AAAA,MACP,kBAAkB,KAAK,MAAM;AAAA,IAC/B;AAAA;AAAA,EAEA,OAAO,yBAAyB,OAAc;AAC5C,WAAO,EAAE,MAAM;AAAA,EACjB;AAAA,EAEA,OAAO,yBACL,OACA,OACA;AACA,QAAI,MAAM,aAAa,MAAM,kBAAkB;AAC7C,aAAO;AAAA,QACL,OAAO;AAAA,QACP,kBAAkB,MAAM;AAAA,MAC1B;AAAA,IACF;AAEA,WAAO;AAAA,EACT;AAAA,EAEA,kBAAkB,OAAc,MAAuB;AACrD,YAAQ,KAAK,4BAA4B;AAAA,MACvC;AAAA,MACA;AAAA,MACA,MAAM,KAAK,MAAM,KAAK;AAAA,MACtB,UAAU,KAAK,MAAM;AAAA,IACvB,CAAC;AAAA,EACH;AAAA,EAEA,SAAS;AACP,QAAI,KAAK,MAAM,OAAO;AACpB,YAAM,OACJ,KAAK,MAAM,MAAM,SAAS,2BACtB,uBACA;AAEN,aACE;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UACA,MAAM,KAAK,MAAM;AAAA,UACjB,OAAO,KAAK,MAAM;AAAA,UAClB,UAAU,KAAK,MAAM;AAAA,UACrB,YAAY,KAAK,MAAM;AAAA,UACvB,SAAS,KAAK,MAAM;AAAA;AAAA,MACtB;AAAA,IAEJ;AAEA,WAAO,KAAK,MAAM;AAAA,EACpB;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/PreviewErrorBoundary.tsx"],"sourcesContent":["import React from \"react\";\nimport type { FileInfo } from \"./utils\";\nimport { PreviewFallback, type PreviewFallbackKind } from \"./PreviewFallback\";\nimport { normalizePreviewError, isPreviewError, type PreviewError } from \"./core/preview-error\";\n\ninterface PreviewErrorBoundaryProps {\n file: FileInfo;\n pluginId?: string;\n pluginName?: string;\n resetKey: string;\n onRetry: () => void;\n onError?: (error: PreviewError) => void;\n children: React.ReactNode;\n}\n\ninterface PreviewErrorBoundaryState {\n error: Error | null;\n previousResetKey: string;\n}\n\nexport class PreviewErrorBoundary extends React.Component<\n PreviewErrorBoundaryProps,\n PreviewErrorBoundaryState\n> {\n state: PreviewErrorBoundaryState = {\n error: null,\n previousResetKey: this.props.resetKey,\n };\n\n static getDerivedStateFromError(error: Error) {\n return { error };\n }\n\n static getDerivedStateFromProps(\n props: PreviewErrorBoundaryProps,\n state: PreviewErrorBoundaryState,\n ) {\n if (props.resetKey !== state.previousResetKey) {\n return {\n error: null,\n previousResetKey: props.resetKey,\n };\n }\n\n return null;\n }\n\n componentDidCatch(error: Error, info: React.ErrorInfo) {\n const previewError = normalizePreviewError(error, {\n code: \"RENDER_FAILED\",\n message: \"Preview rendering failed\",\n pluginId: this.props.pluginId,\n pluginName: this.props.pluginName,\n fileName: this.props.file.name,\n });\n\n console.warn(\"[preview-error-boundary]\", {\n error: previewError,\n info,\n file: this.props.file.name,\n pluginId: this.props.pluginId,\n });\n\n this.props.onError?.(previewError);\n }\n\n render() {\n if (this.state.error) {\n const kind: PreviewFallbackKind =\n this.state.error.name === \"PreviewPluginLoadError\" ||\n (isPreviewError(this.state.error) &&\n this.state.error.code === \"MISSING_PEER_DEPENDENCY\")\n ? \"plugin-load-failed\"\n : \"render-failed\";\n\n return (\n <PreviewFallback\n kind={kind}\n file={this.props.file}\n error={this.state.error}\n pluginId={this.props.pluginId}\n pluginName={this.props.pluginName}\n onRetry={this.props.onRetry}\n />\n );\n }\n\n return this.props.children;\n }\n}\n"],"mappings":"AA4EQ;AA5ER,OAAO,WAAW;AAElB,SAAS,uBAAiD;AAC1D,SAAS,uBAAuB,sBAAyC;AAiBlE,MAAM,6BAA6B,MAAM,UAG9C;AAAA,EAHK;AAAA;AAIL,iBAAmC;AAAA,MACjC,OAAO;AAAA,MACP,kBAAkB,KAAK,MAAM;AAAA,IAC/B;AAAA;AAAA,EAEA,OAAO,yBAAyB,OAAc;AAC5C,WAAO,EAAE,MAAM;AAAA,EACjB;AAAA,EAEA,OAAO,yBACL,OACA,OACA;AACA,QAAI,MAAM,aAAa,MAAM,kBAAkB;AAC7C,aAAO;AAAA,QACL,OAAO;AAAA,QACP,kBAAkB,MAAM;AAAA,MAC1B;AAAA,IACF;AAEA,WAAO;AAAA,EACT;AAAA,EAEA,kBAAkB,OAAc,MAAuB;AACrD,UAAM,eAAe,sBAAsB,OAAO;AAAA,MAChD,MAAM;AAAA,MACN,SAAS;AAAA,MACT,UAAU,KAAK,MAAM;AAAA,MACrB,YAAY,KAAK,MAAM;AAAA,MACvB,UAAU,KAAK,MAAM,KAAK;AAAA,IAC5B,CAAC;AAED,YAAQ,KAAK,4BAA4B;AAAA,MACvC,OAAO;AAAA,MACP;AAAA,MACA,MAAM,KAAK,MAAM,KAAK;AAAA,MACtB,UAAU,KAAK,MAAM;AAAA,IACvB,CAAC;AAED,SAAK,MAAM,UAAU,YAAY;AAAA,EACnC;AAAA,EAEA,SAAS;AACP,QAAI,KAAK,MAAM,OAAO;AACpB,YAAM,OACJ,KAAK,MAAM,MAAM,SAAS,4BACzB,eAAe,KAAK,MAAM,KAAK,KAC9B,KAAK,MAAM,MAAM,SAAS,4BACxB,uBACA;AAEN,aACE;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UACA,MAAM,KAAK,MAAM;AAAA,UACjB,OAAO,KAAK,MAAM;AAAA,UAClB,UAAU,KAAK,MAAM;AAAA,UACrB,YAAY,KAAK,MAAM;AAAA,UACvB,SAAS,KAAK,MAAM;AAAA;AAAA,MACtB;AAAA,IAEJ;AAEA,WAAO,KAAK,MAAM;AAAA,EACpB;AACF;","names":[]}
@@ -0,0 +1,73 @@
1
+ import { FileType, PreviewSource } from './types.js';
2
+
3
+ /**
4
+ * Unified file-metadata detection.
5
+ *
6
+ * `detectFileType(name, mimeType)` (in utils) is filename + MIME based —
7
+ * fine when the user-provided metadata is honest, but real production
8
+ * uploads fail those assumptions all the time:
9
+ *
10
+ * - `report.pdf` renamed to `report.txt`
11
+ * - DOCX uploaded with `type: ""` (some upload widgets drop the MIME)
12
+ * - User picks the wrong file extension on save
13
+ * - Generic servers return `application/octet-stream` for everything
14
+ *
15
+ * `detectFileMeta(source)` looks at the actual bytes via {@link sniffMagic}
16
+ * and {@link sniffZipContainer}, falls back to extension and MIME only when
17
+ * the bytes are inconclusive, and tells the caller *how* it decided via
18
+ * {@link FileMetaConfidence} and {@link FileMetaDetectBy}. That lets a
19
+ * consumer know whether to trust the result enough to e.g. pick a renderer
20
+ * over user-supplied claims.
21
+ */
22
+
23
+ /**
24
+ * How sure we are about the detection.
25
+ *
26
+ * - `"high"` : magic bytes + (when relevant) ZIP container both matched.
27
+ * Filename and MIME are essentially irrelevant.
28
+ * - `"medium"` : only the filename or MIME pointed at the result. Bytes
29
+ * were inconclusive (e.g. plain text, no signature).
30
+ * - `"low"` : neither bytes nor metadata matched — `unknown` fallback.
31
+ */
32
+ type FileMetaConfidence = "high" | "medium" | "low";
33
+ /** Which signal actually drove the answer. */
34
+ type FileMetaDetectBy = "magic" | "container" | "extension" | "mime";
35
+ interface FileMeta {
36
+ fileType: FileType;
37
+ /** Best-guess MIME, possibly synthesized from magic bytes. */
38
+ mimeType: string;
39
+ /** Detected filename (`source.name` for blob/buffer/url, `file.name` for File). */
40
+ fileName: string;
41
+ confidence: FileMetaConfidence;
42
+ detectBy: FileMetaDetectBy;
43
+ }
44
+ interface DetectFileMetaOptions {
45
+ /**
46
+ * Bytes to inspect. Capped to keep large files cheap — only the first
47
+ * 32 bytes are needed for magic, and only ZIP-magic files trigger the
48
+ * full container parse. Defaults to 64 KB, which is plenty.
49
+ */
50
+ maxBytesToInspect?: number;
51
+ }
52
+ /**
53
+ * Best-effort metadata for a `PreviewSource`.
54
+ *
55
+ * Resolution order:
56
+ *
57
+ * 1. Read up to `maxBytesToInspect` bytes from the source.
58
+ * 2. {@link sniffMagic} on those bytes.
59
+ * - PDF / PNG / JPG / GIF / WebP / MP4 / OLE → high confidence; done.
60
+ * - ZIP → run {@link sniffZipContainer}: docx / pptx / xlsx / epub
61
+ * all read out as zip at the magic level.
62
+ * 3. If neither matched (or jszip is missing), fall back to
63
+ * `detectFileType(name, mime)` over filename + source MIME, and
64
+ * mark confidence as `medium` (or `low` for `unknown`).
65
+ *
66
+ * The function never throws on a successful read — a bad source surfaces as
67
+ * an `unknown` result with `confidence: "low"`. Read errors (e.g. URL fetch
68
+ * failure when the source is `kind: "url"`) propagate, since the caller
69
+ * can't get the bytes either way.
70
+ */
71
+ declare function detectFileMeta(source: PreviewSource, options?: DetectFileMetaOptions): Promise<FileMeta>;
72
+
73
+ export { type DetectFileMetaOptions, type FileMeta, type FileMetaConfidence, type FileMetaDetectBy, detectFileMeta };
@@ -0,0 +1,81 @@
1
+ import { detectFileType, getFileExtension } from "../utils";
2
+ import { readSourceAsArrayBuffer, getSourceMimeType, getSourceName } from "./source";
3
+ import { sniffMagic, sniffZipContainer } from "./magic-bytes";
4
+ const DEFAULT_INSPECT_LIMIT = 64 * 1024;
5
+ const FALLBACK_MIME = "application/octet-stream";
6
+ async function detectFileMeta(source, options = {}) {
7
+ const fileName = getSourceName(source) ?? "";
8
+ const declaredMime = getSourceMimeType(source) ?? "";
9
+ const limit = options.maxBytesToInspect ?? DEFAULT_INSPECT_LIMIT;
10
+ const head = await readSourceHead(source, limit);
11
+ const magic = sniffMagic(head);
12
+ if (magic.ext === "zip") {
13
+ const fullBuffer = await readSourceAsArrayBuffer(source);
14
+ const container = await sniffZipContainer(fullBuffer);
15
+ if (container) {
16
+ return {
17
+ fileType: detectFileType(fileName, container.mimeType),
18
+ mimeType: container.mimeType,
19
+ fileName,
20
+ confidence: "high",
21
+ detectBy: "container"
22
+ };
23
+ }
24
+ return {
25
+ fileType: detectFileType(fileName, "application/zip"),
26
+ mimeType: "application/zip",
27
+ fileName,
28
+ confidence: "high",
29
+ detectBy: "magic"
30
+ };
31
+ }
32
+ if (magic.ext !== null && magic.mimeType !== null) {
33
+ return {
34
+ fileType: detectFileType(fileName, magic.mimeType),
35
+ mimeType: magic.mimeType,
36
+ fileName,
37
+ confidence: "high",
38
+ detectBy: "magic"
39
+ };
40
+ }
41
+ const inferred = detectFileType(fileName, declaredMime);
42
+ if (inferred !== "unknown") {
43
+ const detectBy = detectFileTypeByExtension(fileName) === inferred ? "extension" : "mime";
44
+ return {
45
+ fileType: inferred,
46
+ // Only synthesize a MIME when the source didn't provide one; otherwise
47
+ // pass through what the caller / browser told us.
48
+ mimeType: declaredMime || FALLBACK_MIME,
49
+ fileName,
50
+ confidence: "medium",
51
+ detectBy
52
+ };
53
+ }
54
+ return {
55
+ fileType: "unknown",
56
+ mimeType: declaredMime || FALLBACK_MIME,
57
+ fileName,
58
+ confidence: "low",
59
+ detectBy: declaredMime ? "mime" : "extension"
60
+ };
61
+ }
62
+ function detectFileTypeByExtension(fileName) {
63
+ const ext = getFileExtension(fileName);
64
+ if (!ext) return "unknown";
65
+ return detectFileType(fileName, "");
66
+ }
67
+ async function readSourceHead(source, limit) {
68
+ if (source.kind === "file" || source.kind === "blob") {
69
+ const blob = source.kind === "file" ? source.file : source.blob;
70
+ return blob.slice(0, limit).arrayBuffer();
71
+ }
72
+ if (source.kind === "arrayBuffer") {
73
+ return source.buffer.slice(0, Math.min(limit, source.buffer.byteLength));
74
+ }
75
+ const buffer = await readSourceAsArrayBuffer(source);
76
+ return buffer.slice(0, Math.min(limit, buffer.byteLength));
77
+ }
78
+ export {
79
+ detectFileMeta
80
+ };
81
+ //# sourceMappingURL=detect-meta.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/core/detect-meta.ts"],"sourcesContent":["/**\n * Unified file-metadata detection.\n *\n * `detectFileType(name, mimeType)` (in utils) is filename + MIME based —\n * fine when the user-provided metadata is honest, but real production\n * uploads fail those assumptions all the time:\n *\n * - `report.pdf` renamed to `report.txt`\n * - DOCX uploaded with `type: \"\"` (some upload widgets drop the MIME)\n * - User picks the wrong file extension on save\n * - Generic servers return `application/octet-stream` for everything\n *\n * `detectFileMeta(source)` looks at the actual bytes via {@link sniffMagic}\n * and {@link sniffZipContainer}, falls back to extension and MIME only when\n * the bytes are inconclusive, and tells the caller *how* it decided via\n * {@link FileMetaConfidence} and {@link FileMetaDetectBy}. That lets a\n * consumer know whether to trust the result enough to e.g. pick a renderer\n * over user-supplied claims.\n */\n\nimport { detectFileType, getFileExtension } from \"../utils\";\nimport type { FileType } from \"../utils\";\nimport type { PreviewSource } from \"./types\";\nimport { readSourceAsArrayBuffer, getSourceMimeType, getSourceName } from \"./source\";\nimport { sniffMagic, sniffZipContainer } from \"./magic-bytes\";\n\n/**\n * How sure we are about the detection.\n *\n * - `\"high\"` : magic bytes + (when relevant) ZIP container both matched.\n * Filename and MIME are essentially irrelevant.\n * - `\"medium\"` : only the filename or MIME pointed at the result. Bytes\n * were inconclusive (e.g. plain text, no signature).\n * - `\"low\"` : neither bytes nor metadata matched — `unknown` fallback.\n */\nexport type FileMetaConfidence = \"high\" | \"medium\" | \"low\";\n\n/** Which signal actually drove the answer. */\nexport type FileMetaDetectBy = \"magic\" | \"container\" | \"extension\" | \"mime\";\n\nexport interface FileMeta {\n fileType: FileType;\n /** Best-guess MIME, possibly synthesized from magic bytes. */\n mimeType: string;\n /** Detected filename (`source.name` for blob/buffer/url, `file.name` for File). */\n fileName: string;\n confidence: FileMetaConfidence;\n detectBy: FileMetaDetectBy;\n}\n\nexport interface DetectFileMetaOptions {\n /**\n * Bytes to inspect. Capped to keep large files cheap — only the first\n * 32 bytes are needed for magic, and only ZIP-magic files trigger the\n * full container parse. Defaults to 64 KB, which is plenty.\n */\n maxBytesToInspect?: number;\n}\n\nconst DEFAULT_INSPECT_LIMIT = 64 * 1024;\nconst FALLBACK_MIME = \"application/octet-stream\";\n\n/**\n * Best-effort metadata for a `PreviewSource`.\n *\n * Resolution order:\n *\n * 1. Read up to `maxBytesToInspect` bytes from the source.\n * 2. {@link sniffMagic} on those bytes.\n * - PDF / PNG / JPG / GIF / WebP / MP4 / OLE → high confidence; done.\n * - ZIP → run {@link sniffZipContainer}: docx / pptx / xlsx / epub\n * all read out as zip at the magic level.\n * 3. If neither matched (or jszip is missing), fall back to\n * `detectFileType(name, mime)` over filename + source MIME, and\n * mark confidence as `medium` (or `low` for `unknown`).\n *\n * The function never throws on a successful read — a bad source surfaces as\n * an `unknown` result with `confidence: \"low\"`. Read errors (e.g. URL fetch\n * failure when the source is `kind: \"url\"`) propagate, since the caller\n * can't get the bytes either way.\n */\nexport async function detectFileMeta(\n source: PreviewSource,\n options: DetectFileMetaOptions = {}\n): Promise<FileMeta> {\n const fileName = getSourceName(source) ?? \"\";\n const declaredMime = getSourceMimeType(source) ?? \"\";\n const limit = options.maxBytesToInspect ?? DEFAULT_INSPECT_LIMIT;\n\n // Slice the source down to a small head — we only need 32 bytes for\n // most signatures, and 64 KB is well above the first central directory\n // record of even moderately large zips.\n const head = await readSourceHead(source, limit);\n\n // Layer 1: magic-byte sniff (sync, no peer deps).\n const magic = sniffMagic(head);\n\n // ZIP-magic: distinguish docx/pptx/xlsx/epub from a plain zip.\n if (magic.ext === \"zip\") {\n // JSZip needs the full archive (central directory is usually at the end),\n // not just the head bytes we used for cheap magic sniffing.\n const fullBuffer = await readSourceAsArrayBuffer(source);\n const container = await sniffZipContainer(fullBuffer);\n if (container) {\n // Strong signal: it's an OOXML or EPUB. The mime came straight from\n // the spec'd marker entry; confidence is high.\n return {\n fileType: detectFileType(fileName, container.mimeType),\n mimeType: container.mimeType,\n fileName,\n confidence: \"high\",\n detectBy: \"container\",\n };\n }\n // Plain zip: route through `detectFileType` so the registry resolves\n // `zip` correctly. The bytes-level signal is still strong.\n return {\n fileType: detectFileType(fileName, \"application/zip\"),\n mimeType: \"application/zip\",\n fileName,\n confidence: \"high\",\n detectBy: \"magic\",\n };\n }\n\n // Other matched magic: trust the bytes over the filename.\n if (magic.ext !== null && magic.mimeType !== null) {\n return {\n fileType: detectFileType(fileName, magic.mimeType),\n mimeType: magic.mimeType,\n fileName,\n confidence: \"high\",\n detectBy: \"magic\",\n };\n }\n\n // Layer 2: fall through to filename + MIME. This is where plain text /\n // markdown / code / csv / etc. resolve — none of them have magic\n // signatures, so we have to trust the filename or the declared MIME.\n const inferred = detectFileType(fileName, declaredMime);\n\n if (inferred !== \"unknown\") {\n // Prefer extension-driven decisions over MIME ones; `detectFileType`\n // internally weights extensions above MIME, so we mirror that here for\n // `detectBy`. If filename has no extension, attribute to MIME.\n const detectBy: FileMetaDetectBy = detectFileTypeByExtension(fileName) === inferred\n ? \"extension\"\n : \"mime\";\n return {\n fileType: inferred,\n // Only synthesize a MIME when the source didn't provide one; otherwise\n // pass through what the caller / browser told us.\n mimeType: declaredMime || FALLBACK_MIME,\n fileName,\n confidence: \"medium\",\n detectBy,\n };\n }\n\n // Nothing matched anywhere.\n return {\n fileType: \"unknown\",\n mimeType: declaredMime || FALLBACK_MIME,\n fileName,\n confidence: \"low\",\n detectBy: declaredMime ? \"mime\" : \"extension\",\n };\n}\n\nfunction detectFileTypeByExtension(fileName: string): FileType {\n const ext = getFileExtension(fileName);\n if (!ext) return \"unknown\";\n return detectFileType(fileName, \"\");\n}\n\n/**\n * Read up to `limit` bytes from a source, slicing first when possible.\n * Avoids loading huge files into memory just to peek at the header.\n */\nasync function readSourceHead(\n source: PreviewSource,\n limit: number\n): Promise<ArrayBuffer> {\n // For File / Blob, slice first so we never load the rest. For URL we\n // can't (would require Range support); we fall through to the standard\n // reader and rely on the consumer setting a sensible source.\n if (source.kind === \"file\" || source.kind === \"blob\") {\n const blob = source.kind === \"file\" ? source.file : source.blob;\n return blob.slice(0, limit).arrayBuffer();\n }\n\n if (source.kind === \"arrayBuffer\") {\n return source.buffer.slice(0, Math.min(limit, source.buffer.byteLength));\n }\n\n // URL: no Range support; read the whole thing. Callers using URL sources\n // for detection should rely on processRemoteUrl's maxBytes cap instead.\n const buffer = await readSourceAsArrayBuffer(source);\n return buffer.slice(0, Math.min(limit, buffer.byteLength));\n}\n"],"mappings":"AAoBA,SAAS,gBAAgB,wBAAwB;AAGjD,SAAS,yBAAyB,mBAAmB,qBAAqB;AAC1E,SAAS,YAAY,yBAAyB;AAmC9C,MAAM,wBAAwB,KAAK;AACnC,MAAM,gBAAgB;AAqBtB,eAAsB,eACpB,QACA,UAAiC,CAAC,GACf;AACnB,QAAM,WAAW,cAAc,MAAM,KAAK;AAC1C,QAAM,eAAe,kBAAkB,MAAM,KAAK;AAClD,QAAM,QAAQ,QAAQ,qBAAqB;AAK3C,QAAM,OAAO,MAAM,eAAe,QAAQ,KAAK;AAG/C,QAAM,QAAQ,WAAW,IAAI;AAG7B,MAAI,MAAM,QAAQ,OAAO;AAGvB,UAAM,aAAa,MAAM,wBAAwB,MAAM;AACvD,UAAM,YAAY,MAAM,kBAAkB,UAAU;AACpD,QAAI,WAAW;AAGb,aAAO;AAAA,QACL,UAAU,eAAe,UAAU,UAAU,QAAQ;AAAA,QACrD,UAAU,UAAU;AAAA,QACpB;AAAA,QACA,YAAY;AAAA,QACZ,UAAU;AAAA,MACZ;AAAA,IACF;AAGA,WAAO;AAAA,MACL,UAAU,eAAe,UAAU,iBAAiB;AAAA,MACpD,UAAU;AAAA,MACV;AAAA,MACA,YAAY;AAAA,MACZ,UAAU;AAAA,IACZ;AAAA,EACF;AAGA,MAAI,MAAM,QAAQ,QAAQ,MAAM,aAAa,MAAM;AACjD,WAAO;AAAA,MACL,UAAU,eAAe,UAAU,MAAM,QAAQ;AAAA,MACjD,UAAU,MAAM;AAAA,MAChB;AAAA,MACA,YAAY;AAAA,MACZ,UAAU;AAAA,IACZ;AAAA,EACF;AAKA,QAAM,WAAW,eAAe,UAAU,YAAY;AAEtD,MAAI,aAAa,WAAW;AAI1B,UAAM,WAA6B,0BAA0B,QAAQ,MAAM,WACvE,cACA;AACJ,WAAO;AAAA,MACL,UAAU;AAAA;AAAA;AAAA,MAGV,UAAU,gBAAgB;AAAA,MAC1B;AAAA,MACA,YAAY;AAAA,MACZ;AAAA,IACF;AAAA,EACF;AAGA,SAAO;AAAA,IACL,UAAU;AAAA,IACV,UAAU,gBAAgB;AAAA,IAC1B;AAAA,IACA,YAAY;AAAA,IACZ,UAAU,eAAe,SAAS;AAAA,EACpC;AACF;AAEA,SAAS,0BAA0B,UAA4B;AAC7D,QAAM,MAAM,iBAAiB,QAAQ;AACrC,MAAI,CAAC,IAAK,QAAO;AACjB,SAAO,eAAe,UAAU,EAAE;AACpC;AAMA,eAAe,eACb,QACA,OACsB;AAItB,MAAI,OAAO,SAAS,UAAU,OAAO,SAAS,QAAQ;AACpD,UAAM,OAAO,OAAO,SAAS,SAAS,OAAO,OAAO,OAAO;AAC3D,WAAO,KAAK,MAAM,GAAG,KAAK,EAAE,YAAY;AAAA,EAC1C;AAEA,MAAI,OAAO,SAAS,eAAe;AACjC,WAAO,OAAO,OAAO,MAAM,GAAG,KAAK,IAAI,OAAO,OAAO,OAAO,UAAU,CAAC;AAAA,EACzE;AAIA,QAAM,SAAS,MAAM,wBAAwB,MAAM;AACnD,SAAO,OAAO,MAAM,GAAG,KAAK,IAAI,OAAO,OAAO,UAAU,CAAC;AAC3D;","names":[]}