@ai-matrx/rich-content 0.2.9 → 0.2.10

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
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.10
4
+
5
+ - Markdown copy goes through the host's `app.CopyButtons` (the copy menu; matrx-frontend ALC-19).
6
+ - Table CSV/TSV and workbook export through Alchemy (`toDelimitedText`, `buildFile`); `display/tables/table-csv`.
7
+
8
+ ### Consumer action
9
+
10
+ - Register `app.CopyButtons` (matrx-frontend: `components/agent-copy/CopyButtons`).
11
+
3
12
  ## 0.2.9
4
13
 
5
14
  AP-2 (2026-10-07) — the kit and Alchemy doors are the only doors in this package (aidream guard `apps/shared/scripts/check-shared-doors.mjs`). New dependency `@ai-matrx/alchemy`; `html2canvas` dependency removed. Every table/code/SVG/JSON/mermaid/diagram download saves through `@ai-matrx/kit/download` (`downloadFile` / `downloadUrl`) — the tables' "Download Again" toast action now really downloads again (it clicked a revoked URL before). Link/URL/mermaid copies go through `@ai-matrx/kit/clipboard` `copyText` (copied state from its boolean; `copyMermaidSource`/`copyMermaidSvg` throw when refused, as before); ImageBlock's image copy uses `useClipboard().copyImage`. The diagram PNG export captures through `@ai-matrx/alchemy/operate/capture` `captureElement` with `safeColors` (the local getComputedStyle patch is gone). No consumer action.
@@ -49,16 +49,18 @@ export declare function detectTabular(value: unknown): TabularShape;
49
49
  * `null` and `undefined` become empty strings (Excel-friendly).
50
50
  */
51
51
  export declare function cellToCsv(value: unknown): string;
52
+ /**
53
+ * Rows -> CSV/TSV text through Alchemy's one writer (quoting, line breaks, `'` before a leading
54
+ * = + - @ text; real numbers stay numbers, so -5 is never altered).
55
+ */
52
56
  export declare function rowsToCsv(rows: Record<string, unknown>[], columns: string[], delimiter?: string): string;
53
57
  export declare function rowsToNdjson(rows: unknown[]): string;
54
58
  /** Browser file-download for a text payload. */
55
59
  export declare function downloadText(filename: string, content: string, mime?: string): void;
56
60
  /**
57
- * XLSX export uses the same `xlsx` package as `ImportTableModal`.
58
- * Dynamically imported to keep it out of the initial chat bundle.
59
- *
60
- * Cell values are pre-stringified for objects/arrays so spreadsheets
61
- * receive plain text instead of "[object Object]".
61
+ * XLSX export through Alchemy's workbook format (the one spreadsheet engine).
62
+ * Header row = `columns`; object/array cells are pre-stringified so
63
+ * spreadsheets receive plain text instead of "[object Object]".
62
64
  */
63
65
  export declare function rowsToXlsx(rows: Record<string, unknown>[], columns: string[], filename: string): Promise<void>;
64
66
  /** Default filename helper (no extension). */
@@ -1,5 +1,6 @@
1
+ import { buildFile } from "@ai-matrx/alchemy/operate";
2
+ import { toDelimitedText } from "@ai-matrx/alchemy/operate/read";
1
3
  import { downloadFile } from "@ai-matrx/kit/download";
