@particle-academy/fancy-code 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,145 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@particle-academy/fancy-code` are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+
7
+ > **Pre-1.0:** breaking changes land in MINOR releases. Until 1.0 the minor
8
+ > number is not a compatibility promise — read the entry, not the version.
9
+
10
+ > This file starts here. Earlier releases predate it and were never written up;
11
+ > `git log` is the record for those. It is not backfilled rather than
12
+ > guessed-at, because a changelog that invents its own history is worse than one
13
+ > that admits where it begins.
14
+
15
+ ## [Unreleased]
16
+
17
+ ## [0.12.1] - 2026-10-18
18
+
19
+ ### Security
20
+
21
+ - **`<MarkdownEditor>`'s preview no longer renders a link or image whose URL
22
+ can run script.** The preview is built as an HTML string and inserted with
23
+ `innerHTML`, where React's `javascript:` guard does not apply. URLs were kept
24
+ inside their attribute but their scheme was never checked, so
25
+ `[x](javascript:...)` became a live script link in the preview, in the page's
26
+ own origin. A link now needs a relative URL or `http`, `https`, `mailto` or
27
+ `tel`; an image needs a relative URL, `http(s)`, or a raster
28
+ `data:image/...`. Anything else renders as its text (a link) or its alt text
29
+ (an image). The scheme is read the way a browser reads it: case-insensitive,
30
+ with leading control characters and embedded tabs and newlines ignored.
31
+ **Upgrade if any markdown you preview was written by someone else**, an agent
32
+ included. Nothing else changes. Found while auditing the viewers fancy-labs
33
+ uses to show agent-written documents.
34
+
35
+ ## [0.12.0] - 2026-10-17
36
+
37
+ ### Added
38
+
39
+ - **`<FileViewer line={42}>` lands on a line**: scrolled to the middle of the
40
+ view, caret placed, and highlighted — on mount and again whenever `line`
41
+ changes, so re-targeting an open file scrolls again. `column` places the caret
42
+ within it. Text files only; it does not steal focus.
43
+
44
+ Asked for by Genie 2, whose `openFileForUser(path, line)` now opens files in its
45
+ own FileViewer panel. `CodeEditor` could already reveal a line (`cursorLine`);
46
+ `FileViewer` never passed one through, and nothing marked the line on arrival.
47
+
48
+ - **`highlightLines` on `FileViewer` and `CodeEditor`** draws a full-width band
49
+ behind each listed line and bolds its gutter number. `FileViewer` highlights
50
+ `[line]` by default; pass a range or several hits to override, or `[]` to land
51
+ without a highlight. Lines outside the document are ignored. Measured in a real
52
+ browser: the band lines up with the gutter row to the pixel. Sized for
53
+ unwrapped lines, as the gutter is.
54
+
55
+ Handle: `data-fancy-code-highlight-line={n}`.
56
+
57
+ **What you must DO: nothing.** Both props are optional; without them the viewer
58
+ renders exactly as before.
59
+
60
+ ### Fixed
61
+
62
+ - **`CHANGELOG.md` is now in the published tarball.** `files` did not whitelist it, so npm never shipped it — and this package puts breaking changes in MINOR releases and tells you in the README to read the entry before taking one. The instruction existed for the author, who has the file, and not for the consumer, who is the only one being instructed. Nothing for you to do; the file simply arrives from this release on.
63
+
64
+ ### Security
65
+
66
+ - `source-map-js` is pinned forward to `^1.2.2` via `overrides`. Versions up to
67
+ 1.2.1 allow an event-loop denial of service through indexed source-map section
68
+ offsets, and it arrives here transitively through the build toolchain.
69
+ **Nothing for a consumer to do, and no runtime change**: an npm package does
70
+ not ship a lockfile, so this governs builds OF this repo, not anything
71
+ installed FROM it. Recorded rather than left silent because the override it
72
+ sits beside — `shell-quote` `^1.9.0`, added for an earlier advisory — was
73
+ carried with no note of why, and had drifted back inside the vulnerable range
74
+ before anyone looked.
75
+
76
+ ## [0.11.0] — 2026-08-14
77
+
78
+ ### Changed
79
+
80
+ - **The `MarkdownEditor` preview now renders through react-fancy's `<ContentRenderer format="markdown">`** — a full CommonMark + GFM parse — instead of the small built-in renderer.
81
+
82
+ Reported by a consumer reviewing documents through this surface, and measured before changing anything: a GFM table collapsed into a single run-on `<p>` (no table support at all), a hard-wrapped blockquote became one `<blockquote>` per source line, and a wrapped list item's continuation escaped the `<ul>` to become a `<p>`. On a document hard-wrapped at ~95 characters that degrades *everything*, and reads as though the author wrote it badly. Their most important block — a claim/evidence/status table — was the worst rendered.
83
+
84
+ The fix adds **no dependency**. `marked` was already in this tree as a runtime dependency of react-fancy, which fancy-code already requires, so the built-in renderer was a second, weaker markdown implementation sitting next to a complete one. Output is sanitised by default, as before.
85
+
86
+ **What you must do:** nothing to install, and nothing if you already pass `renderPreview` — that still wins and is still injected as-is. If you style the preview, note the rendered HTML is now marked's output: real `<table>`/`<thead>`/`<td>`, one `<blockquote>` per quote, and `<p>` inside `<blockquote>`. Selectors written against the old flattened markup may need updating.
87
+
88
+ `renderMarkdown` is unchanged and still exported for direct use; it is simply no longer the preview default.
89
+
90
+ ### Fixed
91
+
92
+ - **`maxHeight` now constrains the preview pane.** It was passed only to the editor, so a long document rendered at full height instead of scrolling. The preview gets `maxHeight` + `overflow-y: auto` when the prop is set.
93
+
94
+ ## [0.10.0] — 2026-08-07
95
+
96
+ ### Changed
97
+
98
+ - **BREAKING — Node 22 is now declared as the floor.** `engines.node` is `>=22`, where this package previously declared **nothing at all**.
99
+
100
+ Declaring nothing was not the same as supporting old Node: a consumer on 18 installed cleanly and found out at runtime.
101
+
102
+ **What you must do:** on Node 22 or newer, nothing. Note npm only *warns* on an `engines` mismatch while **pnpm fails the install**, so this surfaces differently depending on your package manager. Node 18 is end-of-life and 20 is maintenance-only.
103
+
104
+ - **BREAKING — React 18 is no longer supported.** `peerDependencies.react` / `react-dom` are now `^19.0.0`.
105
+
106
+ **What you must do:** on React 19, nothing. On React 18, stay on the previous release, or upgrade your app to 19 first.
107
+
108
+ React 18 support was a claim nothing tested — every build and test in this package ran against 19, so the 18 half of the old range was never executed. An untested compatibility claim is worse than an absent one, because it reads as support.
109
+
110
+ ### Why
111
+
112
+ These are the kit 0.5 platform floors, applied across every package at once so a consumer never has to resolve a mix. **No API changed, nothing was removed, nothing was renamed** — only what the package requires.
113
+
114
+
115
+ ## [0.9.1] — 2026-07-28
116
+
117
+ ### Fixed
118
+
119
+ - **`CodeEditorPanel` crashed when the editor engine arrived after first
120
+ render.** `if (!engine) return null` sat above the `useMemo` that builds the
121
+ gutter, so a render without an engine ran zero hooks and the next ran one —
122
+ React desyncs on the changed count and throws from inside itself.
123
+ `_engineReturn` is typed `| null` and starts null, so any host rendering the
124
+ panel before the engine settles hit this.
125
+
126
+ **No action needed** — the fix is internal and the API is unchanged.
127
+
128
+ ### Added
129
+
130
+ - `jsdom` as a devDependency, and the first component test in this package.
131
+ There was previously no way to render a component in a test here at all, which
132
+ is precisely how the bug above shipped.
133
+
134
+ ### Changed
135
+
136
+ - Widened the `@particle-academy/fancy-file-commons` requirement from `^0.2.0` to
137
+ `>=0.2 <2.0`, so a sibling minor release is an upgrade and not a resolver
138
+ conflict. **No action needed** — widening a range only adds candidates; the
139
+ version you have today still resolves.
140
+
141
+ A caret on a `0.x` range locks the MINOR, so this pinned a sibling at
142
+ whatever it happened to be on the day it was written, and each sibling
143
+ release then read as a conflict to the resolver rather than an upgrade.
144
+ Nothing here was using an API the newer minors removed — the range was the
145
+ whole problem.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Particle Academy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -12,7 +12,36 @@ npm install @particle-academy/fancy-code
12
12
  # or: yarn add @particle-academy/fancy-code
13
13
  ```
