@particle-academy/fancy-code 0.12.0 → 0.13.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/CHANGELOG.md CHANGED
@@ -14,6 +14,55 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.13.0] - 2026-10-19
18
+
19
+ ### Added
20
+
21
+ - **Documents render as a reader sees them.** `FileViewer` shows Markdown, HTML
22
+ and plain text through react-fancy's `ContentRenderer`, sanitised (a file is
23
+ untrusted), in the `FauxClient` frame every file viewer now carries: zoom and
24
+ full screen, revealed on hover. Plain text keeps its paragraphs and line
25
+ breaks and is escaped, so `*x*` stays asterisks. `view="source"` shows the
26
+ source in the code editor instead, as do editing (`readOnly={false}`) and
27
+ landing on a `line`. `frameProps` reaches the frame.
28
+ - **Office files are recognised.** `.docx`, `.xlsx` and `.pptx` (by extension
29
+ or MIME type) resolve to `{ kind: "office", officeKind }`. They show
30
+ MediaViewer's no-preview card with a download until their viewers are wired
31
+ in. They used to be handed to the code editor as text.
32
+
33
+ ### Changed
34
+
35
+ - **BREAKING:** a `.md`, `.html` or `.txt` file in `FileViewer` is rendered,
36
+ not shown as source. **What to do:** to keep the source, pass
37
+ `view="source"`. A viewer that lands on a `line` (a link into a file) already
38
+ shows the source, so file links need nothing.
39
+ - **BREAKING (types):** `FileKind` gains `{ kind: "document"; format; language }`
40
+ and `{ kind: "office"; officeKind }`. **What to do:** only if you `switch` on
41
+ `resolveFileKind(...).kind` exhaustively: add the two cases, or a `default`.
42
+ `notes.txt` now resolves to `document`, not `text`.
43
+ - The extension decides over a generic MIME type: a host that serves `app.ts`
44
+ as `text/plain` still gets the code editor.
45
+ - **Requires react-fancy 5.48.1 or later** (peer `>=5.48.1 <6.0.0`, was
46
+ `>=4.9.0`), for the frame and for the sanitiser that strips `<title>`.
47
+
48
+ ## [0.12.1] - 2026-10-18
49
+
50
+ ### Security
51
+
52
+ - **`<MarkdownEditor>`'s preview no longer renders a link or image whose URL
53
+ can run script.** The preview is built as an HTML string and inserted with
54
+ `innerHTML`, where React's `javascript:` guard does not apply. URLs were kept
55
+ inside their attribute but their scheme was never checked, so
56
+ `[x](javascript:...)` became a live script link in the preview, in the page's
57
+ own origin. A link now needs a relative URL or `http`, `https`, `mailto` or
58
+ `tel`; an image needs a relative URL, `http(s)`, or a raster
59
+ `data:image/...`. Anything else renders as its text (a link) or its alt text
60
+ (an image). The scheme is read the way a browser reads it: case-insensitive,
61
+ with leading control characters and embedded tabs and newlines ignored.
62
+ **Upgrade if any markdown you preview was written by someone else**, an agent
63
+ included. Nothing else changes. Found while auditing the viewers fancy-labs
64
+ uses to show agent-written documents.
65
+
17
66
  ## [0.12.0] - 2026-10-17
18
67
 
19
68
  ### Added
package/dist/index.cjs CHANGED
@@ -1721,10 +1721,45 @@ function MarkdownEditor({
1721
1721
  );
1722
1722
  }
1723
1723
  MarkdownEditor.displayName = "MarkdownEditor";
