From a1daefd9ec01acecb1009bd2da4b02feb0d71459 Mon Sep 17 00:00:00 2001 From: Will Kahn-Greene Date: Tue, 4 Aug 2026 12:41:57 -0400 Subject: [PATCH] feat(convert): publish table cell background colors A leading `` HTML comment in a GFM table cell sets that cell's background in Confluence. The comment is invisible in a markdown preview, and being in the cell, the color travels with the row when rows are moved or reordered. COLOR is one of the 21 swatches the Confluence editor's cell background picker offers, or a literal #rrggbb for anything off-palette. Atlassian doesn't document the palette, so the swatch hexes were read off an editor-authored page; `grey` is #b3bac5, and the grey column of the picker runs white / light grey / grey rather than light/medium/bold. An unknown color name is dropped with a warning, as is a marker that isn't the first thing in its cell: Confluence strips HTML comments on write, so a misplaced marker would otherwise disappear without a trace. tableCellBGTransformer consumes the marker and stashes the resolved hex on the cell node; renderTableCell emits it as data-highlight-colour and otherwise reproduces what goldmark's GFM renderer emits, so no existing golden changed. Cell alignment is untouched (issue #48). Verified live on page 2942664752: all 21 swatches, an off-palette hex, a colored header cell, and a marker-only empty cell reach ADF as a cell background; both warning cases publish uncolored. Fixes #7 --- CLAUDE.md | 2 +- README.md | 52 ++++- internal/convert/convert.go | 13 +- internal/convert/renderer.go | 1 + internal/convert/tables.go | 190 +++++++++++++++++- .../regression/table-cell-colors/main.md | 27 +++ .../regression/table-cell-colors/test.output | 9 + 7 files changed, 281 insertions(+), 13 deletions(-) create mode 100644 internal/convert/testdata/regression/table-cell-colors/main.md create mode 100644 internal/convert/testdata/regression/table-cell-colors/test.output 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("\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

\n

A leading bg: comment sets a cell background, by swatch name or hex, in body cells and header cells alike:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
ServiceStatusNotes
authoksteady
billingdownpaging
searchdecommissioned
\n

An unknown color name is dropped with a warning, and so is a marker that isn't first in its cell:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
CellResult
nopeno background
trailing no background
\n

The keyword and the color are case-insensitive; other comments pass through untouched:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Cell
shouty
shouty hex
text
\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)" + ] +}