@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 +49 -0
- package/dist/index.cjs +94 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -2
- package/dist/index.d.ts +27 -2
- package/dist/index.js +95 -3
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
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
|
-
|
|
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 = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
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, """).replace(/'/g, "'");
|
|
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");
|