Skip to content

DataTable: add Table.CopyAsMarkdownButton with hardened markdown serializer#7857

Open
ianwinsemius wants to merge 2 commits into
primer:mainfrom
ianwinsemius:feat/datatable-copy-as-markdown
Open

DataTable: add Table.CopyAsMarkdownButton with hardened markdown serializer#7857
ianwinsemius wants to merge 2 commits into
primer:mainfrom
ianwinsemius:feat/datatable-copy-as-markdown

Conversation

@ianwinsemius
Copy link
Copy Markdown

Closes #

Adds an opt-in slot button for copying the visible rows of a DataTable to the system clipboard as a GitHub-flavoured Markdown table.

<Table.Container>
  <Table.Title id="repositories">Repositories</Table.Title>
  <Table.Actions>
    <Table.CopyAsMarkdownButton rows={repos} columns={columns} />
  </Table.Actions>
  <DataTable data={repos} columns={columns} />
</Table.Container>

Why review this PR carefully

A markdown-export feature can become a vulnerability surface if cell content can break out of a table cell, smuggle in HTML, or escape into the surrounding markdown. The implementation here is intentionally conservative — please scrutinise the escape rules and security model.

Security model (for review)

  1. Plain-text only. The clipboard payload is always text/plain. There is no HTML and no inline-HTML escape hatch (e.g. no <br>).
  2. No React/JSX in the export. renderCell is never called by the serializer. Cell projection order is:
    1. column.getExportValue(row) — explicit, type-safe, audited per-column
    2. The value at column.field (dotted path traversal)
    3. Empty string
      A spy-based test verifies renderCell is not invoked.
  3. Escaping rules in escapeMarkdownCell:
    • \\\ (done first so escaped pipes survive)
    • |\|
    • CR / LF / CRLF → single space
    • ASCII control characters \u0000–\u001f and Unicode C1 \u007f–\u009f → stripped (includes TAB, DEL)
    • Leading/trailing whitespace trimmed
  4. What is deliberately NOT escaped: backticks, brackets, asterisks. These cannot break the table layout and may be legitimately styled by downstream Markdown renderers. The contract is "cannot break out of the cell" — not "renders as plain text".
  5. Clipboard write prefers navigator.clipboard.writeText and falls back to a hidden-textarea document.execCommand('copy') for non-secure contexts and browsers that reject the async API.

Hostile-input test

There's a test case that feeds the serializer an explicit table-breakout attempt:

| evil |
| --- |
| `cmd` | [link](javascript:alert(1)) | **bold** |

and asserts:

  • No unescaped pipes remain.
  • No newlines remain.
  • The cell content stays inside the cell.

Changelog

New

  • Table.CopyAsMarkdownButton slot component (designed for Table.Actions).
  • Column.getExportValue?: (data: Data) => string — opt-in plain-text projection per column.
  • Helpers exported from the DataTable barrel: escapeMarkdownCell, rowsToMarkdown, writeTextToClipboard.

Changed

  • DataTable.docs.json updated with Table.CopyAsMarkdownButton and Column.getExportValue docs.

Removed

(none)

Rollout strategy

  • Patch release
  • Minor release
  • Major release; if selected, include a written rollout or migration plan
  • None; if selected, include a brief description as to why

Testing & Reviewing

  • 33 unit tests in packages/react/src/DataTable/__tests__/clipboard.test.tsx cover escape rules, hostile input, projection precedence, renderCell exclusion (spy-verified), array/object stringification, dotted field paths, well-formed table shape, empty data, header pipe escaping, callable header fallback, both clipboard write paths, and the component's transient label / onCopy callback / custom children.
  • 117 existing DataTable tests pass unchanged.
  • npm run build, npm run type-check, npm run lint, npm run lint:css, and npm test -- --run packages/react/src/DataTable/ all pass locally.
  • Story available under Experimental / Components / DataTable / Features / With Copy As Markdown.

Merge checklist

…alizer

Adds an opt-in slot button for copying the visible rows to the clipboard
as a GitHub-flavoured Markdown table.

Security model (audit hand-off):
- Cell values are projected via `Column.getExportValue` (preferred) or
  the field value via dotted-path traversal. `renderCell` is explicitly
  NEVER called — React/JSX output cannot reach the clipboard.
