diff --git a/CLAUDE.md b/CLAUDE.md
index dbbcb4b..fd0572f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -49,7 +49,7 @@ Module `github.com/mozilla/markfluence` (`go 1.25`). `main.go` is a shim to `cmd
- `cmd/root.go` — the cobra root: `--url`/`--username`/`--debug`/`--no-color` persistent flags, version from `internal/buildinfo`, and registration of the four subcommands. `Execute()` prints cobra-generated errors (bad args/flags) but not `ui.ErrSilent`, which marks a failure a command already reported.
- `cmd/{update,create,fix,info}/` — one package per command (each exports `Cmd`), orchestrating the `internal` packages and `internal/ui` output. `create` is two-phase and transactional (validate all, then create parents-first in topological order); `fix` is read-only on the server.
- `internal/client` — `ConfluenceClient` over `net/http` with basic auth. Built from a `Config` (site URL, cloud ID, username, token) via `New`; it carries **two bases**: `BaseURL()` is where requests go (the gateway when a cloud ID is set) and `SiteURL()` is always the site. Anything a reader sees uses `SiteURL()` — printed page URLs and, critically, the `baseURL` handed to `convert.MdToConfluence`, since rewritten links are published *into* the page. Pages are Confluence **v2**; attachment writes and the user lookup are **v1** (`/wiki/rest/api/...`). Typed `HTTPError`, per-attempt context timeouts, centralized retry/backoff in `send` (429 for any method honoring `Retry-After`, plus 502/503/504 and network errors for idempotent methods only; exponential backoff capped), `SetContentProperty` retry-once on top (recovers a lost create-POST response), `SyncAttachments` (SHA-256-in-comment skip/update), `_links.next` pagination. `config.go` holds `Resolve` and the `.env` reader.
-- `internal/convert` — the converter (the crux). `MdToConfluence(md *frontmatter.MarkdownFile, baseURL, spaceKey, version string) (*ConfluencePage, error)`. It parses with goldmark (GFM) and renders through a custom `storageRenderer` registered at priority 100 (below the default HTML=1000 and table=500 renderers) that emits Confluence storage format. `shield.go` renames raw `ac:`/`ri:` tags to colon-free sentinels around the goldmark step so pasted storage passes through; `callouts.go` is an AST transformer + blockquote renderer for GitHub alerts; `images.go`, `links.go` (sibling-file scans, GitHub/Confluence slugs, doc-link + anchor rewriting), `tables.go` (the `
` tag only, stamped with Confluence's `data-layout="align-start"` so tables auto-size to their content and left-align; rows and cells still fall through to the GFM renderer), and `renderer.go` (code macros, text soft-break→space, images, links) do the rest. The `` and `` token substitutions happen **inside** `MdToConfluence`.
+- `internal/convert` — the converter (the crux). `MdToConfluence(md *frontmatter.MarkdownFile, baseURL, spaceKey, version string) (*ConfluencePage, error)`. It parses with goldmark (GFM) and renders through a custom `storageRenderer` registered at priority 100 (below the default HTML=1000 and table=500 renderers) that emits Confluence storage format. `shield.go` renames raw `ac:`/`ri:` tags to colon-free sentinels around the goldmark step so pasted storage passes through; `callouts.go` is an AST transformer + blockquote renderer for GitHub alerts; `images.go`, `links.go` (sibling-file scans, GitHub/Confluence slugs, doc-link + anchor rewriting), `tables.go` (the `` tag, stamped with Confluence's `data-layout="align-start"` so tables auto-size to their content and left-align, plus cells: an AST transformer consumes a leading `` comment in a cell and `renderTableCell` emits it as `data-highlight-colour` — rows still fall through to the GFM renderer), and `renderer.go` (code macros, text soft-break→space, images, links) do the rest. The `` and `` token substitutions happen **inside** `MdToConfluence`.
- `internal/frontmatter` — flat YAML frontmatter parse/quote/`UpdateField`, and the `MarkdownFile` type (`Parse`/`ParseFile`, exported `Filename`/`Content`/`Frontmatter`/`Body`, and `Title`/`PageID`/`Space`/`Parent` accessors that normalize missing/blank/`"null"`).
- `internal/pagewidth` — the `page_width` `Width` enum (`narrow`/`wide`/`max`, default `max`), `Declared`, the vocab↔content-property maps, `WidthFromProperties`, and `Apply`/`Read` against the client.
- `internal/buildinfo` — `Version` (set via ldflags), `CommitDate` (from the `vcs.time` build setting), and `Stamp`.
diff --git a/README.md b/README.md
index ed451ac..a2be604 100644
--- a/README.md
+++ b/README.md
@@ -232,7 +232,8 @@ or a Confluence page URL (the modern `/wiki/.../pages//...` form or a legacy
column layouts pass through as raw storage tags, with a macro/cell body kept as
readable markdown, so they round-trip back through `create`/`update`. Some
transforms are lossy (e.g. `CAUTION`
- alerts, internal links, and original image paths cannot be recovered), so this is
+ alerts, internal links, original image paths, and table cell background colors
+ cannot be recovered), so this is
a reading aid, not a guaranteed source round-trip.
- `storage` — the page's raw storage-format XHTML, exactly as stored.
@@ -441,11 +442,54 @@ To create a page, you only need to specify the `title` in the frontmatter.
The body is [GitHub-Flavored Markdown](https://github.github.com/gfm/), converted
to Confluence storage format. Supported constructs:
-**Tables** and **fenced code blocks** (rendered as Confluence code macros, with
-language).
+**Fenced code blocks** are rendered as Confluence code macros and support the
+syntax highlighting, but only the languages Confluence supports.
+[GFM fenced code](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks)
+
+**Tables** use GFM syntax and are rendered as Confluence tables.
+[GFM tables](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-tables)
+
+**Table cell background colors** can be specified using an HTML comment at the
+start of the cell. They will be invisible in Markdown preview, but will have
+the specified background color in Confluence.
+
+```markdown
+| Service | Status |
+| ------- | -------------------------- |
+| auth | ok |
+| billing | down |
+```
+
+The color is a swatch name from the Confluence editor's cell background palette,
+or a literal `#rrggbb` hex for anything else. The 21 swatches, one row here per
+column of the editor's picker:
+
+| Light | Medium | Bold |
+| --- | --- | --- |
+| `white` `#ffffff` | `light-grey` `light-gray` `#f4f5f7` | `grey` `gray` `#b3bac5` |
+| `light-blue` `#deebff` | `blue` `#b3d4ff` | `bold-blue` `#4c9aff` |
+| `light-teal` `#e6fcff` | `teal` `#b3f5ff` | `bold-teal` `#79e2f2` |
+| `light-green` `#e3fcef` | `green` `#abf5d1` | `bold-green` `#57d9a3` |
+| `light-yellow` `#fffae6` | `yellow` `#fff0b3` | `bold-yellow` `#ffc400` |
+| `light-red` `#ffebe6` | `red` `#ffbdad` | `bold-red` `#ff8f73` |
+| `light-purple` `#eae6ff` | `purple` `#c0b6f2` | `bold-purple` `#998dd9` |
+
+Details:
+
+- Confluence colors **cells**, not rows or columns; a colored column is
+ implemented with a marker per cell in the column and a colored row is
+ implemented with a marker per cell in the row.
+- The marker works in header cells too.
+- A cell holding nothing but a marker is an empty colored cell.
+- The color marker has to be the first thing in the cell. Anywhere else it's
+ ignored with a warning, since a stray comment would otherwise do nothing
+ visible.
+- An unknown color name is dropped with a warning and the cell publishes
+ uncolored.
**GitHub alerts** — `> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`,
`[!CAUTION]` — become info/tip/note/warning panels.
+[GFM alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts)
Example:
@@ -485,7 +529,7 @@ Examples:
URL; **heading anchors** are rewritten to Confluence's anchor scheme.
**Comment directives:**
-- `` — table-of-contents macro.
+- `` — replaced with Confluence table-of-contents macro.
- `` — replaced with the build stamp,
`markfluence VERSION (SHA, DATE)` (the same string `markfluence --version`
prints).
diff --git a/internal/convert/convert.go b/internal/convert/convert.go
index 0ecb889..5faa4c8 100644
--- a/internal/convert/convert.go
+++ b/internal/convert/convert.go
@@ -28,15 +28,18 @@ const (
)
// newMarkdown builds the goldmark instance: GFM for tables/strikethrough/
-// task-lists/autolinks, the callout AST transformer, XHTML self-closing tags and
-// raw-HTML passthrough (storage format is XHTML), and our storageRenderer
-// registered at a priority below the default HTML (1000) and GFM table (500)
-// renderers so its node handlers win.
+// task-lists/autolinks, the callout and table-cell-background AST transformers,
+// XHTML self-closing tags and raw-HTML passthrough (storage format is XHTML), and
+// our storageRenderer registered at a priority below the default HTML (1000) and
+// GFM table (500) renderers so its node handlers win.
func newMarkdown(r *storageRenderer) goldmark.Markdown {
return goldmark.New(
goldmark.WithExtensions(extension.GFM),
goldmark.WithParserOptions(
- parser.WithASTTransformers(util.Prioritized(calloutTransformer{}, 100)),
+ parser.WithASTTransformers(
+ util.Prioritized(calloutTransformer{}, 100),
+ util.Prioritized(tableCellBGTransformer{r: r}, 101),
+ ),
),
goldmark.WithRendererOptions(
html.WithXHTML(),
diff --git a/internal/convert/renderer.go b/internal/convert/renderer.go
index 8676ee1..0fa4607 100644
--- a/internal/convert/renderer.go
+++ b/internal/convert/renderer.go
@@ -44,6 +44,7 @@ func (r *storageRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer)
reg.Register(ast.KindImage, r.renderImage)
reg.Register(ast.KindLink, r.renderLink)
reg.Register(tableKind, r.renderTable)
+ reg.Register(tableCellKind, r.renderTableCell)
}
// renderText renders inline text, collapsing soft line breaks to a single space
diff --git a/internal/convert/tables.go b/internal/convert/tables.go
index 3919cf7..6fca491 100644
--- a/internal/convert/tables.go
+++ b/internal/convert/tables.go
@@ -1,8 +1,14 @@
package convert
import (
+ "fmt"
+ "regexp"
+ "strings"
+
"github.com/yuin/goldmark/ast"
east "github.com/yuin/goldmark/extension/ast"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
@@ -31,6 +37,184 @@ func (r *storageRenderer) renderTable(
return ast.WalkContinue, nil
}
-// tableKind is the GFM table node kind, aliased so renderer.go's registration list
-// does not need the extension AST import.
-var tableKind = east.KindTable
+// tableKind and tableCellKind are the GFM table node kinds, aliased so
+// renderer.go's registration list does not need the extension AST import.
+var (
+ tableKind = east.KindTable
+ tableCellKind = east.KindTableCell
+)
+
+// cellBGAttr is the node attribute set on a table cell carrying a background
+// color; its value is the resolved hex for data-highlight-colour.
+const cellBGAttr = "mfCellBG"
+
+// cellBGSwatches maps a color name to its hex. These are the 21 swatches the
+// Confluence editor's cell background picker offers, so a color set from markdown
+// is indistinguishable from one set by hand in the editor (and shows up as the
+// selected swatch there). Read off an editor-authored page (2913502220) on
+// 2026-08-04; the picker is a grid of seven hue columns by three shades, where the
+// grey column runs white / light grey / grey.
+//
+// Confluence accepts any hex in data-highlight-colour, so this map is markfluence's
+// vocabulary rather than a limit imposed by the server; #rrggbb also works
+// directly for a color outside the palette.
+var cellBGSwatches = map[string]string{
+ "white": "#ffffff",
+ "light-grey": "#f4f5f7",
+ "light-gray": "#f4f5f7",
+ "grey": "#b3bac5",
+ "gray": "#b3bac5",
+ "light-blue": "#deebff",
+ "blue": "#b3d4ff",
+ "bold-blue": "#4c9aff",
+ "light-teal": "#e6fcff",
+ "teal": "#b3f5ff",
+ "bold-teal": "#79e2f2",
+ "light-green": "#e3fcef",
+ "green": "#abf5d1",
+ "bold-green": "#57d9a3",
+ "light-yellow": "#fffae6",
+ "yellow": "#fff0b3",
+ "bold-yellow": "#ffc400",
+ "light-red": "#ffebe6",
+ "red": "#ffbdad",
+ "bold-red": "#ff8f73",
+ "light-purple": "#eae6ff",
+ "purple": "#c0b6f2",
+ "bold-purple": "#998dd9",
+}
+
+var (
+ // cellBGMarkerRE matches a cell background marker comment, "".
+ cellBGMarkerRE = regexp.MustCompile(`(?i)^$`)
+ // cellBGHexRE matches a literal color, "#ffebe6".
+ cellBGHexRE = regexp.MustCompile(`^#[0-9a-f]{6}$`)
+)
+
+// tableCellBGTransformer implements the cell background color marker: an HTML
+// comment at the start of a table cell,
+//
+// | auth | down |
+//
+// which is invisible in a plain markdown preview. The marker is stripped from the
+// cell and the resolved color is stashed on the cell node for renderTableCell to
+// emit as data-highlight-colour. An unresolvable color is dropped with a warning.
+type tableCellBGTransformer struct{ r *storageRenderer }
+
+func (t tableCellBGTransformer) Transform(doc *ast.Document, reader text.Reader, _ parser.Context) {
+ source := reader.Source()
+ _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
+ if !entering || n.Kind() != tableCellKind {
+ return ast.WalkContinue, nil
+ }
+ t.applyCellBG(n, source)
+ return ast.WalkSkipChildren, nil
+ })
+}
+
+// applyCellBG consumes a leading marker on one cell. A marker anywhere else in
+// the cell is left alone and warned about: it would otherwise be a silent no-op,
+// since a comment is invisible in both markdown and Confluence.
+func (t tableCellBGTransformer) applyCellBG(cell ast.Node, source []byte) {
+ if first := cell.FirstChild(); first != nil {
+ if value, ok := cellBGMarker(first, source); ok {
+ cell.RemoveChild(cell, first)
+ trimLeadingSpaces(cell.FirstChild(), source)
+ if hex, ok := resolveCellBG(value); ok {
+ cell.SetAttributeString(cellBGAttr, hex)
+ } else {
+ t.warn(cell, source, value, "unknown color: use a swatch name or a #rrggbb hex")
+ }
+ }
+ }
+ for c := cell.FirstChild(); c != nil; c = c.NextSibling() {
+ if value, ok := cellBGMarker(c, source); ok {
+ t.warn(cell, source, value, "a bg marker must come first in the cell")
+ }
+ }
+}
+
+// warn records an ignored marker, quoting the cell's text so the author can find
+// it in a page full of tables.
+func (t tableCellBGTransformer) warn(cell ast.Node, source []byte, value, problem string) {
+ label := strings.TrimSpace(nodeText(cell, source))
+ if runes := []rune(label); len(runes) > 40 {
+ label = string(runes[:40]) + "..."
+ }
+ t.r.warnings = append(t.r.warnings,
+ fmt.Sprintf("table cell %q: ignoring bg:%s (%s)", label, value, problem))
+}
+
+// cellBGMarker reports whether an inline node is a background marker comment,
+// returning the color it names.
+func cellBGMarker(n ast.Node, source []byte) (string, bool) {
+ raw, ok := n.(*ast.RawHTML)
+ if !ok {
+ return "", false
+ }
+ var b strings.Builder
+ for i := 0; i < raw.Segments.Len(); i++ {
+ seg := raw.Segments.At(i)
+ b.Write(seg.Value(source))
+ }
+ m := cellBGMarkerRE.FindStringSubmatch(strings.TrimSpace(b.String()))
+ if m == nil {
+ return "", false
+ }
+ return m[1], true
+}
+
+// resolveCellBG turns a marker's color into a hex, accepting a swatch name or a
+// literal #rrggbb.
+func resolveCellBG(value string) (string, bool) {
+ v := strings.ToLower(value)
+ if hex, ok := cellBGSwatches[v]; ok {
+ return hex, true
+ }
+ if cellBGHexRE.MatchString(v) {
+ return v, true
+ }
+ return "", false
+}
+
+// trimLeadingSpaces drops the whitespace a consumed marker leaves behind at the
+// start of a cell (" down" renders as "down", not " down").
+func trimLeadingSpaces(n ast.Node, source []byte) {
+ txt, ok := n.(*ast.Text)
+ if !ok {
+ return
+ }
+ seg := txt.Segment
+ for seg.Start < seg.Stop && (source[seg.Start] == ' ' || source[seg.Start] == '\t') {
+ seg.Start++
+ }
+ txt.Segment = seg
+}
+
+// renderTableCell emits a | / | , adding data-highlight-colour for a cell with
+// a background color marker. It otherwise reproduces what the GFM renderer emits,
+// including the align attribute Confluence discards (see issue #48).
+func (r *storageRenderer) renderTableCell(
+ w util.BufWriter, _ []byte, node ast.Node, entering bool,
+) (ast.WalkStatus, error) {
+ n := node.(*east.TableCell)
+ tag := "td"
+ if n.Parent().Kind() == east.KindTableHeader {
+ tag = "th"
+ }
+ if !entering {
+ _, _ = w.WriteString("" + tag + ">\n")
+ return ast.WalkContinue, nil
+ }
+ _, _ = w.WriteString("<" + tag)
+ if n.Alignment != east.AlignNone {
+ _, _ = w.WriteString(` align="` + n.Alignment.String() + `"`)
+ }
+ if v, ok := n.AttributeString(cellBGAttr); ok {
+ if hex, ok := v.(string); ok {
+ _, _ = w.WriteString(` data-highlight-colour="` + hex + `"`)
+ }
+ }
+ _ = w.WriteByte('>')
+ return ast.WalkContinue, nil
+}
diff --git a/internal/convert/testdata/regression/table-cell-colors/main.md b/internal/convert/testdata/regression/table-cell-colors/main.md
new file mode 100644
index 0000000..1d97353
--- /dev/null
+++ b/internal/convert/testdata/regression/table-cell-colors/main.md
@@ -0,0 +1,27 @@
+# Table Cell Colors
+
+A leading `bg:` comment sets a cell background, by swatch name or hex, in body
+cells and header cells alike:
+
+| Service | Status | Notes |
+| :------ | :---------------------------- | -------------------------: |
+| auth | ok | steady |
+| billing | down | paging |
+| search | | decommissioned |
+
+An unknown color name is dropped with a warning, and so is a marker that isn't
+first in its cell:
+
+| Cell | Result |
+| --------------------------- | -------------------------- |
+| nope | no background |
+| trailing | no background |
+
+The keyword and the color are case-insensitive; other comments pass through
+untouched:
+
+| Cell |
+| ------------------------------- |
+| shouty |
+| shouty hex |
+| text |
diff --git a/internal/convert/testdata/regression/table-cell-colors/test.output b/internal/convert/testdata/regression/table-cell-colors/test.output
new file mode 100644
index 0000000..7ea585f
--- /dev/null
+++ b/internal/convert/testdata/regression/table-cell-colors/test.output
@@ -0,0 +1,9 @@
+{
+ "attachments": [],
+ "broken": [],
+ "html": "Table Cell Colors\nA leading bg: comment sets a cell background, by swatch name or hex, in body cells and header cells alike: \n\n\n\n| Service | \nStatus | \nNotes | \n \n\n\n\n| auth | \nok | \nsteady | \n \n\n| billing | \ndown | \npaging | \n \n\n| search | \n | \ndecommissioned | \n \n\n \nAn unknown color name is dropped with a warning, and so is a marker that isn't first in its cell: \n\n\n\n| Cell | \nResult | \n \n\n\n\n| nope | \nno background | \n \n\n| trailing | \nno background | \n \n\n \nThe keyword and the color are case-insensitive; other comments pass through untouched: \n\n\n\n| Cell | \n \n\n\n\n| shouty | \n \n\n| shouty hex | \n \n\n| text | \n \n\n \n",
+ "warnings": [
+ "table cell \"nope\": ignoring bg:chartreuse (unknown color: use a swatch name or a #rrggbb hex)",
+ "table cell \"trailing\": ignoring bg:green (a bg marker must come first in the cell)"
+ ]
+}
|