1724
+ var OFFICE_EXT = { docx: "docx", xlsx: "xlsx", xlsm: "xlsx", pptx: "pptx" };
1725
+ var OFFICE_MIME = {
1726
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
1727
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
1728
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx"
1729
+ };
1730
+ var DOCUMENT_EXT = {
1731
+ md: "markdown",
1732
+ markdown: "markdown",
1733
+ html: "html",
1734
+ htm: "html",
1735
+ txt: "text",
1736
+ text: "text"
1737
+ };
1738
+ var DOCUMENT_MIME = {
1739
+ "text/markdown": "markdown",
1740
+ "text/x-markdown": "markdown",
1741
+ "text/html": "html",
1742
+ "text/plain": "text"
1743
+ };
1744
+ function extensionOf(filename) {
1745
+ const name = (filename ?? "").split(/[?#]/)[0].split("/").pop() ?? "";
1746
+ const dot = name.lastIndexOf(".");
1747
+ return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
1748
+ }
1724
1749
  function resolveFileKind({ filename, mime }) {
1725
1750
  const media = reactFancy.resolveMediaType({ mime, src: filename });
1726
1751
  if (media !== "unknown") return { kind: "media", mediaKind: media };
1727
- return { kind: "text", language: fancyFileCommons.languageFromFilename(filename) };
1752
+ const type = (mime ?? "").split(";")[0].trim().toLowerCase();
1753
+ const ext = extensionOf(filename);
1754
+ const office = OFFICE_MIME[type] ?? OFFICE_EXT[ext];
1755
+ if (office) return { kind: "office", officeKind: office };
1756
+ const language = fancyFileCommons.languageFromFilename(filename);
1757
+ const format = DOCUMENT_EXT[ext] ?? (language === "plaintext" ? DOCUMENT_MIME[type] : void 0);
1758
+ if (format) {
1759
+ const lang = language !== "plaintext" ? language : format === "markdown" ? "markdown" : format === "html" ? "html" : "plaintext";
1760
+ return { kind: "document", format, language: lang };
1761
+ }
1762
+ return { kind: "text", language };
1728
1763
  }
1729
1764
  function FileViewer({
1730
1765
  filename,
@@ -1743,10 +1778,44 @@ function FileViewer({
1743
1778
  minHeight,
1744
1779
  maxHeight,
1745
1780
  mediaProps,
1781
+ view = "rendered",
1782
+ frameProps,
1746
1783
  className,
1747
1784
  style
1748
1785
  }) {
1749
1786
  const resolved = fileKind ?? resolveFileKind({ filename, mime });
1787
+ if (resolved.kind === "document" && view === "rendered" && readOnly && line == null) {
1788
+ return /* @__PURE__ */ jsxRuntime.jsx(
1789
+ "div",
1790
+ {
1791
+ "data-fancy-file-viewer": "",
1792
+ "data-kind": "document",
1793
+ "data-format": resolved.format,
1794
+ className,
1795
+ style,
1796
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactFancy.FauxClient, { variant: "device", controls: true, fill: true, bodyClassName: "overflow-auto", ...frameProps, children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-5", children: /* @__PURE__ */ jsxRuntime.jsx(
1797
+ reactFancy.ContentRenderer,
1798
+ {
1799
+ value: resolved.format === "text" ? textToHtml(value) : value,
1800
+ format: resolved.format === "markdown" ? "markdown" : "html"
1801
+ }
1802
+ ) }) })
1803
+ }
1804
+ );
1805
+ }
1806
+ if (resolved.kind === "office") {
1807
+ return /* @__PURE__ */ jsxRuntime.jsx(
1808
+ "div",
1809
+ {
1810
+ "data-fancy-file-viewer": "",
1811
+ "data-kind": "office",
1812
+ "data-office-kind": resolved.officeKind,
1813
+ className: reactFancy.cn("overflow-hidden", className),
1814
+ style,
1815
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactFancy.MediaViewer, { src: src ?? "", mime, alt: filename, className: "h-full w-full" })
1816
+ }
1817
+ );
1818
+ }
1750
1819
  if (resolved.kind === "media") {
1751
1820
  const { className: mediaClassName, ...restMediaProps } = mediaProps ?? {};
1752
1821
  const fill = resolved.mediaKind !== "audio";
@@ -1801,6 +1870,10 @@ function FileViewer({
1801
1870
  );
1802
1871
  }
1803
1872
  FileViewer.displayName = "FileViewer";
1873
+ var ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
1874
+ function textToHtml(text) {
1875
+ return text.replace(/\r\n?/g, "\n").split(/\n{2,}/).filter((block) => block.trim() !== "").map((block) => `<p>${block.replace(/[&<>"']/g, (c) => ESCAPES[c]).replace(/\n/g, "<br>")}</p>`).join("");
1876
+ }
1804
1877
 
1805
1878
  // src/engine/markdown-render.ts
1806
1879
  function escapeHtml(s) {
@@ -1809,8 +1882,27 @@ function escapeHtml(s) {
1809
1882
  function escapeAttr(s) {
1810
1883
  return s.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1811
1884
  }
1885
+ var LINK_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel"]);
1886
+ function schemeOf(url) {
1887
+ let s = "";
1888
+ for (const ch of url) {
1889
+ const code = ch.codePointAt(0) ?? 0;
1890
+ if (code === 9 || code === 10 || code === 13) continue;
1891
+ if (s === "" && code <= 32) continue;
1892
+ s += ch;
1893
+ }
1894
+ const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(s);
1895
+ return m ? m[1].toLowerCase() : null;
1896
+ }
1897
+ function safeUrl(url, kind) {
1898
+ const scheme = schemeOf(url);
1899
+ if (scheme === null) return true;
1900
+ if (scheme === "http" || scheme === "https") return true;
1901
+ if (kind === "link") return LINK_SCHEMES.has(scheme);
1902
+ return scheme === "data" && /^data:image\/(png|jpe?g|gif|webp|avif|bmp);/i.test(url.trim());
1903
+ }
1812
1904
  function inline(text) {
1813
- return text.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (_m, alt, src, title) => `<img src="${escapeAttr(src)}" alt="${escapeAttr(alt)}"${title ? ` title="${escapeAttr(title)}"` : ""}>`).replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (_m, t, href, title) => `<a href="${escapeAttr(href)}"${title ? ` title="${escapeAttr(title)}"` : ""} rel="noopener">${t}</a>`).replace(/`([^`]+)`/g, (_m, c) => `<code>${c}</code>`).replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, "<strong>$2</strong>").replace(/(\*|_)(?=\S)([\s\S]*?\S)\1/g, "<em>$2</em>");
1905
+ return text.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (_m, alt, src, title) => safeUrl(src, "image") ? `<img src="${escapeAttr(src)}" alt="${escapeAttr(alt)}"${title ? ` title="${escapeAttr(title)}"` : ""}>` : alt).replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (_m, t, href, title) => safeUrl(href, "link") ? `<a href="${escapeAttr(href)}"${title ? ` title="${escapeAttr(title)}"` : ""} rel="noopener">${t}</a>` : t).replace(/`([^`]+)`/g, (_m, c) => `<code>${c}</code>`).replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, "<strong>$2</strong>").replace(/(\*|_)(?=\S)([\s\S]*?\S)\1/g, "<em>$2</em>");
1814
1906
  }
1815
1907
  function renderMarkdown(markdown) {
1816
1908
  const lines = markdown.replace(/\r\n?/g, "\n").split("\n");