2
- import { announceBareHost, hostCapability } from "../../../host/registry.js";
3
4
  function parseJsonSafe(text) {
4
5
  if (!text || !text.trim()) {
5
6
  return { ok: false, error: "Empty input" };
@@ -73,30 +74,38 @@ function cellToCsv(value) {
73
74
  }
74
75
  return String(value);
75
76
  }
76
- function escapeCsv(value, delimiter) {
77
- const needsQuote = value.includes(delimiter) || value.includes('"') || value.includes("\n") || value.includes("\r");
78
- if (!needsQuote) return value;
79
- return `"${value.replace(/"/g, '""')}"`;
80
- }
81
77
  function rowsToCsv(rows, columns, delimiter = ",") {
82
- const header = columns.map((c) => escapeCsv(c, delimiter)).join(delimiter);
83
- const body = rows.map(
84
- (row) => columns.map((c) => escapeCsv(cellToCsv(row[c]), delimiter)).join(delimiter)
85
- ).join("\n");
86
- return body ? `${header}
87
- ${body}` : header;
78
+ return toDelimitedText(
79
+ columns,
80
+ rows.map((row) => columns.map((c) => cellToCsvValue(row[c]))),
81
+ { format: delimiter === " " ? "tsv" : "csv", spreadsheetSafe: true }
82
+ );
83
+ }
84
+ function cellToCsvValue(value) {
85
+ if (value === null || value === void 0) return "";
86
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
87
+ return cellToCsv(value);
88
88
  }
89
89
  function rowsToNdjson(rows) {
90
90
  return rows.map((r) => JSON.stringify(r)).join("\n");
91
91
  }
92
92
  function downloadText(filename, content, mime = "text/plain") {
93
- downloadFile(filename, content, `${mime};charset=utf-8`);
93
+ const blob = new Blob([content], { type: `${mime};charset=utf-8` });
94
+ downloadFile(filename, blob, blob.type);
94
95
  }
95
96
  async function rowsToXlsx(rows, columns, filename) {
96
- const impl = hostCapability("app")?.rowsToXlsx;
97
- if (impl) return impl(rows, columns, filename);
98
- announceBareHost("app.rowsToXlsx", "spreadsheets download as CSV");
99
- downloadText(rowsToCsv(rows, columns), filename.replace(/\.xlsx$/i, "") + ".csv", "text/csv");
97
+ const cell = (v) => v === null || v === void 0 ? null : typeof v === "object" ? JSON.stringify(v) : typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? v : String(v);
98
+ const built = await buildFile(
99
+ {
100
+ kind: "registered",
101
+ format: "workbook",
102
+ value: { sheets: [{ name: "Data", rows: [columns, ...rows.map((row) => columns.map((c) => cell(row[c])))] }] }
103
+ },
104
+ "xlsx-workbook",
105
+ { filename }
106
+ );
107
+ const blob = built.blob();
108
+ downloadFile(built.filename, blob, blob.type);
100
109
  }
101
110
  function defaultJsonFilename() {
102
111
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
@@ -1,6 +1,5 @@
1
1
  "use client";
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
- import { downloadFile } from "@ai-matrx/kit/download";
4
3
  import { useClipboard } from "@ai-matrx/kit/clipboard";
5
4
  import { toast as copyToast } from "../../../host/toast.js";
6
5
  import { rewriteTableSource } from "../../../utils/table-source.js";
@@ -77,6 +76,8 @@ import {
77
76
  parseMarkdownTable,
78
77
  cleanTableHeaderKey
79
78
  } from "./parseMarkdownTable.js";
79
+ import { downloadFile } from "@ai-matrx/kit/download";
80
+ import { tableToCsv } from "../../tables/table-csv.js";
80
81
  const NO_HIDDEN_COLUMNS = /* @__PURE__ */ new Set();
81
82
  const ExportDropdownMenu = ({
82
83
  tableData,
@@ -292,21 +293,13 @@ const StreamingTableRendererCore = ({
292
293
  };
293
294
  const downloadCSV = () => {
294
295
  try {
295
- const csvContent = [
296
- headers.map((h) => h.replace(/"/g, '""')).join(","),
297
- ...rows.map(
298
- (row) => row.map((cell) => {
299
- const escaped = cell.replace(/"/g, '""');
300
- return cell.includes(",") ? `"${escaped}"` : escaped;
301
- }).join(",")
302
- )
303
- ].join("\n");
304
- const saveCsv = () => downloadFile("table_data.csv", csvContent, "text/csv;charset=utf-8");
305
- saveCsv();
296
+ const csvContent = tableToCsv(headers, rows);
297
+ const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
298
+ downloadFile("table_data.csv", blob, blob.type);
306
299
  toast.success("Table exported to CSV", {
307
300
  action: {
308
301
  label: "Download Again",
309
- onClick: saveCsv,
302
+ onClick: () => downloadFile("table_data.csv", blob, blob.type),
310
303
  className: "font-medium"
311
304
  }
312
305
  });
@@ -320,12 +313,14 @@ const StreamingTableRendererCore = ({
320
313
  try {
321
314
  const markdownContent = content || "";
322
315
  const fileName = headers && headers[0] ? `${headers[0].replace(/[^a-z0-9]/gi, "_").toLowerCase()}.md` : "table_data.md";
323
- const saveMarkdown = () => downloadFile(fileName, markdownContent, "text/markdown;charset=utf-8");
324
- saveMarkdown();
316
+ const blob = new Blob([markdownContent], {
317
+ type: "text/markdown;charset=utf-8"
318
+ });
319
+ downloadFile(fileName, blob, blob.type);
325
320
  toast.success("Markdown file downloaded", {
326
321
  action: {
327
322
  label: "Download Again",
328
- onClick: saveMarkdown,
323
+ onClick: () => downloadFile(fileName, blob, blob.type),
329
324
  className: "font-medium"
330
325
  }
331
326
  });
@@ -7,7 +7,7 @@ import {
7
7
  reportDelimiterViolations
8
8
  } from "@ai-matrx/kit/delimiter-guard";
9
9
  import { captureError } from "../../host/diagnostics.js";
10
- import { InlineCopyButton } from "../../host/app-bindings.js";
10
+ import { CopyButtons } from "../../host/app-bindings.js";
11
11
  import {
12
12
  TableRenderPathDiagnostic
13
13
  } from "../blocks/table/TableRenderPathDiagnostic.js";
@@ -102,12 +102,14 @@ const BasicMarkdownContentBody = ({
102
102
  /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: PROSE_FRAME_CSS } }),
103
103
  /* @__PURE__ */ jsx(MarkdownCore, { preset: "chat", components, children: processedContent }),
104
104
  !isStreamActive && isHovering && /* @__PURE__ */ jsx("div", { className: "absolute top-0 right-0 p-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200", children: showCopyButton && /* @__PURE__ */ jsx(
105
- InlineCopyButton,
105
+ CopyButtons,
106
106
  {
107
- markdownContent: content,
108
- position: "top-right",
109
- className: "mt-1 mr-1",
110
- isMarkdown: true
107
+ label: "Content",
108
+ human: content,
109
+ contentFlavor: "markdown",
110
+ stripThinking: true,
111
+ size: "sm",
112
+ className: "absolute top-1 right-1 z-10 mt-1 mr-1"
111
113
  }
112
114
  ) })
113
115
  ]
@@ -12,7 +12,7 @@ import {
12
12
  } from "@ai-matrx/kit/delimiter-guard";
13
13
  import { captureError } from "../../host/diagnostics.js";
14
14
  import { LinkComponent } from "../blocks/links/LinkComponent.js";
15
- import { InlineCopyButton } from "../../host/app-bindings.js";
15
+ import { CopyButtons } from "../../host/app-bindings.js";
16
16
  import {
17
17
  useMediaBlob,
18
18
  useMediaLoadRecovery
@@ -614,12 +614,14 @@ const ConfigurableMarkdownContentBody = ({
614
614
  /* @__PURE__ */ jsx(MarkdownCore, { preset: "rich", components, children: processedContent }),
615
615
  !isStreamActive && isHovering && /* @__PURE__ */ jsxs("div", { className: "absolute top-0 right-0 p-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200", children: [
616
616
  showCopyButton && /* @__PURE__ */ jsx(
617
- InlineCopyButton,
617
+ CopyButtons,
618
618
  {
619
- markdownContent: content,
620
- position: "top-right",
621
- className: "mt-1 mr-1",
622
- isMarkdown: true
619
+ label: "Content",
620
+ human: content,
621
+ contentFlavor: "markdown",
622
+ stripThinking: true,
623
+ size: "sm",
624
+ className: "absolute top-1 right-1 z-10 mt-1 mr-1"
623
625
  }
624
626
  ),
625
627
  onEditRequest && /* @__PURE__ */ jsx(
@@ -1,6 +1,5 @@
1
1
  "use client";
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
- import { downloadFile } from "@ai-matrx/kit/download";
4
3
  import { useClipboard } from "@ai-matrx/kit/clipboard";
5
4
  import { toast as copyToast } from "../../host/toast.js";
6
5
  import { rewriteTableSource } from "../../utils/table-source.js";
@@ -61,6 +60,8 @@ import {
61
60
  clearColumn,
62
61
  duplicateColumn
63
62
  } from "./editing/tableMutations.js";
63
+ import { downloadFile } from "@ai-matrx/kit/download";
64
+ import { tableToCsv } from "./table-csv.js";
64
65
  const ExportDropdownMenu = ({
65
66
  tableData,
66
67
  content,
@@ -291,21 +292,13 @@ const MarkdownTable = ({
291
292
  };
292
293
  const downloadCSV = () => {
293
294
  try {
294
- const csvContent = [
295
- internalTableData.headers.map((h) => h.replace(/"/g, '""')).join(","),
296
- ...internalTableData.rows.map(
297
- (row) => row.map((cell) => {
298
- const escaped = cell.replace(/"/g, '""');
299
- return cell.includes(",") ? `"${escaped}"` : escaped;
300
- }).join(",")
301
- )
302
- ].join("\n");
303
- const saveCsv = () => downloadFile("table_data.csv", csvContent, "text/csv;charset=utf-8");
304
- saveCsv();
295
+ const csvContent = tableToCsv(internalTableData.headers, internalTableData.rows);
296
+ const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
297
+ downloadFile("table_data.csv", blob, blob.type);
305
298
  toast.success("Table exported to CSV", {
306
299
  action: {
307
300
  label: "Download Again",
308
- onClick: saveCsv,
301
+ onClick: () => downloadFile("table_data.csv", blob, blob.type),
309
302
  className: "font-medium"
310
303
  }
311
304
  });
@@ -317,12 +310,14 @@ const MarkdownTable = ({
317
310
  try {
318
311
  const markdownContent = content || "";
319
312
  const fileName = internalTableData.headers && internalTableData.headers[0] ? `${internalTableData.headers[0].replace(/[^a-z0-9]/gi, "_").toLowerCase()}.md` : "table_data.md";
320
- const saveMarkdown = () => downloadFile(fileName, markdownContent, "text/markdown;charset=utf-8");
321
- saveMarkdown();
313
+ const blob = new Blob([markdownContent], {
314
+ type: "text/markdown;charset=utf-8"
315
+ });
316
+ downloadFile(fileName, blob, blob.type);
322
317
  toast.success("Markdown file downloaded", {
323
318
  action: {
324
319
  label: "Download Again",
325
- onClick: saveMarkdown,
320
+ onClick: () => downloadFile(fileName, blob, blob.type),
326
321
  className: "font-medium"
327
322
  }
328
323
  });
@@ -1,6 +1,5 @@
1
1
  "use client";
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
- import { downloadFile } from "@ai-matrx/kit/download";
4
3
  import { useClipboard } from "@ai-matrx/kit/clipboard";
5
4
  import { toast as copyToast } from "../../host/toast.js";
6
5
  import { rewriteTableSource } from "../../utils/table-source.js";
@@ -31,6 +30,8 @@ import {
31
30
  import { useToastManager } from "../../utils/lifted/useToastManager.js";
32
31
  import { THEMES } from "../themes.js";
33
32
  import { useOpenSaveToTable } from "../../host/tables.js";
33
+ import { downloadFile } from "@ai-matrx/kit/download";
34
+ import { tableToCsv } from "./table-csv.js";
34
35
  function useDebounce(value, delay) {
35
36
  const [debouncedValue, setDebouncedValue] = useState(value);
36
37
  useEffect(() => {
@@ -108,21 +109,13 @@ const TableControls = ({
108
109
  }, [content, copyTableToClipboard, toast]);
109
110
  const downloadCSV = useCallback(() => {
110
111
  try {
111
- const csvContent = [
112
- tableData.headers.map((h) => h.replace(/"/g, '""')).join(","),
113
- ...tableData.rows.map(
114
- (row) => row.map((cell) => {
115
- const escaped = cell.replace(/"/g, '""');
116
- return cell.includes(",") ? `"${escaped}"` : escaped;
117
- }).join(",")
118
- )
119
- ].join("\n");
120
- const saveCsv = () => downloadFile("table_data.csv", csvContent, "text/csv;charset=utf-8");
121
- saveCsv();
112
+ const csvContent = tableToCsv(tableData.headers, tableData.rows);
113
+ const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
114
+ downloadFile("table_data.csv", blob, blob.type);
122
115
  toast.success("Table exported to CSV", {
123
116
  action: {
124
117
  label: "Download Again",
125
- onClick: saveCsv,
118
+ onClick: () => downloadFile("table_data.csv", blob, blob.type),
126
119
  className: "font-medium"
127
120
  }
128
121
  });
@@ -134,12 +127,14 @@ const TableControls = ({
134
127
  try {
135
128
  const markdownContent = content || "";
136
129
  const fileName = tableData.headers && tableData.headers[0] ? `${tableData.headers[0].replace(/[^a-z0-9]/gi, "_").toLowerCase()}.md` : "table_data.md";
137
- const saveMarkdown = () => downloadFile(fileName, markdownContent, "text/markdown;charset=utf-8");
138
- saveMarkdown();
130
+ const blob = new Blob([markdownContent], {
131
+ type: "text/markdown;charset=utf-8"
132
+ });
133
+ downloadFile(fileName, blob, blob.type);
139
134
  toast.success("Markdown file downloaded", {
140
135
  action: {
141
136
  label: "Download Again",
142
- onClick: saveMarkdown,
137
+ onClick: () => downloadFile(fileName, blob, blob.type),
143
138
  className: "font-medium"
144
139
  }
145
140
  });
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The CSV text of a markdown/streaming table, written by Alchemy's one writer. Quotes, commas and
3
+ * line breaks are quoted; text that opens with = + - @ is prefixed `'` so a spreadsheet never runs
4
+ * it (alchemy `spreadsheetSafe`, on). A cell that is a plain number is written as a number, so a
5
+ * negative amount stays `-5`.
6
+ */
7
+ export declare function tableToCsv(headers: readonly string[], rows: readonly (readonly string[])[]): string;
@@ -0,0 +1,19 @@
1
+ import { toDelimitedText } from "@ai-matrx/alchemy/operate/read";
2
+ const PLAIN_NUMBER = /^-?\d+(\.\d+)?$/;
3
+ function cellValue(cell) {
4
+ if (PLAIN_NUMBER.test(cell)) {
5
+ const n = Number(cell);
6
+ if (String(n) === cell) return n;
7
+ }
8
+ return cell;
9
+ }
10
+ function tableToCsv(headers, rows) {
11
+ return toDelimitedText(
12
+ headers,
13
+ rows.map((row) => row.map(cellValue)),
14
+ { spreadsheetSafe: true }
15
+ );
16
+ }
17
+ export {
18
+ tableToCsv
19
+ };
@@ -47,6 +47,7 @@ export interface RichContentAppBindings {
47
47
  MarkdownStream: HostComponent;
48
48
  CitationMarkerInline: HostComponent;
49
49
  InlineCopyButton: HostComponent;
50
+ CopyButtons: HostComponent;
50
51
  AdvancedMenu: HostComponent;
51
52
  useAdvancedMenu: HostFn;
52
53
  EntityRef: HostComponent;
@@ -129,6 +130,7 @@ export declare const kindHasRecordChrome: HostFn;
129
130
  export declare const KindFixItBar: HostComponent;
130
131
  export declare const MarkdownStream: HostComponent;
131
132
  export declare const CitationMarkerInline: HostComponent;
133
+ export declare const CopyButtons: HostComponent;
132
134
  export declare const InlineCopyButton: HostComponent;
133
135
  export declare const AdvancedMenu: HostComponent;
134
136
  export declare const useAdvancedMenu: HostFn;
@@ -70,6 +70,7 @@ const kindHasRecordChrome = fn("kindHasRecordChrome", () => false, "kind records
70
70
  const KindFixItBar = component("KindFixItBar", null, "no fix-it bar on broken kinds");
71
71
  const MarkdownStream = component("MarkdownStream", null, "the full renderer is not registered");
72
72
  const CitationMarkerInline = component("CitationMarkerInline", TextOnly, "citation markers show as text");
73
+ const CopyButtons = component("CopyButtons", null, "no copy menu on rendered content");
73
74
  const InlineCopyButton = component("InlineCopyButton", null, "no inline copy button");
74
75
  const AdvancedMenu = component("AdvancedMenu", null, "no block menus");
75
76
  const useAdvancedMenu = fn("useAdvancedMenu", () => ({ isOpen: false, open: noop, close: noop, toggle: noop, menuProps: {} }), "no block menus");
@@ -105,6 +106,7 @@ export {
105
106
  ArtifactRender,
106
107
  CitationMarkerInline,
107
108
  CodeBlockWithContextAttach,
109
+ CopyButtons,
108
110
  EntityRef,
109
111
  IconInputWithValidation,
110
112
  InlineCopyButton,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-matrx/rich-content",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "The AI Matrx formatted-text engine: the one markdown renderer, streaming markdown, the block splitter and renderer, the generic blocks, <RichContent> levels, the rich-document action registry and menus, copy, and the selection toolbar — as portable React.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -115,20 +115,20 @@
115
115
  "unist-util-visit": "^5.1.0",
116
116
  "unist-util-visit-parents": "^6.0.2",
117
117
  "yaml": "^2.9.1",
118
- "@ai-matrx/agents": "latest",
119
118
  "@ai-matrx/alchemy": "latest",
120
- "@ai-matrx/canvas": "latest",
121
- "@ai-matrx/content-ir-react": "latest",
122
119
  "@ai-matrx/content-ir": "latest",
120
+ "@ai-matrx/content-ir-react": "latest",
123
121
  "@ai-matrx/data": "latest",
122
+ "@ai-matrx/agents": "latest",
124
123
  "@ai-matrx/design-system": "latest",
125
- "@ai-matrx/diff": "latest",
126
124
  "@ai-matrx/icons": "latest",
125
+ "@ai-matrx/canvas": "latest",
126
+ "@ai-matrx/kit": "latest",
127
127
  "@ai-matrx/print": "latest",
128
128
  "@ai-matrx/records": "latest",
129
- "@ai-matrx/tap-target": "latest",
130
129
  "@ai-matrx/media": "latest",
131
- "@ai-matrx/kit": "latest"
130
+ "@ai-matrx/tap-target": "latest",
131
+ "@ai-matrx/diff": "latest"
132
132
  },
133
133
  "peerDependencies": {
134
134
  "react": ">=19.0.0",