- Escaping rules: `\` → `\\`, `|` → `\|`, CR/LF → single space,
  ASCII / Unicode control characters (including TAB and DEL) stripped.
  Backticks, brackets, asterisks intentionally not escaped — they cannot
  break the table cell layout, and downstream Markdown renderers may
  legitimately style them.
- Output is always `text/plain`. No HTML, no inline-HTML escape hatches.
- Clipboard write prefers the async Clipboard API, falls back to
  `document.execCommand('copy')` via a hidden `<textarea>` for
  non-secure contexts.

API:
- New: `<Table.CopyAsMarkdownButton rows columns ... />` slot.
- New: `Column.getExportValue?: (data) => string` — declare a plain-text
  projection for columns whose `renderCell` emits React nodes.
- New helpers in `./clipboard.ts`: `escapeMarkdownCell`,
  `getExportableCellValue`, `rowsToMarkdown`, `writeTextToClipboard`.

Tests (33 cases):
- Escape rules: pipes, backslashes, CR/LF, ASCII/C1 controls, DEL,
  TAB, leading/trailing whitespace.
- Hostile input (table-breakout attempt) cannot break out of the cell.
- `getExportableCellValue` precedence, field traversal, dotted paths,
  arrays, plain objects, null/undefined.
- `renderCell` is never invoked by the serializer (verified via spy).
- `rowsToMarkdown`: header/separator/body shape, empty columns/data,
  header pipe escaping, callable `header` fallback to column id.
- `writeTextToClipboard`: Clipboard API path, execCommand fallback when
  Clipboard API rejects.
- Component: default label, click writes serialized markdown to
  clipboard, transient success label, onCopy callback, custom children.

Stories: `WithCopyAsMarkdown`.
Docs: `DataTable.docs.json` updated with `Table.CopyAsMarkdownButton` and
`Column.getExportValue`.
Changeset: minor.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 19, 2026 23:52
@ianwinsemius ianwinsemius requested a review from a team as a code owner May 19, 2026 23:52
@ianwinsemius ianwinsemius requested a review from liuliu-dev May 19, 2026 23:52
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 19, 2026

🦋 Changeset detected

Latest commit: 5c4a830

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@primer/react Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a DataTable slot action for copying tabular data as Markdown, plus serializer helpers and documentation/tests around the export behavior.

Changes:

  • Adds Table.CopyAsMarkdownButton and exports clipboard helpers from the DataTable barrel.
  • Introduces Markdown serialization, cell escaping, export-value projection, and clipboard write fallback logic.
  • Adds docs, Storybook example, tests, and a minor changeset for the new API.
Show a summary per file
File Description
packages/react/src/DataTable/index.ts Exports the new slot component, props type, and clipboard helpers.
packages/react/src/DataTable/DataTable.features.stories.tsx Adds a Storybook feature example for Markdown copying.
packages/react/src/DataTable/DataTable.docs.json Documents the new button and Column.getExportValue.
packages/react/src/DataTable/CopyAsMarkdownButton.tsx Implements the copy button UI/state and clipboard interaction.
packages/react/src/DataTable/CopyAsMarkdownButton.module.css Adds stable sizing for transient button labels.
packages/react/src/DataTable/column.ts Adds the optional getExportValue column projection hook.
packages/react/src/DataTable/clipboard.ts Implements Markdown escaping, row serialization, and clipboard writing.
packages/react/src/DataTable/__tests__/clipboard.test.tsx Adds serializer, clipboard, and component tests.
.changeset/datatable-copy-as-markdown.md Adds a minor changeset for the new DataTable API.

Copilot's findings

  • Files reviewed: 9/9 changed files
  • Comments generated: 2

Comment on lines +33 to +39
export function escapeMarkdownCell(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/\|/g, '\\|')
.replace(/\r\n|\r|\n/g, ' ')
.replace(CONTROL_CHARACTERS, '')
.trim()
Comment on lines +76 to +78
const handleClick = useCallback(async () => {
const markdown = rowsToMarkdown(rows, columns)
const ok = await writeTextToClipboard(markdown)
- Add clipboard.test.tsx to check-classname-tests.mjs via a manual
  className-forwarding test inside the file (the implementsClassName
  helper renders the component with no other props, but
  CopyAsMarkdownButton needs rows/columns to render anything).
- escapeMarkdownCell now backslash-escapes < and > so a cell
  containing raw HTML such as <img src=x onerror=...> is rendered as
  literal text by GFM/CommonMark renderers instead of being
  interpreted as HTML. This is the most important hardening — paste
  destinations like GitHub comments DO interpret HTML inside markdown.
- Architectural fix for the rows-source issue: introduce a small
  publish/subscribe snapshot store provided by Table.Container and
  populated by DataTable with its visible rows (post sort + filter +
  pagination). CopyAsMarkdownButton consumes the snapshot via
  useSyncExternalStore so it copies what the user actually sees, not
  the original `data` prop. Explicit `rows` / `columns` props still
  override the snapshot for standalone usage.
- Tests: angle-bracket escape verified; 3 new context tests cover
  context source, sorted-order reflection, and explicit-props override.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants