Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<table>` 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 `<!-- confluence-toc -->` and `<!-- markfluence-version -->` 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 `<table>` 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 `<!-- bg:COLOR -->` 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 `<!-- confluence-toc -->` and `<!-- markfluence-version -->` 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`.
Expand Down
52 changes: 48 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,8 @@ or a Confluence page URL (the modern `/wiki/.../pages/<id>/...` 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.

Expand Down Expand Up @@ -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 | <!-- bg:light-green --> ok |
| billing | <!-- bg:light-red --> 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:

Expand Down Expand Up @@ -485,7 +529,7 @@ Examples:
URL; **heading anchors** are rewritten to Confluence's anchor scheme.

**Comment directives:**
- `<!-- confluence-toc -->` — table-of-contents macro.
- `<!-- confluence-toc -->` — replaced with Confluence table-of-contents macro.
- `<!-- markfluence-version -->` — replaced with the build stamp,
`markfluence VERSION (SHA, DATE)` (the same string `markfluence --version`
prints).
Expand Down
13 changes: 8 additions & 5 deletions internal/convert/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions internal/convert/renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
190 changes: 187 additions & 3 deletions internal/convert/tables.go
Original file line number Diff line number Diff line change
@@ -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"
)

Expand Down Expand Up @@ -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, "<!-- bg:green -->".
cellBGMarkerRE = regexp.MustCompile(`(?i)^<!--\s*bg:\s*(\S+)\s*-->$`)
// 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 | <!-- bg:light-red --> 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 ("<!-- bg:red --> 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 <th>/<td>, 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
}
27 changes: 27 additions & 0 deletions internal/convert/testdata/regression/table-cell-colors/main.md
Original file line number Diff line number Diff line change
@@ -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 | <!-- bg:light-grey --> Status | Notes |
| :------ | :---------------------------- | -------------------------: |
| auth | <!-- bg:light-green -->ok | steady |
| billing | <!--bg:light-red--> down | <!-- bg:#fffae6 --> paging |
| search | <!-- bg:grey --> | decommissioned |

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

| Cell | Result |
| --------------------------- | -------------------------- |
| <!-- bg:chartreuse --> nope | no background |
| trailing <!-- bg:green --> | no background |

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

| Cell |
| ------------------------------- |
| <!-- BG:Bold-Blue --> shouty |
| <!-- bg:#FFEBE6 --> shouty hex |
| <!-- a note --> text |
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"attachments": [],
"broken": [],
"html": "<h1>Table Cell Colors</h1>\n<p>A leading <code>bg:</code> comment sets a cell background, by swatch name or hex, in body cells and header cells alike:</p>\n<table data-layout=\"align-start\">\n<thead>\n<tr>\n<th align=\"left\">Service</th>\n<th align=\"left\" data-highlight-colour=\"#f4f5f7\">Status</th>\n<th align=\"right\">Notes</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td align=\"left\">auth</td>\n<td align=\"left\" data-highlight-colour=\"#e3fcef\">ok</td>\n<td align=\"right\">steady</td>\n</tr>\n<tr>\n<td align=\"left\">billing</td>\n<td align=\"left\" data-highlight-colour=\"#ffebe6\">down</td>\n<td align=\"right\" data-highlight-colour=\"#fffae6\">paging</td>\n</tr>\n<tr>\n<td align=\"left\">search</td>\n<td align=\"left\" data-highlight-colour=\"#b3bac5\"></td>\n<td align=\"right\">decommissioned</td>\n</tr>\n</tbody>\n</table>\n<p>An unknown color name is dropped with a warning, and so is a marker that isn't first in its cell:</p>\n<table data-layout=\"align-start\">\n<thead>\n<tr>\n<th>Cell</th>\n<th>Result</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>nope</td>\n<td>no background</td>\n</tr>\n<tr>\n<td>trailing <!-- bg:green --></td>\n<td>no background</td>\n</tr>\n</tbody>\n</table>\n<p>The keyword and the color are case-insensitive; other comments pass through untouched:</p>\n<table data-layout=\"align-start\">\n<thead>\n<tr>\n<th>Cell</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td data-highlight-colour=\"#4c9aff\">shouty</td>\n</tr>\n<tr>\n<td data-highlight-colour=\"#ffebe6\">shouty hex</td>\n</tr>\n<tr>\n<td><!-- a note --> text</td>\n</tr>\n</tbody>\n</table>\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)"
]
}