`, `| `, and
+ ` | ` nodes, with no CSS, colspan, rowspan, or custom attributes.
+ A plain-text `body` is generated alongside for clients that ignore
+ `formatted_body`.
+* `mx_markdown_to_html()` converts GitHub-style pipe tables to the same
+ table HTML, honouring the `:---`/`:---:`/`---:` alignment row.
+* New: `inst/skills/mx.client/matrix-messaging/SKILL.md`.
+
# mx.client 0.1.1.1
* `mx_extract_text_events()` keeps the event's `origin_server_ts` as a
diff --git a/R/markdown.R b/R/markdown.R
index f0262c7..4df1bcd 100644
--- a/R/markdown.R
+++ b/R/markdown.R
@@ -15,10 +15,88 @@ mx_markdown_inline_html <- function(x) {
x
}
+mx_table_row_cells <- function(x) {
+ x <- trimws(x)
+ if (startsWith(x, "|")) {
+ x <- substring(x, 2L)
+ }
+ if (endsWith(x, "|")) {
+ x <- substring(x, 1L, nchar(x) - 1L)
+ }
+ trimws(strsplit(x, "|", fixed = TRUE)[[1]])
+}
+
+mx_is_table_separator <- function(x) {
+ cells <- mx_table_row_cells(x)
+ length(cells) > 0L && all(grepl("^:?-{3,}:?$", cells))
+}
+
+mx_is_table_row <- function(x) {
+ grepl("\\|", x) && nzchar(trimws(x))
+}
+
+mx_table_align <- function(sep) {
+ cells <- mx_table_row_cells(sep)
+ vapply(cells, function(cell) {
+ left <- startsWith(cell, ":")
+ right <- endsWith(cell, ":")
+ if (left && right) {
+ "center"
+ } else if (right) {
+ "right"
+ } else if (left) {
+ "left"
+ } else {
+ NA_character_
+ }
+ }, character(1))
+}
+
+mx_table_html_cells <- function(cells, tag = "td", align = NULL) {
+ n <- length(cells)
+ if (is.null(align)) {
+ align <- rep(NA_character_, n)
+ }
+ if (length(align) < n) {
+ align <- c(align, rep(NA_character_, n - length(align)))
+ }
+ paste(vapply(seq_len(n), function(i) {
+ attr <- if (!is.na(align[[i]]) && nzchar(align[[i]])) {
+ sprintf(" align=\"%s\"", align[[i]])
+ } else {
+ ""
+ }
+ sprintf("<%s%s>%s%s>", tag, attr,
+ mx_markdown_inline_html(cells[[i]]), tag)
+ }, character(1)), collapse = "")
+}
+
+mx_table_to_html <- function(rows) {
+ header <- mx_table_row_cells(rows[[1]])
+ align <- mx_table_align(rows[[2]])
+ body <- rows[-c(1L, 2L)]
+ html <- c("", "",
+ mx_table_html_cells(header, "th", align), " ")
+ if (length(body)) {
+ body_html <- vapply(body, function(row) {
+ cells <- mx_table_row_cells(row)
+ # Pad or trim body rows to header width, matching common GFM behavior.
+ if (length(cells) < length(header)) {
+ cells <- c(cells, rep("", length(header) - length(cells)))
+ } else if (length(cells) > length(header)) {
+ cells <- cells[seq_along(header)]
+ }
+ paste0("", mx_table_html_cells(cells, "td", align), " ")
+ }, character(1))
+ html <- c(html, "", body_html, "")
+ }
+ paste0(c(html, " "), collapse = "")
+}
+
#' Convert a conservative markdown subset to Matrix custom HTML
#'
#' Supports headings, bullets, numbered lists, fenced code blocks, inline
-#' code, bold, and simple underscore emphasis.
+#' code, bold, simple underscore emphasis, and GitHub-style pipe tables.
#'
#' @param text Character markdown body.
#' @return Character HTML suitable for m.room.message formatted_body.
@@ -43,7 +121,9 @@ mx_markdown_to_html <- function(text) {
}
z
}
- for (ln in lines) {
+ i <- 1L
+ while (i <= length(lines)) {
+ ln <- lines[[i]]
if (grepl("^```", ln)) {
if (in_pre) {
out <- c(out, "")
@@ -52,76 +132,107 @@ mx_markdown_to_html <- function(text) {
out <- c(out, close_lists(), "")
in_pre <- TRUE
}
+ i <- i + 1L
next
}
if (in_pre) {
out <- c(out, mx_html_escape(ln))
+ i <- i + 1L
+ next
+ }
+ if (i < length(lines) && mx_is_table_row(ln) &&
+ mx_is_table_separator(lines[[i + 1L]])) {
+ j <- i + 2L
+ while (j <= length(lines) && mx_is_table_row(lines[[j]])) {
+ j <- j + 1L
+ }
+ out <- c(out, close_lists(), mx_table_to_html(lines[i:(j - 1L)]))
+ i <- j
next
}
if (!nzchar(trimws(ln))) {
out <- c(out, close_lists())
+ i <- i + 1L
next
}
- if (grepl("^#{1,6}\\s+", ln)) {
+ h <- regexec("^(#{1,6})\\s+(.+)$", ln, perl = TRUE)
+ hm <- regmatches(ln, h)[[1]]
+ if (length(hm)) {
out <- c(out, close_lists())
- lvl <- nchar(sub("^(#{1,6}).*$", "\\1", ln))
- body <- sub("^#{1,6}\\s+", "", ln)
+ lvl <- nchar(hm[[2]])
+ body <- hm[[3]]
out <- c(out, sprintf("%s", lvl,
mx_markdown_inline_html(body), lvl))
+ i <- i + 1L
next
}
- if (grepl("^\\s*[-*]\\s+", ln)) {
+ b <- regexec("^\\s*[-*]\\s+(.+)$", ln, perl = TRUE)
+ bm <- regmatches(ln, b)[[1]]
+ if (length(bm)) {
+ if (in_ol) {
+ out <- c(out, "")
+ in_ol <- FALSE
+ }
if (!in_ul) {
- out <- c(out, close_lists(), "")
+ out <- c(out, "")
in_ul <- TRUE
}
- body <- sub("^\\s*[-*]\\s+", "", ln)
+ body <- bm[[2]]
out <- c(out, sprintf("- %s
", mx_markdown_inline_html(body)))
+ i <- i + 1L
next
}
- if (grepl("^\\s*[0-9]+[.)]\\s+", ln)) {
+ # Both "1." and "1)" are ordered-list markers; matching only the
+ # dot silently renders "1)" input as a paragraph.
+ o <- regexec("^\\s*[0-9]+[.)]\\s+(.+)$", ln, perl = TRUE)
+ om <- regmatches(ln, o)[[1]]
+ if (length(om)) {
+ if (in_ul) {
+ out <- c(out, " ")
+ in_ul <- FALSE
+ }
if (!in_ol) {
- out <- c(out, close_lists(), "")
+ out <- c(out, "")
in_ol <- TRUE
}
- body <- sub("^\\s*[0-9]+[.)]\\s+", "", ln)
+ body <- om[[2]]
out <- c(out, sprintf("- %s
", mx_markdown_inline_html(body)))
+ i <- i + 1L
next
}
out <- c(out, close_lists(), sprintf("%s ",
mx_markdown_inline_html(ln)))
+ i <- i + 1L
}
- out <- c(out, close_lists())
if (in_pre) {
out <- c(out, " ")
}
+ out <- c(out, close_lists())
paste(out, collapse = "")
}
-#' Turn textual @mentions into matrix.to pills
-#'
-#' Replaces each occurrence of \code{@localpart} (or the full
-#' \code{@localpart:server} id) in already-rendered HTML with a
-#' \code{matrix.to} anchor, which Matrix clients render as a mention pill.
-#' A user id with no textual occurrence is left to \code{m.mentions} alone,
-#' which still notifies.
+#' Turn textual @mentions into Matrix pills in formatted HTML
#'
#' @param html Character HTML (e.g. from \code{\link{mx_markdown_to_html}}).
-#' @param user_ids Character vector of full Matrix user ids.
-#' @return Character HTML with mention pills.
+#' @param user_ids Character Matrix user ids, such as
+#' \code{"@jorge:example.org"}.
+#' @return HTML with textual \code{@localpart} occurrences replaced by
+#' matrix.to links. Unmatched user ids leave the HTML unchanged; they
+#' can still be placed in \code{m.mentions} by \code{mx_send_text()}.
#' @examples
#' mx_pill_mentions("ping @jorge ", "@jorge:example.org")
#' @export
mx_pill_mentions <- function(html, user_ids) {
- for (uid in user_ids) {
+ if (!length(user_ids)) {
+ return(html)
+ }
+ for (uid in unique(user_ids)) {
local <- sub("^@([^:]+):.*$", "\\1", uid)
- esc <- gsub("([][{}().*+?^$\\\\|])", "\\\\\\1", local)
- pill <- sprintf("%s", uid, local)
- # One pass matching @localpart with an optional :server tail -- a
- # second pass would rescan the @id inside the inserted href. Case-
- # insensitive since people type @Jorge for @jorge.
+ esc <- gsub("([][{}()+*^$.|\\\\?])", "\\\\\\1", local, perl = TRUE)
+ pill <- sprintf("%s",
+ mx_html_escape(uid), mx_html_escape(local))
html <- gsub(paste0("@", esc, "(:[A-Za-z0-9._-]+)?\\b"), pill, html,
- ignore.case = TRUE)
+ perl = TRUE, ignore.case = TRUE)
}
html
}
diff --git a/R/messages.R b/R/messages.R
index 9ebf3f4..5da1f9a 100644
--- a/R/messages.R
+++ b/R/messages.R
@@ -218,19 +218,17 @@ mx_accept_invites <- function(client, invites) {
#' target_event_id = "$msg")
#' @export
mx_extract_reaction_verdict <- function(sync_resp, room_id, self_id,
- target_event_id,
- approve_keys = NULL,
+ target_event_id, approve_keys = NULL,
deny_keys = NULL) {
# Emoji defaults are built here, not in the signature, so they don't
# land as raw astral-plane glyphs in the .Rd \usage block -- LaTeX
# can't typeset them and the PDF manual fails R CMD check --as-cran.
if (is.null(approve_keys)) {
- approve_keys <- c(intToUtf8(0x1F44D), intToUtf8(0x2705),
- "y", "yes", "ok")
+ approve_keys <- c(intToUtf8(0x1F44D), intToUtf8(0x2705), "y", "yes",
+ "ok")
}
if (is.null(deny_keys)) {
- deny_keys <- c(intToUtf8(0x1F44E), intToUtf8(0x274C),
- "n", "no", "nope")
+ deny_keys <- c(intToUtf8(0x1F44E), intToUtf8(0x274C), "n", "no", "nope")
}
room <- sync_resp$rooms$join[[room_id]]
if (is.null(room)) {
diff --git a/R/tables.R b/R/tables.R
new file mode 100644
index 0000000..3a1b3a8
--- /dev/null
+++ b/R/tables.R
@@ -0,0 +1,97 @@
+# Matrix HTML table helpers.
+
+mx_table_coerce <- function(x) {
+ if (is.data.frame(x)) {
+ return(x)
+ }
+ if (is.matrix(x)) {
+ return(as.data.frame(x, stringsAsFactors = FALSE))
+ }
+ if (is.list(x)) {
+ return(as.data.frame(x, stringsAsFactors = FALSE, check.names = FALSE))
+ }
+ stop("x must be a data.frame, matrix, or list", call. = FALSE)
+}
+
+mx_table_plain <- function(x, header = TRUE) {
+ x <- mx_table_coerce(x)
+ rows <- vapply(seq_len(nrow(x)), function(i) {
+ paste(vapply(x[i,, drop = FALSE], as.character, character(1)),
+ collapse = " | ")
+ }, character(1))
+ # Track the HTML: emitting column names in the fallback while the
+ # formatted_body omits them shows different tables to different clients.
+ if (isTRUE(header)) {
+ rows <- c(paste(names(x), collapse = " | "), rows)
+ }
+ paste(rows, collapse = "\n")
+}
+
+#' Render tabular data as Matrix custom HTML
+#'
+#' Produces the conservative table shape rendered by Matrix clients such as
+#' FluffyChat 2.6.0+: a bare \code{} containing \code{},
+#' \code{| }, and \code{ | } nodes. No CSS, colspan, rowspan, or custom
+#' attributes are emitted.
+#'
+#' @param x A data frame, matrix, or list coercible to a data frame.
+#' @param header Logical. Include a header row using column names.
+#' @return Character HTML suitable for Matrix \code{formatted_body}.
+#' @examples
+#' mx_table_html(data.frame(A = 1:2, B = c("x", "y")))
+#' @export
+mx_table_html <- function(x, header = TRUE) {
+ x <- mx_table_coerce(x)
+ cell <- function(tag, value) {
+ sprintf("<%s>%s%s>", tag, mx_html_escape(as.character(value)), tag)
+ }
+ rows <- character()
+ if (isTRUE(header)) {
+ rows <- c(rows, paste0(" | ",
+ paste(vapply(names(x), cell, character(1), tag = "th"),
+ collapse = ""),
+ " "))
+ }
+ if (nrow(x)) {
+ rows <- c(rows, vapply(seq_len(nrow(x)), function(i) {
+ vals <- vapply(x[i,, drop = FALSE], as.character, character(1))
+ paste0("", paste(vapply(vals, cell, character(1), tag = "td"),
+ collapse = ""), " ")
+ }, character(1)))
+ }
+ paste0("", paste(rows, collapse = ""), " ")
+}
+
+#' Send tabular data to a Matrix room
+#'
+#' Sends a plain-text fallback body plus Matrix custom HTML table in
+#' \code{formatted_body}. This bypasses Markdown entirely.
+#'
+#' @param client Matrix client config.
+#' @param x A data frame, matrix, or list coercible to a data frame.
+#' @param room Character room id/name or NULL for the default room.
+#' @param header Logical. Include a header row using column names.
+#' @param title Optional text prepended to the plain fallback body.
+#' @param room_cache Optional room name-to-id cache.
+#' @param dry_run Logical. Print instead of sending.
+#' @return Event id, or NULL on dry-run.
+#' @examples
+#' client <- list(room_id = "!default:example.org")
+#' mx_send_table(client, data.frame(A = 1, B = 2), dry_run = TRUE)
+#' @export
+mx_send_table <- function(client, x, room = NULL, header = TRUE,
+ title = NULL, room_cache = NULL, dry_run = FALSE) {
+ html <- mx_table_html(x, header = header)
+ body <- mx_table_plain(x, header = header)
+ if (!is.null(title) && nzchar(title)) {
+ body <- paste(as.character(title), body, sep = "\n")
+ }
+ if (isTRUE(dry_run)) {
+ message("=== mx_send_table (dry-run) [", room %||% "default",
+ "] ===\n", body, "\n--- formatted_body ---\n", html)
+ return(invisible(NULL))
+ }
+ rid <- mx_resolve_room(client, room, room_cache = room_cache)
+ mx.api::mx_send(mx_client_session(client), rid, body, msgtype = "m.text",
+ extra = list(format = "org.matrix.custom.html", formatted_body = html))
+}
diff --git a/inst/skills/mx.client/matrix-messaging/SKILL.md b/inst/skills/mx.client/matrix-messaging/SKILL.md
new file mode 100644
index 0000000..92d5970
--- /dev/null
+++ b/inst/skills/mx.client/matrix-messaging/SKILL.md
@@ -0,0 +1,148 @@
+---
+name: matrix-messaging
+description: >
+ Send and receive Matrix messages from R using the mx.* package family
+ (mx.api / mx.crypto / mx.client). Use when a user wants an R program or
+ agent to post to a Matrix room, read new messages, accept invites, send
+ files or tables, or talk to a Matrix homeserver. Posts go through
+ mx.client (config, room resolution, HTML formatting) over mx.api, never
+ hand-rolled curl. End-to-end encryption is orchestrated over the optional
+ mx.crypto package.
+allowed-tools: Bash(r:*), Bash(Rscript:*), Read
+---
+
+# matrix-messaging
+
+Drive a Matrix homeserver from R with the mx.* family. `mx.client` owns
+config persistence, room resolution, sync cursors, event extraction, and
+HTML formatting; it sits on `mx.api` (the raw Client-Server endpoints) and,
+for encryption, `mx.crypto` (Olm/Megolm primitives).
+
+Do not hand-roll curl against the homeserver. `mx.client` already owns
+config loading, session construction, room resolution, and HTML formatting.
+
+## The package family
+
+| Package | Role | Notes |
+|---|---|---|
+| `mx.api` | HTTP transport, one function per CS-API endpoint, holds nothing | on CRAN; `mx.client` Imports it |
+| `mx.crypto` | Olm/Megolm primitives (vodozemac via Rust) | on CRAN; `mx.client` Suggests it (E2EE only) |
+| `mx.client` | stateful client: config, rooms, sync, formatting, E2EE orchestration | on CRAN |
+
+## First-time setup
+
+`mx_client_configure()` logs in, joins the room, and persists credentials
+(mode 0600, under `tools::R_user_dir()`). `app` namespaces the stored
+config so several bots can coexist. Do this once per identity.
+
+```r
+mx.client::mx_client_configure(
+ server = "https://matrix.example.org",
+ user = "bot",
+ password = "secret",
+ room = "#general:example.org", # default room for later sends
+ app = "myapp"
+)
+```
+
+## Send a message
+
+Every later session loads the stored config and sends. `markdown = TRUE`
+adds a conservative HTML `formatted_body` (headings, bold, code, lists,
+links, and GitHub pipe tables become Matrix `` HTML). `room` takes a
+name or id; omit it for the configured default. `mx_send_text()` returns the
+event id.
+
+```r
+client <- mx.client::mx_client_load(app = "myapp")
+
+mx.client::mx_send_text(client, "hello from R") # default room
+id <- mx.client::mx_send_text(client, "**shipped** `v0.1.1`",
+ room = "general", markdown = TRUE) # named room
+```
+
+Tokens stay in the config file. Never print them.
+
+To load a config by explicit path instead of `app`, pass `path =`. Use
+`dry_run = TRUE` to print the resolved send without hitting the network.
+
+### Mentions
+
+`mentions = "@user:example.org"` adds the user to the event's `m.mentions`
+(so they get pinged) and rewrites any textual `@localpart` in the body into a
+matrix.to pill. A pill implies an HTML body even without `markdown = TRUE`.
+
+## Resolve rooms
+
+Send by human name instead of `!opaque:id`. `mx_resolve_room()` turns a name
+into an id (or passes a literal `!id`/`#alias` through); `mx_room_lookup_by_name()`
+lists the joined rooms, which is how you find a DM (the room whose members
+are just the bot and one person).
+
+```r
+room_id <- mx.client::mx_resolve_room(client, "general")
+mx.client::mx_room_lookup_by_name(client) # name -> id table
+```
+
+## Send files and media
+
+`mx_send_media()` (needs `mx.api (>= 0.3.0)`) uploads and posts in one call.
+The msgtype comes from the file's MIME type: a `.png` posts as `m.image`, a
+`.mp4` as `m.video`. Server upload cap is about 20 MB
+(`mx.api::mx_media_config()` to check). Pass `content_type =` for files whose
+extension lies, and `body =` for a caption/filename.
+
+```r
+mx.client::mx_send_media(client, "plot.png", room = "general")
+```
+
+## Send a table
+
+`mx_send_table()` renders a data frame (or matrix) straight to Matrix HTML
+via `mx_table_html()`.
+
+```r
+mx.client::mx_send_table(client, head(mtcars), room = "general")
+```
+
+## Receive: read new messages and advance the cursor
+
+`mx_sync_update()` long-polls and advances the stored sync cursor so you only
+see new events. The `mx_extract_*` helpers parse the sync response.
+
+```r
+res <- mx.client::mx_sync_update(client, timeout = 30000L)
+msgs <- mx.client::mx_extract_text_events(res$sync, client$user_id)
+invs <- mx.client::mx_extract_invites(res$sync)
+mx.client::mx_accept_invites(client, invs) # join rooms you were invited to
+```
+
+## Survive token rotation
+
+`mx_with_relogin()` wraps any client operation: on `M_UNKNOWN_TOKEN` it
+re-logs in with the stored password (keeping the device id, so an E2EE
+identity survives), saves the refreshed token, and retries once.
+
+```r
+mx.client::mx_with_relogin(client, function(cl) {
+ mx.client::mx_send_text(cl, "still here after a token rotation")
+})
+```
+
+## End-to-end encryption
+
+Olm/Megolm send/receive orchestrated over `mx.crypto`, aimed at bots and
+controlled deployments. `mx.crypto` is a Suggests and is only touched from
+the E2EE entry points, so plaintext clients install and run without a Rust
+toolchain. Security model is trust-on-first-use (no cross-signing trust store
+yet, no key-request flow). Check a room's state with `mx_room_encrypted()`
+before choosing the encrypted or plaintext path.
+
+The full flow (store, account, key publish, `mx_send_encrypted()`,
+`mx_crypto_process_sync()`) and its current limitations are in
+`vignette("e2ee", package = "mx.client")`.
+
+## Report
+
+End with: which identity (app/config) posted, which room, the message, and
+the returned `event_id`.
diff --git a/inst/tinytest/test_markdown.R b/inst/tinytest/test_markdown.R
index 3103e00..1c1f72b 100644
--- a/inst/tinytest/test_markdown.R
+++ b/inst/tinytest/test_markdown.R
@@ -30,3 +30,43 @@ expect_true(grepl("@jXr does not", p2, fixed = TRUE))
# no textual occurrence: html unchanged (m.mentions still notifies)
expect_equal(mx.client::mx_pill_mentions("no names here", "@tiny:cornball.ai"),
"no names here")
+
+# GitHub-style pipe tables become Matrix custom HTML tables.
+tab <- mx.client::mx_markdown_to_html(paste(c(
+ "| Area | OKF | pensar |",
+ "|---|---|---|",
+ "| Links | Markdown links | `[[wikilinks]]` |",
+ "| Strictness | only **type** | title, type, source |"
+), collapse = "\n"))
+expect_true(grepl("", tab, fixed = TRUE))
+expect_true(grepl("| Area | ", tab, fixed = TRUE))
+expect_true(grepl("[[wikilinks]] | ", tab, fixed = TRUE))
+expect_true(grepl("type", tab, fixed = TRUE))
+
+# Known-good Matrix room shape from the Cornelius CRAN downloads report.
+# This pins the exact format that rendered in-client: blank line before the
+# pipe table, backticked package names, right-aligned numeric/date columns,
+# and ordinary prose after the table.
+known_good <- mx.client::mx_markdown_to_html(paste(c(
+ "**CRAN downloads weekly** (16 packages, as of 2026-07-20)",
+ "",
+ "| package | last week | last month | total |",
+ "|---|---:|---:|---:|",
+ "| `tinyoauth` | 63 | 543 | 601 |",
+ "| `saber` | 51 | 443 | 2,395 |",
+ "",
+ "* Partial period: package has not been on CRAN for the full week or month."
+), collapse = "\n"))
+expect_true(grepl("", known_good, fixed = TRUE))
+expect_true(grepl("CRAN downloads weekly", known_good,
+ fixed = TRUE))
+expect_true(grepl(" tinyoauth | ", known_good, fixed = TRUE))
+expect_true(grepl("2,395 | ", known_good, fixed = TRUE))
+expect_true(grepl("Partial period", known_good, fixed = TRUE))
+
+# Ordered lists accept both "1." and "1)" markers. There was no coverage
+# here, which is how a rewrite that dropped ")" support went unnoticed.
+expect_equal(mx.client::mx_markdown_to_html("1. first\n2. second"),
+ "- first
- second
")
+expect_equal(mx.client::mx_markdown_to_html("1) first\n2) second"),
+ "- first
- second
")
diff --git a/inst/tinytest/test_tables.R b/inst/tinytest/test_tables.R
new file mode 100644
index 0000000..609b35c
--- /dev/null
+++ b/inst/tinytest/test_tables.R
@@ -0,0 +1,20 @@
+library(tinytest)
+
+html <- mx.client::mx_table_html(data.frame(A = c("1", ""), B = c("&", "two")))
+expect_equal(html, paste0(
+ "",
+ "| A | B | ",
+ "| 1 | & | ",
+ "| <x> | two | ",
+ " "))
+
+expect_null(mx.client::mx_send_table(list(room_id = "!default:example.org"),
+ data.frame(A = 1, B = 2),
+ dry_run = TRUE))
+
+# header = FALSE has to drop the header from both renderings, or the
+# plain-text fallback shows a different table than formatted_body does.
+d <- data.frame(A = c("1", "2"), B = c("x", "y"))
+expect_equal(mx.client:::mx_table_plain(d, header = TRUE), "A | B\n1 | x\n2 | y")
+expect_equal(mx.client:::mx_table_plain(d, header = FALSE), "1 | x\n2 | y")
+expect_false(grepl("", mx.client::mx_table_html(d, header = FALSE)))
diff --git a/man/mx_markdown_to_html.Rd b/man/mx_markdown_to_html.Rd
index aa7dc5b..46a8120 100644
--- a/man/mx_markdown_to_html.Rd
+++ b/man/mx_markdown_to_html.Rd
@@ -13,7 +13,7 @@ Character HTML suitable for m.room.message formatted_body.
}
\description{
Supports headings, bullets, numbered lists, fenced code blocks, inline
-code, bold, and simple underscore emphasis.
+code, bold, simple underscore emphasis, and GitHub-style pipe tables.
}
\examples{
mx_markdown_to_html("# Status\n- built\n- checked\n\nShip `0.1.0` **soon**")
diff --git a/man/mx_pill_mentions.Rd b/man/mx_pill_mentions.Rd
index f8f716b..499a964 100644
--- a/man/mx_pill_mentions.Rd
+++ b/man/mx_pill_mentions.Rd
@@ -1,24 +1,23 @@
% tinyrox says don't edit this manually, but it can't stop you!
\name{mx_pill_mentions}
\alias{mx_pill_mentions}
-\title{Turn textual @mentions into matrix.to pills}
+\title{Turn textual @mentions into Matrix pills in formatted HTML}
\usage{
mx_pill_mentions(html, user_ids)
}
\arguments{
\item{html}{Character HTML (e.g. from \code{\link{mx_markdown_to_html}}).}
-\item{user_ids}{Character vector of full Matrix user ids.}
+\item{user_ids}{Character Matrix user ids, such as
+\code{"@jorge:example.org"}.}
}
\value{
-Character HTML with mention pills.
+HTML with textual \code{@localpart} occurrences replaced by
+ matrix.to links. Unmatched user ids leave the HTML unchanged; they
+ can still be placed in \code{m.mentions} by \code{mx_send_text()}.
}
\description{
-Replaces each occurrence of \code{@localpart} (or the full
-\code{@localpart:server} id) in already-rendered HTML with a
-\code{matrix.to} anchor, which Matrix clients render as a mention pill.
-A user id with no textual occurrence is left to \code{m.mentions} alone,
-which still notifies.
+Turn textual @mentions into Matrix pills in formatted HTML
}
\examples{
mx_pill_mentions(" ping @jorge ", "@jorge:example.org")
diff --git a/man/mx_send_table.Rd b/man/mx_send_table.Rd
new file mode 100644
index 0000000..f97f146
--- /dev/null
+++ b/man/mx_send_table.Rd
@@ -0,0 +1,34 @@
+% tinyrox says don't edit this manually, but it can't stop you!
+\name{mx_send_table}
+\alias{mx_send_table}
+\title{Send tabular data to a Matrix room}
+\usage{
+mx_send_table(client, x, room = NULL, header = TRUE, title = NULL,
+ room_cache = NULL, dry_run = FALSE)
+}
+\arguments{
+\item{client}{Matrix client config.}
+
+\item{x}{A data frame, matrix, or list coercible to a data frame.}
+
+\item{room}{Character room id/name or NULL for the default room.}
+
+\item{header}{Logical. Include a header row using column names.}
+
+\item{title}{Optional text prepended to the plain fallback body.}
+
+\item{room_cache}{Optional room name-to-id cache.}
+
+\item{dry_run}{Logical. Print instead of sending.}
+}
+\value{
+Event id, or NULL on dry-run.
+}
+\description{
+Sends a plain-text fallback body plus Matrix custom HTML table in
+\code{formatted_body}. This bypasses Markdown entirely.
+}
+\examples{
+client <- list(room_id = "!default:example.org")
+mx_send_table(client, data.frame(A = 1, B = 2), dry_run = TRUE)
+}
diff --git a/man/mx_table_html.Rd b/man/mx_table_html.Rd
new file mode 100644
index 0000000..fa2dc40
--- /dev/null
+++ b/man/mx_table_html.Rd
@@ -0,0 +1,24 @@
+% tinyrox says don't edit this manually, but it can't stop you!
+\name{mx_table_html}
+\alias{mx_table_html}
+\title{Render tabular data as Matrix custom HTML}
+\usage{
+mx_table_html(x, header = TRUE)
+}
+\arguments{
+\item{x}{A data frame, matrix, or list coercible to a data frame.}
+
+\item{header}{Logical. Include a header row using column names.}
+}
+\value{
+Character HTML suitable for Matrix \code{formatted_body}.
+}
+\description{
+Produces the conservative table shape rendered by Matrix clients such as
+FluffyChat 2.6.0+: a bare \code{} containing \code{},
+\code{| }, and \code{ | } nodes. No CSS, colspan, rowspan, or custom
+attributes are emitted.
+}
+\examples{
+mx_table_html(data.frame(A = 1:2, B = c("x", "y")))
+}
| | |