From 3a3c82c9ad729ad64d17ad552e0aeb6981504504 Mon Sep 17 00:00:00 2001 From: Will Kahn-Greene Date: Sat, 25 Jul 2026 22:52:11 -0400 Subject: [PATCH] feat(convert): publish tables with the align-start layout A table with no data-layout attribute auto-sizes but sits unanchored, which renders poorly for the wide, many-column tables markdown tends to produce. "align-start" auto-sizes the table to its content and left-aligns it on the page, which is what a markdown table should look like. Only the tag is overridden; goldmark's GFM renderer still emits the thead/tbody, rows, and cells, so the change to every existing page is exactly one attribute. Confluence does not document its table storage format. The layout vocabulary (center, align-start, wide, full-width) was established empirically by pushing storage and reading the page back as ADF, since an attribute can survive in body.storage while never reaching the renderer. Noted in tables.go: a colgroup on a table with no layout attribute makes Confluence default the layout to full-width, so this attribute must stay if column widths are ever emitted. --- CLAUDE.md | 2 +- internal/convert/renderer.go | 1 + internal/convert/tables.go | 36 +++++++++++++++++++ .../regression/kitchen-sink/test.output | 2 +- .../testdata/regression/tables/test.output | 2 +- 5 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 internal/convert/tables.go diff --git a/CLAUDE.md b/CLAUDE.md index 27a4afe..8ee7b87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,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. 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), 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 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/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/internal/convert/renderer.go b/internal/convert/renderer.go index 75dd523..8676ee1 100644 --- a/internal/convert/renderer.go +++ b/internal/convert/renderer.go @@ -43,6 +43,7 @@ func (r *storageRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) reg.Register(ast.KindBlockquote, r.renderBlockquote) reg.Register(ast.KindImage, r.renderImage) reg.Register(ast.KindLink, r.renderLink) + reg.Register(tableKind, r.renderTable) } // renderText renders inline text, collapsing soft line breaks to a single space diff --git a/internal/convert/tables.go b/internal/convert/tables.go new file mode 100644 index 0000000..3919cf7 --- /dev/null +++ b/internal/convert/tables.go @@ -0,0 +1,36 @@ +package convert + +import ( + "github.com/yuin/goldmark/ast" + east "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/util" +) + +// tableLayout is the Confluence table layout every table is published with. +// Without a layout attribute Confluence auto-sizes the table but leaves it +// unanchored; "align-start" auto-sizes it to its content and left-aligns it on the +// page, which is what a markdown table should look like. The other values Confluence +// accepts are "center", "wide", and "full-width". +// +// Note that a colwidth on a table with no layout attribute makes +// Confluence default the layout to "full-width", so this attribute must stay if +// column widths are ever emitted. +const tableLayout = "align-start" + +// renderTable emits the
tag with the Confluence layout attribute. Only the +// table element itself is overridden; the GFM renderer still emits the thead/tbody, +// rows, and cells. +func (r *storageRenderer) renderTable( + w util.BufWriter, _ []byte, _ ast.Node, entering bool, +) (ast.WalkStatus, error) { + if entering { + _, _ = w.WriteString(`
\n") + } else { + _, _ = w.WriteString("
\n") + } + 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 diff --git a/internal/convert/testdata/regression/kitchen-sink/test.output b/internal/convert/testdata/regression/kitchen-sink/test.output index 2907e90..a289b1f 100644 --- a/internal/convert/testdata/regression/kitchen-sink/test.output +++ b/internal/convert/testdata/regression/kitchen-sink/test.output @@ -6,6 +6,6 @@ } ], "broken": [], - "html": "

Release Notes

\n\n

See the upgrade guide before starting.

\n

Back up your data before upgrading.

\n

What's New

\n

A soft-wrapped paragraph describing the release across multiple source lines that collapse into one.

\n

\n

Configuration

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
SettingDefault
retries3
timeout30s
\npython

A pasted status macro: Stable

\n", + "html": "

Release Notes

\n\n

See the upgrade guide before starting.

\n

Back up your data before upgrading.

\n

What's New

\n

A soft-wrapped paragraph describing the release across multiple source lines that collapse into one.

\n

\n

Configuration

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
SettingDefault
retries3
timeout30s
\npython

A pasted status macro: Stable

\n", "warnings": [] } diff --git a/internal/convert/testdata/regression/tables/test.output b/internal/convert/testdata/regression/tables/test.output index d51958e..f81c3a7 100644 --- a/internal/convert/testdata/regression/tables/test.output +++ b/internal/convert/testdata/regression/tables/test.output @@ -1,6 +1,6 @@ { "attachments": [], "broken": [], - "html": "

Tables

\n

A GFM table with alignment:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
NameRoleCount
AliceMaintainer12
BobContributor3
\n

Text after the table.

\n", + "html": "

Tables

\n

A GFM table with alignment:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
NameRoleCount
AliceMaintainer12
BobContributor3
\n

Text after the table.

\n", "warnings": [] }