14
14
 
15
- **Peer dependencies:** `react >= 18`, `react-dom >= 18`, `@particle-academy/react-fancy >= 4.9` (the `FileViewer` delegates media to react-fancy's `<MediaViewer>`, added in 4.9)
15
+ **Peer dependencies:** `react ^19.0.0`, `react-dom ^19.0.0`, `@particle-academy/react-fancy >= 4.9` (the `FileViewer` delegates media to react-fancy's `<MediaViewer>`, added in 4.9)
16
+
17
+ ## Which version range to depend on
18
+
19
+ **Use a caret. `^0.11.0` is correct here, and it is deliberate.**
20
+
21
+ ```jsonc
22
+ "@particle-academy/fancy-code": "^0.11.0"
23
+ ```
24
+
25
+ This package is **pre-1.0, and breaking changes land in MINOR releases** — see
26
+ the note at the top of [`CHANGELOG.md`](./CHANGELOG.md). A caret on a `0.x` locks
27
+ the minor (npm reads `^0.11.0` as `>=0.11.0 <0.12.0`), so it gives you patches and
28
+ holds you at a surface you have already integrated against. That is the right
29
+ default when the next minor may change something under you.
30
+
31
+ **Moving up a minor is a deliberate act.** Read that version's `CHANGELOG.md`
32
+ entry before you do — breaking entries say what a consumer must actually DO, and
33
+ about half of what reads as breaking needs no action at all.
34
+
35
+ > **A note on the rest of the suite.** Some Fancy packages recommend an open
36
+ > `>=X <2.0` range instead. That is not an inconsistency: those packages carry a
37
+ > **runtime compatibility check** that fails loudly when a consumer is out of
38
+ > step — `fancy-connector-core`'s `CONNECTOR_API_VERSION` is the example — which
39
+ > is a stronger guarantee than a caret and makes the caret unnecessary. This
40
+ > package has no such check, so the caret is doing real work.
41
+ >
42
+ > The rule inside the suite differs again: first-party packages depend on each
43
+ > other with an open range because they are **released and tested together** at a
44
+ > kit version. A consumer is not, which is why the advice here is not the same.
16
45
 
17
46
  ## Setup
18
47
 
package/dist/index.cjs CHANGED
@@ -41,12 +41,14 @@ function CodeEditorPanel({ className }) {
41
41
  placeholder,
42
42
  _diffAnnotations: diffAnnotations,
43
43
  _maxHeight,
44
- _minHeight
44
+ _minHeight,
45
+ _highlightLines: highlightLines
45
46
  } = useCodeEditor();
46
47
  const lineNumberElements = react.useMemo(() => {
47
48
  if (!engine || !lineNumbers) return null;
48
49
  const { themeColors: themeColors2, lineCount: lineCount2, activeLine: activeLine2 } = engine;
49
50
  const removed = diffRemovedColor(themeColors2);
51
+ const highlighted = new Set(highlightLines ?? []);
50
52
  const lines = [];
51
53
  for (let i = 1; i <= lineCount2; i++) {
52
54
  const mark = gutterDiffMark(diffAnnotations, i);
@@ -58,8 +60,9 @@ function CodeEditorPanel({ className }) {
58
60
  "data-diff-deleted-above": mark?.deletedAbove ? "" : void 0,
59
61
  style: {
60
62
  position: "relative",
61
- color: i === activeLine2 ? themeColors2.foreground : themeColors2.gutterForeground,
62
- backgroundColor: i === activeLine2 ? themeColors2.activeLineBackground : void 0
63
+ color: i === activeLine2 || highlighted.has(i) ? themeColors2.foreground : themeColors2.gutterForeground,
64
+ backgroundColor: i === activeLine2 ? themeColors2.activeLineBackground : void 0,
65
+ fontWeight: highlighted.has(i) ? 600 : void 0
63
66
  },
64
67
  className: "pr-3 text-right text-[13px] leading-[1.5] select-none",
65
68
  children: [
@@ -119,7 +122,7 @@ function CodeEditorPanel({ className }) {
119
122
  );
120
123
  }
121
124
  return lines;
122
- }, [engine, lineNumbers, diffAnnotations]);
125
+ }, [engine, lineNumbers, diffAnnotations, highlightLines]);
123
126
  if (!engine) return null;
124
127
  const {
125
128
  textareaRef,
@@ -162,6 +165,21 @@ function CodeEditorPanel({ className }) {
162
165
  }
163
166
  ),
164
167
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: reactFancy.cn("relative flex-1", wordWrap && "min-w-0"), children: [
168
+ (highlightLines ?? []).filter((n) => Number.isInteger(n) && n >= 1 && n <= lineCount).map((n) => /* @__PURE__ */ jsxRuntime.jsx(
169
+ "div",
170
+ {
171
+ "data-fancy-code-highlight-line": n,
172
+ "aria-hidden": "true",
173
+ className: "pointer-events-none absolute inset-x-0 text-[13px] leading-[1.5]",
174
+ style: {
175
+ top: `calc(0.625rem + ${(n - 1) * 1.5}em)`,
176
+ height: "1.5em",
177
+ backgroundColor: themeColors.selectionBackground,
178
+ opacity: 0.55
179
+ }
180
+ },
181
+ `highlight-${n}`
182
+ )),
165
183
  /* @__PURE__ */ jsxRuntime.jsx(
166
184
  "pre",
167
185
  {
@@ -1517,7 +1535,8 @@ function CodeEditorRoot({
1517
1535
  maxHeight,
1518
1536
  cursorLine,
1519
1537
  cursorColumn,
1520
- diffBase
1538
+ diffBase,
1539
+ highlightLines
1521
1540
  }) {
1522
1541
  const [currentValue, setCurrentValue] = reactFancy.useControllableState(valueProp, defaultValue, onChange);
1523
1542
  const diffAnnotations = react.useMemo(
@@ -1610,9 +1629,10 @@ function CodeEditorRoot({
1610
1629
  _diffAnnotations: diffAnnotations,
1611
1630
  _engineReturn: engineReturn,
1612
1631
  _minHeight: minHeight,
1613
- _maxHeight: maxHeight
1632
+ _maxHeight: maxHeight,
1633
+ _highlightLines: highlightLines
1614
1634
  }),
1615
- [engineReturn, currentValue, currentLanguage, setLanguage, resolvedTheme, readOnly, showLineNumbers, isWordWrap, tabSizeProp, cursorPosition, selectionLength, setCurrentValue, placeholder, diffAnnotations, minHeight, maxHeight]
1635
+ [engineReturn, currentValue, currentLanguage, setLanguage, resolvedTheme, readOnly, showLineNumbers, isWordWrap, tabSizeProp, cursorPosition, selectionLength, setCurrentValue, placeholder, diffAnnotations, minHeight, maxHeight, highlightLines]
1616
1636
  );
1617
1637
  return /* @__PURE__ */ jsxRuntime.jsx(CodeEditorContext.Provider, { value: contextValue, children: /* @__PURE__ */ jsxRuntime.jsx(
1618
1638
  "div",
@@ -1717,6 +1737,9 @@ function FileViewer({
1717
1737
  fileKind,
1718
1738
  lineNumbers = true,
1719
1739
  wordWrap = false,
1740
+ line,
1741
+ column,
1742
+ highlightLines,
1720
1743
  minHeight,
1721
1744
  maxHeight,
1722
1745
  mediaProps,
@@ -1768,6 +1791,9 @@ function FileViewer({
1768
1791
  wordWrap,
1769
1792
  minHeight,
1770
1793
  maxHeight,
1794
+ cursorLine: line,
1795
+ cursorColumn: column,
1796
+ highlightLines: highlightLines ?? (line != null ? [line] : void 0),
1771
1797
  children: /* @__PURE__ */ jsxRuntime.jsx(CodeEditor.Panel, {})
1772
1798
  }
1773
1799
  )
@@ -1783,8 +1809,27 @@ function escapeHtml(s) {
1783
1809
  function escapeAttr(s) {
1784
1810
  return s.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1785
1811
  }
1812
+ var LINK_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel"]);
1813
+ function schemeOf(url) {
1814
+ let s = "";
1815
+ for (const ch of url) {
1816
+ const code = ch.codePointAt(0) ?? 0;
1817
+ if (code === 9 || code === 10 || code === 13) continue;
1818
+ if (s === "" && code <= 32) continue;
1819
+ s += ch;
1820
+ }
1821
+ const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(s);
1822
+ return m ? m[1].toLowerCase() : null;
1823
+ }
1824
+ function safeUrl(url, kind) {
1825
+ const scheme = schemeOf(url);
1826
+ if (scheme === null) return true;
1827
+ if (scheme === "http" || scheme === "https") return true;
1828
+ if (kind === "link") return LINK_SCHEMES.has(scheme);
1829
+ return scheme === "data" && /^data:image\/(png|jpe?g|gif|webp|avif|bmp);/i.test(url.trim());
1830
+ }
1786
1831
  function inline(text) {
1787
- 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>");
1832
+ 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>");
1788
1833
  }
1789
1834
  function renderMarkdown(markdown) {
1790
1835
  const lines = markdown.replace(/\r\n?/g, "\n").split("\n");