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)" + ] +}