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\nSee the upgrade guide before starting.
\nBack up your data before upgrading.
\nWhat's New
\nA soft-wrapped paragraph describing the release across multiple source lines that collapse into one.
\n
\nConfiguration
\n\n\n\n| Setting | \nDefault | \n
\n\n\n\n| retries | \n3 | \n
\n\n| timeout | \n30s | \n
\n\n
\npythonA pasted status macro: Stable
\n",
+ "html": "Release Notes
\n\nSee the upgrade guide before starting.
\nBack up your data before upgrading.
\nWhat's New
\nA soft-wrapped paragraph describing the release across multiple source lines that collapse into one.
\n
\nConfiguration
\n\n\n\n| Setting | \nDefault | \n
\n\n\n\n| retries | \n3 | \n
\n\n| timeout | \n30s | \n
\n\n
\npythonA 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
\nA GFM table with alignment:
\n\n\n\n| Name | \nRole | \nCount | \n
\n\n\n\n| Alice | \nMaintainer | \n12 | \n
\n\n| Bob | \nContributor | \n3 | \n
\n\n
\nText after the table.
\n",
+ "html": "Tables
\nA GFM table with alignment:
\n\n\n\n| Name | \nRole | \nCount | \n
\n\n\n\n| Alice | \nMaintainer | \n12 | \n
\n\n| Bob | \nContributor | \n3 | \n
\n\n
\nText after the table.
\n",
"warnings": []
}