*! _tabtools_markdown_write Version 1.0.0 2026/10/20
*! Write the current dataset as a GitHub-Flavored Markdown table
*! Author: Timothy P Copeland, Karolinska Institutet
*! Program class: rclass
program define _tabtools_markdown_write, rclass
version 17.0
local _orig_varabbrev = c(varabbrev)
set varabbrev off
capture noisily {
syntax using/ , [APPEND LABELVar(name) HEADERStart(integer 2) ///
DATAStart(integer 3) DATAEnd(integer -1) TITLE(string) FOOTnote(string) ///
NOVARNAMES STRICTHeaders KEEPBlank BOLDRows(numlist >0 integer)]
capture _tabtools_helpers_ready
if !_rc capture mata: assert(findexternal("_tt_sep_parse()") != NULL)
if _rc {
capture findfile _tabtools_common.ado
if _rc == 0 {
run "`r(fn)'"
}
else {
noisily display as error "_tabtools_common.ado not found; reinstall tabtools"
exit 111
}
}
quietly ds
local _allvars `r(varlist)'
if "`_allvars'" == "" {
noisily display as error "No variables available for Markdown export"
exit 111
}
quietly count
if r(N) == 0 {
noisily display as error "No observations available for Markdown export"
exit 2000
}
local _path_lower = lower(`"`using'"')
if !(strmatch(`"`_path_lower'"', "*.md") | ///
strmatch(`"`_path_lower'"', "*.markdown") | ///
strmatch(`"`_path_lower'"', "*.qmd") | ///
strmatch(`"`_path_lower'"', "*.rmd")) {
noisily display as error "markdown() must specify a .md, .markdown, .qmd, or .rmd file"
exit 198
}
capture _tabtools_validate_path `"`using'"' "markdown()"
if _rc exit _rc
local _visible_opts ""
if `"`labelvar'"' != "" local _visible_opts "labelvar(`labelvar')"
_tabtools_visible_vars, `_visible_opts'
local _vars `"`_tabtools_visible_vars'"'
local _k : word count `_vars'
if `_k' == 0 {
noisily display as error "No output columns available for Markdown export"
exit 111
}
if `headerstart' < 1 local headerstart = 1
if `datastart' < 1 local datastart = 1
* A Markdown heading is emitted ONLY for an explicit title(). The writer
* used to scan row 1 for the first non-empty cell when no title was
* given, which promoted table DATA into a fabricated heading: for
* table1_tc, row 1 holds the group labels, so a by(foreign) table with
* no title() was headed "### Domestic". Header-shaped data must never
* become a heading.
*
* Every piece of text -- title, headers, body cells, footnote -- is
* read, escaped and written inside Mata. The writer used to move each
* cell into a local macro and expand it again in an if-condition and
* in -file write-, so data was parsed as macro syntax: a cell quoted
* as a local-macro reference vanished, an unbalanced backtick stopped
* with r(132) after a partial file, and a dollar-prefixed word was
* replaced by a global. st_local(), st_sdata() and fput() never
* expand anything. The table is built in full before the file is
* opened, so an error cannot leave a partial file behind.
*
* The default replaces an existing file, as README and every help
* file describe; mdappend (append) adds to it. The new text is staged
* in a tempfile and copied over the target only once complete.
local _append = ("`append'" != "")
local _append_existing = 0
if `_append' {
capture confirm file `"`using'"'
if !_rc local _append_existing = 1
}
* A body row blank in every exported column is dropped by default:
* the table producers (regtab, desctab, ...) use such rows as
* structural spacers. keepblank is for a caller whose rows are all
* data (puttab), so an all-missing observation stays a body row and
* Markdown carries as many rows as the workbook and the CSV. C3
* (codex audit 2026-09-26).
tempfile _stage
mata: _tt_md_write(st_local("_stage"), st_local("_vars"), ///
`headerstart', `datastart', `dataend', ///
"`novarnames'" != "", "`strictheaders'" != "", `_append_existing', ///
"`keepblank'" != "", "`boldrows'")
local _n_body = `_tt_md_nbody'
if `_append' {
mata: _tt_md_append(st_local("_stage"), st_local("using"))
}
else {
quietly copy `"`_stage'"' `"`using'"', replace
}
* tabtools set markdown: once the session file has been written by
* any command, later session writes append to it.
_tabtools_set_sinks mddone, path(`"`using'"')
return scalar n_rows = `_n_body'
return scalar n_cols = `_k'
return local markdown `"`using'"'
}
local rc = _rc
set varabbrev `_orig_varabbrev'
if `rc' exit `rc'
end
version 17.0
capture mata: mata drop _tt_md_escape()
capture mata: mata drop _tt_md_cell()
capture mata: mata drop _tt_md_body_cell()
capture mata: mata drop _tt_md_write()
capture mata: mata drop _tt_md_append()
capture mata: mata drop _tt_md_fn_paras()
* matastrict is a session setting: save the caller's value here and
* restore it after the block, so loading this file never leaks it.
local _tt_ms0 = c(matastrict)
mata:
mata set matastrict on
// Split footnote text into paragraphs on the literal token " \ " (space,
// backslash, space). Text without the token is returned unchanged; otherwise
// each piece is trimmed and empty pieces are dropped. Same rule in every
// tabtools writer.
string colvector _tt_md_fn_paras(string scalar s)
{
string colvector out
string scalar rest, piece
real scalar pos
if (!strpos(s, " " + char(92) + " ")) return(s)
out = J(0, 1, "")
rest = s
while ((pos = strpos(rest, " " + char(92) + " ")) > 0) {
piece = strtrim(substr(rest, 1, pos - 1))
if (piece != "") out = out \ piece
rest = substr(rest, pos + 3, .)
}
piece = strtrim(rest)
if (piece != "") out = out \ piece
if (rows(out) == 0) out = ""
return(out)
}
string scalar _tt_md_escape(string scalar x)
{
x = strtrim(x)
x = subinstr(x, "\", "\\")
x = subinstr(x, "|", "\|")
x = subinstr(x, "*", "\*")
x = subinstr(x, "_", "\_")
// Literal text, not markup: raw HTML and comments (< >), entities (&),
// link, image and reference syntax ([ ]), GFM strikethrough (~) and code
// spans (backtick) were rendered as markup, so "Drug ©
// [arm](url)" displayed as bold text, a copyright sign and a hyperlink,
// and "~~x~~" as struck-through text. A backslash before ASCII
// punctuation is a literal in CommonMark/GFM. This runs before
is
// inserted below, and _tt_md_body_cell() prepends indentation
// after escaping, so helper-generated markup stays live. The backtick is
// spelled char(96): lines of an .ado file are macro-expanded, Mata
// blocks included.
x = subinstr(x, char(96), "\" + char(96))
x = subinstr(x, "<", "\<")
x = subinstr(x, ">", "\>")
x = subinstr(x, "&", "\&")
x = subinstr(x, "[", "\[")
x = subinstr(x, "]", "\]")
x = subinstr(x, "~", "\~")
x = subinstr(x, char(13) + char(10), "
")
x = subinstr(x, char(13), "
")
x = subinstr(x, char(10), "
")
return(x)
}
string scalar _tt_md_cell(string scalar v, real scalar i)
{
real scalar j
real scalar x
j = st_varindex(v)
if (st_isstrvar(j)) {
return(strtrim(st_sdata(i, j)))
}
x = st_data(i, j)
if (x >= .) return("")
return(strtrim(ustrregexra(strofreal(x, st_varformat(j)), "-(?=[0.,]+\z)", "")))
}
// Escaped body cell. With indent != 0, each leading space of a string cell
// becomes one so row-label indentation survives GFM cell trimming.
// The entity is prepended after escaping, so it can never combine with the
// escaped content (e.g. a leading "|" is written as \|). Trailing
// blanks are trimmed; interior spaces are unchanged; an all-blank cell is
// empty. Numeric cells keep the trimmed display-format rendering.
string scalar _tt_md_body_cell(string scalar v, real scalar i, real scalar indent)
{
real scalar j, n
string scalar raw, core
j = st_varindex(v)
if (!indent | !st_isstrvar(j)) return(_tt_md_escape(_tt_md_cell(v, i)))
raw = st_sdata(i, j)
// Count before escaping: Mata passes arguments by reference and
// _tt_md_escape() trims its argument in place.
n = strlen(raw) - strlen(strltrim(raw))
core = _tt_md_escape(raw)
if (core == "") return("")
return(n * " " + core)
}
// Build the whole Markdown table and write it to the fresh file stage.
// Text is read from the data and from the title/footnote locals with
// st_sdata()/st_local() and written with fput(), so nothing is ever macro-
// expanded. Posts the number of body rows written to local _tt_md_nbody.
void _tt_md_write(string scalar stage, string scalar varlist,
real scalar hs, real scalar ds, real scalar de,
real scalar novarnames, real scalar strict, real scalar lead_blank,
real scalar keepblank, string scalar boldrows)
{
string rowvector vars
string colvector out, paras
string scalar h, line, cell, title, foot
real scalar j, i, k, nobs, body_end, has_text, nbody, fh, isbold
real rowvector bold
vars = tokens(varlist)
k = cols(vars)
nobs = st_nobs()
out = J(0, 1, "")
if (lead_blank) out = out \ ""
title = st_local("title")
if (title != "") out = out \ ("### " + _tt_md_escape(title)) \ ""
line = "|"
for (j = 1; j <= k; j++) {
h = ""
if (!novarnames & hs >= 1 & hs <= nobs) h = _tt_md_cell(vars[j], hs)
if (h == "" & !strict & !novarnames) {
h = st_varlabel(vars[j])
if (h == "") h = vars[j]
if (h == "") h = "Column " + strofreal(j)
}
line = line + " " + _tt_md_escape(h) + " |"
}
out = out \ line
line = "|"
for (j = 1; j <= k; j++) line = line + " --- |"
out = out \ line
// boldrows(): observations written in bold (puttab panel rows).
bold = (boldrows != "" ? strtoreal(tokens(boldrows)) : J(1, 0, .))
nbody = 0
body_end = (de < 0 ? nobs : min((de, nobs)))
for (i = ds; i <= body_end; i++) {
line = "|"
has_text = 0
for (j = 1; j <= k; j++) {
// Column 1 is the row-label (stub) column: its leading spaces are
// the hierarchy and are written as entities, because GFM
// trims cell whitespace. Value columns are trimmed.
cell = _tt_md_body_cell(vars[j], i, j == 1)
isbold = (cols(bold) > 0 ? any(bold :== i) : 0)
if (isbold & cell != "") cell = "**" + cell + "**"
if (cell != "") has_text = 1
line = line + " " + cell + " |"
}
if (has_text | keepblank) {
out = out \ line
nbody++
}
}
// One italic paragraph per footnote paragraph (" \ " separates them).
foot = st_local("footnote")
if (foot != "") {
paras = _tt_md_fn_paras(foot)
for (i = 1; i <= rows(paras); i++) {
out = out \ "" \ ("*" + _tt_md_escape(paras[i]) + "*")
}
}
fh = fopen(stage, "w")
for (i = 1; i <= rows(out); i++) fput(fh, out[i])
fclose(fh)
st_local("_tt_md_nbody", strofreal(nbody))
}
// Append the staged table to target (created when absent). The stage is
// complete before target is opened.
void _tt_md_append(string scalar stage, string scalar target)
{
string colvector lines
real scalar fh, i
lines = cat(stage)
fh = fopen(target, "a")
for (i = 1; i <= rows(lines); i++) fput(fh, lines[i])
fclose(fh)
}
end
mata: mata set matastrict `_tt_ms0'