@particle-academy/fancy-code 0.10.0 → 0.12.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/dist/index.d.cts CHANGED
@@ -73,6 +73,12 @@ interface CodeEditorProps {
73
73
  cursorLine?: number;
74
74
  /** 1-based column for {@link cursorLine} (default 1). */
75
75
  cursorColumn?: number;
76
+ /**
77
+ * 1-based lines to mark with a full-width band — "here is the line you were
78
+ * sent to". Purely visual and independent of the caret; lines outside the
79
+ * document are ignored. Sized for unwrapped lines, as the gutter is.
80
+ */
81
+ highlightLines?: readonly number[];
76
82
  /**
77
83
  * Baseline document for the diff gutter. When set, the line-number column
78
84
  * shows per-line change marks of the CURRENT value against this baseline —
@@ -158,6 +164,8 @@ interface CodeEditorContextValue {
158
164
  _minHeight?: number;
159
165
  /** @internal */
160
166
  _maxHeight?: number;
167
+ /** @internal — lines to band; see {@link CodeEditorProps.highlightLines}. */
168
+ _highlightLines?: readonly number[];
161
169
  }
162
170
  interface CodeEditorToolbarProps {
163
171
  children?: ReactNode;
@@ -191,7 +199,7 @@ declare namespace CodeEditorStatusBar {
191
199
  var displayName: string;
192
200
  }
193
201
 
194
- declare function CodeEditorRoot({ children, className, value: valueProp, defaultValue, onChange, language: languageProp, onLanguageChange, theme, readOnly, lineNumbers: lineNumbersProp, wordWrap: wordWrapProp, tabSize: tabSizeProp, placeholder, minHeight, maxHeight, cursorLine, cursorColumn, diffBase, }: CodeEditorProps): react.JSX.Element;
202
+ declare function CodeEditorRoot({ children, className, value: valueProp, defaultValue, onChange, language: languageProp, onLanguageChange, theme, readOnly, lineNumbers: lineNumbersProp, wordWrap: wordWrapProp, tabSize: tabSizeProp, placeholder, minHeight, maxHeight, cursorLine, cursorColumn, diffBase, highlightLines, }: CodeEditorProps): react.JSX.Element;
195
203
  declare namespace CodeEditorRoot {
196
204
  var displayName: string;
197
205
  }
@@ -228,21 +236,30 @@ interface MarkdownEditorProps {
228
236
  wordWrap?: boolean;
229
237
  /** Placeholder shown when empty. */
230
238
  placeholder?: string;
231
- /** Editor height bounds (px). */
239
+ /**
240
+ * Height bounds (px). `maxHeight` constrains the PREVIEW pane as well as the
241
+ * editor — it previously reached only the editor, so a long document rendered
242
+ * at full height instead of scrolling.
243
+ */
232
244
  minHeight?: number;
233
245
  maxHeight?: number;
234
246
  className?: string;
235
247
  /**
236
- * Swap the markdown → HTML renderer used by the preview pane (e.g. a full
237
- * CommonMark library). Defaults to the built-in lightweight renderer.
248
+ * Swap the markdown → HTML renderer used by the preview pane.
249
+ *
250
+ * Rarely needed now: the preview renders through react-fancy's
251
+ * `<ContentRenderer format="markdown">`, which is a full CommonMark + GFM
252
+ * parse. Supply this only to impose your own pipeline — the returned HTML is
253
+ * injected as-is, so it must already be sanitised.
238
254
  */
239
255
  renderPreview?: (markdown: string) => string;
240
256
  }
241
257
  /**
242
258
  * A markdown-aware editor: a syntax-highlighted `CodeEditor` (the registered
243
259
  * `markdown` language) with an optional live preview pane. Controlled via
244
- * `value` + `onValueChange`. The preview renders with a small built-in
245
- * dependency-free renderer; pass `renderPreview` to use your own.
260
+ * `value` + `onValueChange`. The preview renders through react-fancy's
261
+ * `<ContentRenderer format="markdown">` — a full CommonMark + GFM parse,
262
+ * sanitised by default. Pass `renderPreview` to impose your own pipeline.
246
263
  *
247
264
  * ```tsx
248
265
  * <MarkdownEditor value={md} onValueChange={setMd} mode="split" minHeight={240} />
@@ -319,6 +336,19 @@ interface FileViewerProps {
319
336
  lineNumbers?: boolean;
320
337
  /** Wrap long lines in the text editor (default `false`). */
321
338
  wordWrap?: boolean;
339
+ /**
340
+ * 1-based line to land on: scrolled to the middle of the view, caret placed,
341
+ * and highlighted. Applied on mount and again whenever it changes, so
342
+ * re-targeting an open file scrolls again. Text files only; does not steal focus.
343
+ */
344
+ line?: number;
345
+ /** 1-based column for {@link line} (default 1). */
346
+ column?: number;
347
+ /**
348
+ * Lines to highlight. Defaults to `[line]` when `line` is set; pass your own
349
+ * (a range, several hits) to override, or `[]` to land without highlighting.
350
+ */
351
+ highlightLines?: readonly number[];
322
352
  /** Editor height bounds (px). */
323
353
  minHeight?: number;
324
354
  maxHeight?: number;
@@ -344,7 +374,7 @@ interface FileViewerProps {
344
374
  * <FileViewer filename="logo.png" src={url} style={{ height: 360 }} />
345
375
  * ```
346
376
  */
347
- declare function FileViewer({ filename, mime, src, value, onChange, readOnly, theme, fileKind, lineNumbers, wordWrap, minHeight, maxHeight, mediaProps, className, style, }: FileViewerProps): react.JSX.Element;
377
+ declare function FileViewer({ filename, mime, src, value, onChange, readOnly, theme, fileKind, lineNumbers, wordWrap, line, column, highlightLines, minHeight, maxHeight, mediaProps, className, style, }: FileViewerProps): react.JSX.Element;
348
378
  declare namespace FileViewer {
349
379
  var displayName: string;
350
380
  }
package/dist/index.d.ts CHANGED
@@ -73,6 +73,12 @@ interface CodeEditorProps {
73
73
  cursorLine?: number;
74
74
  /** 1-based column for {@link cursorLine} (default 1). */
75
75
  cursorColumn?: number;
76
+ /**
77
+ * 1-based lines to mark with a full-width band — "here is the line you were
78
+ * sent to". Purely visual and independent of the caret; lines outside the
79
+ * document are ignored. Sized for unwrapped lines, as the gutter is.
80
+ */
81
+ highlightLines?: readonly number[];
76
82
  /**
77
83
  * Baseline document for the diff gutter. When set, the line-number column
78
84
  * shows per-line change marks of the CURRENT value against this baseline —
@@ -158,6 +164,8 @@ interface CodeEditorContextValue {
158
164
  _minHeight?: number;
159
165
  /** @internal */
160
166
  _maxHeight?: number;
167
+ /** @internal — lines to band; see {@link CodeEditorProps.highlightLines}. */
168
+ _highlightLines?: readonly number[];
161
169
  }
162
170
  interface CodeEditorToolbarProps {
163
171
  children?: ReactNode;
@@ -191,7 +199,7 @@ declare namespace CodeEditorStatusBar {
191
199
  var displayName: string;
192
200
  }
193
201
 
194
- declare function CodeEditorRoot({ children, className, value: valueProp, defaultValue, onChange, language: languageProp, onLanguageChange, theme, readOnly, lineNumbers: lineNumbersProp, wordWrap: wordWrapProp, tabSize: tabSizeProp, placeholder, minHeight, maxHeight, cursorLine, cursorColumn, diffBase, }: CodeEditorProps): react.JSX.Element;
202
+ declare function CodeEditorRoot({ children, className, value: valueProp, defaultValue, onChange, language: languageProp, onLanguageChange, theme, readOnly, lineNumbers: lineNumbersProp, wordWrap: wordWrapProp, tabSize: tabSizeProp, placeholder, minHeight, maxHeight, cursorLine, cursorColumn, diffBase, highlightLines, }: CodeEditorProps): react.JSX.Element;
195
203
  declare namespace CodeEditorRoot {
196
204
  var displayName: string;
197
205
  }
@@ -228,21 +236,30 @@ interface MarkdownEditorProps {
228
236
  wordWrap?: boolean;
229
237
  /** Placeholder shown when empty. */
230
238
  placeholder?: string;
231
- /** Editor height bounds (px). */
239
+ /**
240
+ * Height bounds (px). `maxHeight` constrains the PREVIEW pane as well as the
241
+ * editor — it previously reached only the editor, so a long document rendered
242
+ * at full height instead of scrolling.
243
+ */
232
244
  minHeight?: number;
233
245
  maxHeight?: number;
234
246
  className?: string;
235
247
  /**
236
- * Swap the markdown → HTML renderer used by the preview pane (e.g. a full
237
- * CommonMark library). Defaults to the built-in lightweight renderer.
248
+ * Swap the markdown → HTML renderer used by the preview pane.
249
+ *
250
+ * Rarely needed now: the preview renders through react-fancy's
251
+ * `<ContentRenderer format="markdown">`, which is a full CommonMark + GFM
252
+ * parse. Supply this only to impose your own pipeline — the returned HTML is
253
+ * injected as-is, so it must already be sanitised.
238
254
  */
239
255
  renderPreview?: (markdown: string) => string;
240
256
  }
241
257
  /**
242
258
  * A markdown-aware editor: a syntax-highlighted `CodeEditor` (the registered
243
259
  * `markdown` language) with an optional live preview pane. Controlled via
244
- * `value` + `onValueChange`. The preview renders with a small built-in
245
- * dependency-free renderer; pass `renderPreview` to use your own.
260
+ * `value` + `onValueChange`. The preview renders through react-fancy's
261
+ * `<ContentRenderer format="markdown">` — a full CommonMark + GFM parse,
262
+ * sanitised by default. Pass `renderPreview` to impose your own pipeline.
246
263
  *
247
264
  * ```tsx
248
265
  * <MarkdownEditor value={md} onValueChange={setMd} mode="split" minHeight={240} />
@@ -319,6 +336,19 @@ interface FileViewerProps {
319
336
  lineNumbers?: boolean;
320
337
  /** Wrap long lines in the text editor (default `false`). */
321
338
  wordWrap?: boolean;
339
+ /**
340
+ * 1-based line to land on: scrolled to the middle of the view, caret placed,
341
+ * and highlighted. Applied on mount and again whenever it changes, so
342
+ * re-targeting an open file scrolls again. Text files only; does not steal focus.
343
+ */
344
+ line?: number;
345
+ /** 1-based column for {@link line} (default 1). */
346
+ column?: number;
347
+ /**
348
+ * Lines to highlight. Defaults to `[line]` when `line` is set; pass your own
349
+ * (a range, several hits) to override, or `[]` to land without highlighting.
350
+ */
351
+ highlightLines?: readonly number[];
322
352
  /** Editor height bounds (px). */
323
353
  minHeight?: number;
324
354
  maxHeight?: number;
@@ -344,7 +374,7 @@ interface FileViewerProps {
344
374
  * <FileViewer filename="logo.png" src={url} style={{ height: 360 }} />
345
375
  * ```
346
376
  */
347
- declare function FileViewer({ filename, mime, src, value, onChange, readOnly, theme, fileKind, lineNumbers, wordWrap, minHeight, maxHeight, mediaProps, className, style, }: FileViewerProps): react.JSX.Element;
377
+ declare function FileViewer({ filename, mime, src, value, onChange, readOnly, theme, fileKind, lineNumbers, wordWrap, line, column, highlightLines, minHeight, maxHeight, mediaProps, className, style, }: FileViewerProps): react.JSX.Element;
348
378
  declare namespace FileViewer {
349
379
  var displayName: string;
350
380
  }
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createContext, useMemo, useState, useCallback, useRef, useEffect, useSyncExternalStore, useContext } from 'react';
2
- import { cn, useControllableState, resolveMediaType, MediaViewer, textareaAdapter, InputTag } from '@particle-academy/react-fancy';
2
+ import { cn, useControllableState, ContentRenderer, resolveMediaType, MediaViewer, textareaAdapter, InputTag } from '@particle-academy/react-fancy';
3
3
  import { annotateLines, highlightCode, tok, tokenizeHtml, LIGHT_COLORS, DARK_COLORS, languageFromFilename } from '@particle-academy/fancy-file-commons';
4
4
  export { languageFromFilename } from '@particle-academy/fancy-file-commons';
5
5
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
@@ -40,12 +40,14 @@ function CodeEditorPanel({ className }) {
40
40
  placeholder,
41
41
  _diffAnnotations: diffAnnotations,
42
42
  _maxHeight,
43
- _minHeight
43
+ _minHeight,
44
+ _highlightLines: highlightLines
44
45
  } = useCodeEditor();
45
46
  const lineNumberElements = useMemo(() => {
46
47
  if (!engine || !lineNumbers) return null;
47
48
  const { themeColors: themeColors2, lineCount: lineCount2, activeLine: activeLine2 } = engine;
48
49
  const removed = diffRemovedColor(themeColors2);
50
+ const highlighted = new Set(highlightLines ?? []);
49
51
  const lines = [];
50
52
  for (let i = 1; i <= lineCount2; i++) {
51
53
  const mark = gutterDiffMark(diffAnnotations, i);
@@ -57,8 +59,9 @@ function CodeEditorPanel({ className }) {
57
59
  "data-diff-deleted-above": mark?.deletedAbove ? "" : void 0,
58
60
  style: {
59
61
  position: "relative",
60
- color: i === activeLine2 ? themeColors2.foreground : themeColors2.gutterForeground,
61
- backgroundColor: i === activeLine2 ? themeColors2.activeLineBackground : void 0
62
+ color: i === activeLine2 || highlighted.has(i) ? themeColors2.foreground : themeColors2.gutterForeground,
63
+ backgroundColor: i === activeLine2 ? themeColors2.activeLineBackground : void 0,
64
+ fontWeight: highlighted.has(i) ? 600 : void 0
62
65
  },
63
66
  className: "pr-3 text-right text-[13px] leading-[1.5] select-none",
64
67
  children: [
@@ -118,7 +121,7 @@ function CodeEditorPanel({ className }) {
118
121
  );
119
122
  }
120
123
  return lines;
121
- }, [engine, lineNumbers, diffAnnotations]);
124
+ }, [engine, lineNumbers, diffAnnotations, highlightLines]);
122
125
  if (!engine) return null;
123
126
  const {
124
127
  textareaRef,
@@ -161,6 +164,21 @@ function CodeEditorPanel({ className }) {
161
164
  }
162
165
  ),
163
166
  /* @__PURE__ */ jsxs("div", { className: cn("relative flex-1", wordWrap && "min-w-0"), children: [
167
+ (highlightLines ?? []).filter((n) => Number.isInteger(n) && n >= 1 && n <= lineCount).map((n) => /* @__PURE__ */ jsx(
168
+ "div",
169
+ {
170
+ "data-fancy-code-highlight-line": n,
171
+ "aria-hidden": "true",
172
+ className: "pointer-events-none absolute inset-x-0 text-[13px] leading-[1.5]",
173
+ style: {
174
+ top: `calc(0.625rem + ${(n - 1) * 1.5}em)`,
175
+ height: "1.5em",
176
+ backgroundColor: themeColors.selectionBackground,
177
+ opacity: 0.55
178
+ }
179
+ },
180
+ `highlight-${n}`
181
+ )),
164
182
  /* @__PURE__ */ jsx(
165
183
  "pre",
166
184
  {
@@ -1516,7 +1534,8 @@ function CodeEditorRoot({
1516
1534
  maxHeight,
1517
1535
  cursorLine,
1518
1536
  cursorColumn,
1519
- diffBase
1537
+ diffBase,
1538
+ highlightLines
1520
1539
  }) {
1521
1540
  const [currentValue, setCurrentValue] = useControllableState(valueProp, defaultValue, onChange);
1522
1541
  const diffAnnotations = useMemo(
@@ -1609,9 +1628,10 @@ function CodeEditorRoot({
1609
1628
  _diffAnnotations: diffAnnotations,
1610
1629
  _engineReturn: engineReturn,
1611
1630
  _minHeight: minHeight,
1612
- _maxHeight: maxHeight
1631
+ _maxHeight: maxHeight,
1632
+ _highlightLines: highlightLines
1613
1633
  }),
1614
- [engineReturn, currentValue, currentLanguage, setLanguage, resolvedTheme, readOnly, showLineNumbers, isWordWrap, tabSizeProp, cursorPosition, selectionLength, setCurrentValue, placeholder, diffAnnotations, minHeight, maxHeight]
1634
+ [engineReturn, currentValue, currentLanguage, setLanguage, resolvedTheme, readOnly, showLineNumbers, isWordWrap, tabSizeProp, cursorPosition, selectionLength, setCurrentValue, placeholder, diffAnnotations, minHeight, maxHeight, highlightLines]
1615
1635
  );
1616
1636
  return /* @__PURE__ */ jsx(CodeEditorContext.Provider, { value: contextValue, children: /* @__PURE__ */ jsx(
1617
1637
  "div",
@@ -1634,105 +1654,6 @@ var CodeEditor = Object.assign(CodeEditorRoot, {
1634
1654
  Panel: CodeEditorPanel,
1635
1655
  StatusBar: CodeEditorStatusBar
1636
1656
  });
1637
-
1638
- // src/engine/markdown-render.ts
1639
- function escapeHtml(s) {
1640
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1641
- }
1642
- function escapeAttr(s) {
1643
- return s.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1644
- }
1645
- function inline(text) {
1646
- 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>");
1647
- }
1648
- function renderMarkdown(markdown) {
1649
- const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
1650
- const out = [];
1651
- let i = 0;
1652
- let para = [];
1653
- let listType = null;
1654
- const flushPara = () => {
1655
- if (para.length) {
1656
- out.push(`<p>${inline(para.join(" "))}</p>`);
1657
- para = [];
1658
- }
1659
- };
1660
- const closeList = () => {
1661
- if (listType) {
1662
- out.push(`</${listType}>`);
1663
- listType = null;
1664
- }
1665
- };
1666
- while (i < lines.length) {
1667
- const line = lines[i];
1668
- const fence = line.match(/^\s*(```|~~~)(.*)$/);
1669
- if (fence) {
1670
- flushPara();
1671
- closeList();
1672
- const lang = fence[2].trim();
1673
- const body = [];
1674
- i++;
1675
- while (i < lines.length && !/^\s*(```|~~~)/.test(lines[i])) {
1676
- body.push(lines[i]);
1677
- i++;
1678
- }
1679
- i++;
1680
- out.push(`<pre><code${lang ? ` class="language-${escapeAttr(escapeHtml(lang))}"` : ""}>${escapeHtml(body.join("\n"))}</code></pre>`);
1681
- continue;
1682
- }
1683
- const esc = escapeHtml(line);
1684
- if (/^\s*$/.test(line)) {
1685
- flushPara();
1686
- closeList();
1687
- i++;
1688
- continue;
1689
- }
1690
- const heading = esc.match(/^(#{1,6})\s+(.*)$/);
1691
- if (heading) {
1692
- flushPara();
1693
- closeList();
1694
- const level = heading[1].length;
1695
- out.push(`<h${level}>${inline(heading[2])}</h${level}>`);
1696
- i++;
1697
- continue;
1698
- }
1699
- if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
1700
- flushPara();
1701
- closeList();
1702
- out.push("<hr>");
1703
- i++;
1704
- continue;
1705
- }
1706
- const quote = esc.match(/^\s*&gt;\s?(.*)$/);
1707
- if (quote) {
1708
- flushPara();
1709
- closeList();
1710
- out.push(`<blockquote>${inline(quote[1])}</blockquote>`);
1711
- i++;
1712
- continue;
1713
- }
1714
- const ol = esc.match(/^\s*\d+\.\s+(.*)$/);
1715
- const ul = esc.match(/^\s*[-*+]\s+(.*)$/);
1716
- if (ol || ul) {
1717
- flushPara();
1718
- const type = ol ? "ol" : "ul";
1719
- if (listType !== type) {
1720
- closeList();
1721
- out.push(`<${type}>`);
1722
- listType = type;
1723
- }
1724
- out.push(`<li>${inline((ol ?? ul)[1])}</li>`);
1725
- i++;
1726
- continue;
1727
- }
1728
- closeList();
1729
- para.push(esc);
1730
- i++;
1731
- }
1732
- flushPara();
1733
- closeList();
1734
- return out.join("\n");
1735
- }
1736
1657
  function MarkdownEditor({
1737
1658
  value,
1738
1659
  defaultValue = "",
@@ -1746,7 +1667,7 @@ function MarkdownEditor({
1746
1667
  minHeight,
1747
1668
  maxHeight,
1748
1669
  className,
1749
- renderPreview = renderMarkdown
1670
+ renderPreview
1750
1671
  }) {
1751
1672
  const [content, setContent] = useControllableState(value, defaultValue, onValueChange);
1752
1673
  const showEditor = mode !== "preview";
@@ -1779,7 +1700,19 @@ function MarkdownEditor({
1779
1700
  {
1780
1701
  "data-fancy-markdown-preview": "",
1781
1702
  className: "fancy-md-preview",
1782
- dangerouslySetInnerHTML: { __html: renderPreview(content) }
1703
+ style: maxHeight ? { maxHeight, overflowY: "auto" } : void 0,
1704
+ children: renderPreview ? (
1705
+ // The host imposed its own pipeline; its HTML is injected as-is and
1706
+ // is its own responsibility to sanitise.
1707
+ /* @__PURE__ */ jsx("div", { dangerouslySetInnerHTML: { __html: renderPreview(content) } })
1708
+ ) : (
1709
+ // The kit's own renderer — a full CommonMark + GFM parse that
1710
+ // sanitises by default. `marked` already sits in this tree as a
1711
+ // react-fancy runtime dependency, so the built-in renderer this
1712
+ // replaced was a second, weaker markdown implementation next to a
1713
+ // complete one.
1714
+ /* @__PURE__ */ jsx(ContentRenderer, { value: content, format: "markdown" })
1715
+ )
1783
1716
  }
1784
1717
  )
1785
1718
  ]
@@ -1803,6 +1736,9 @@ function FileViewer({
1803
1736
  fileKind,
1804
1737
  lineNumbers = true,
1805
1738
  wordWrap = false,
1739
+ line,
1740
+ column,
1741
+ highlightLines,
1806
1742
  minHeight,
1807
1743
  maxHeight,
1808
1744
  mediaProps,
@@ -1854,6 +1790,9 @@ function FileViewer({
1854
1790
  wordWrap,
1855
1791
  minHeight,
1856
1792
  maxHeight,
1793
+ cursorLine: line,
1794
+ cursorColumn: column,
1795
+ highlightLines: highlightLines ?? (line != null ? [line] : void 0),
1857
1796
  children: /* @__PURE__ */ jsx(CodeEditor.Panel, {})
1858
1797
  }
1859
1798
  )
@@ -1861,6 +1800,105 @@ function FileViewer({
1861
1800
  );
1862
1801
  }
1863
1802
  FileViewer.displayName = "FileViewer";
1803
+
1804
+ // src/engine/markdown-render.ts
1805
+ function escapeHtml(s) {
1806
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1807
+ }
1808
+ function escapeAttr(s) {
1809
+ return s.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1810
+ }
1811
+ function inline(text) {
1812
+ 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>");
1813
+ }
1814
+ function renderMarkdown(markdown) {
1815
+ const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
1816
+ const out = [];
1817
+ let i = 0;
1818
+ let para = [];
1819
+ let listType = null;
1820
+ const flushPara = () => {
1821
+ if (para.length) {
1822
+ out.push(`<p>${inline(para.join(" "))}</p>`);
1823
+ para = [];
1824
+ }
1825
+ };
1826
+ const closeList = () => {
1827
+ if (listType) {
1828
+ out.push(`</${listType}>`);
1829
+ listType = null;
1830
+ }
1831
+ };
1832
+ while (i < lines.length) {
1833
+ const line = lines[i];
1834
+ const fence = line.match(/^\s*(```|~~~)(.*)$/);
1835
+ if (fence) {
1836
+ flushPara();
1837
+ closeList();
1838
+ const lang = fence[2].trim();
1839
+ const body = [];
1840
+ i++;
1841
+ while (i < lines.length && !/^\s*(```|~~~)/.test(lines[i])) {
1842
+ body.push(lines[i]);
1843
+ i++;
1844
+ }
1845
+ i++;
1846
+ out.push(`<pre><code${lang ? ` class="language-${escapeAttr(escapeHtml(lang))}"` : ""}>${escapeHtml(body.join("\n"))}</code></pre>`);
1847
+ continue;
1848
+ }
1849
+ const esc = escapeHtml(line);
1850
+ if (/^\s*$/.test(line)) {
1851
+ flushPara();
1852
+ closeList();
1853
+ i++;
1854
+ continue;
1855
+ }
1856
+ const heading = esc.match(/^(#{1,6})\s+(.*)$/);
1857
+ if (heading) {
1858
+ flushPara();
1859
+ closeList();
1860
+ const level = heading[1].length;
1861
+ out.push(`<h${level}>${inline(heading[2])}</h${level}>`);
1862
+ i++;
1863
+ continue;
1864
+ }
1865
+ if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
1866
+ flushPara();
1867
+ closeList();
1868
+ out.push("<hr>");
1869
+ i++;
1870
+ continue;
1871
+ }
1872
+ const quote = esc.match(/^\s*&gt;\s?(.*)$/);
1873
+ if (quote) {
1874
+ flushPara();
1875
+ closeList();
1876
+ out.push(`<blockquote>${inline(quote[1])}</blockquote>`);
1877
+ i++;
1878
+ continue;
1879
+ }
1880
+ const ol = esc.match(/^\s*\d+\.\s+(.*)$/);
1881
+ const ul = esc.match(/^\s*[-*+]\s+(.*)$/);
1882
+ if (ol || ul) {
1883
+ flushPara();
1884
+ const type = ol ? "ol" : "ul";
1885
+ if (listType !== type) {
1886
+ closeList();
1887
+ out.push(`<${type}>`);
1888
+ listType = type;
1889
+ }
1890
+ out.push(`<li>${inline((ol ?? ul)[1])}</li>`);
1891
+ i++;
1892
+ continue;
1893
+ }
1894
+ closeList();
1895
+ para.push(esc);
1896
+ i++;
1897
+ }
1898
+ flushPara();
1899
+ closeList();
1900
+ return out.join("\n");
1901
+ }
1864
1902
  function useCodeEditorInputTagAdapter() {
1865
1903
  const ctx = useCodeEditor();
1866
1904
  return useMemo(() => textareaAdapter(ctx.textareaRef), [ctx.textareaRef]);