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

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions cmd/check/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"path/filepath"
"strings"

"github.com/mozilla/markfluence/internal/buildinfo"
"github.com/mozilla/markfluence/internal/completion"
"github.com/mozilla/markfluence/internal/convert"
"github.com/mozilla/markfluence/internal/frontmatter"
Expand Down Expand Up @@ -252,7 +251,7 @@ func processFile(filename string, roots *project.Cache, indexes *linkindex.Cache
project.Filename))
}

page, err := convert.MdToConfluence(mf, root, index, checkBaseURL, checkSpaceKey, buildinfo.Stamp())
page, err := convert.MdToConfluence(mf, root, index, checkBaseURL, checkSpaceKey)
if err != nil {
// Two assets wanting one attachment name is a defect in the document,
// not a failure of the converter: the author fixes it by renaming a
Expand Down
5 changes: 2 additions & 3 deletions cmd/create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import (
"path/filepath"
"strings"

"github.com/mozilla/markfluence/internal/buildinfo"
"github.com/mozilla/markfluence/internal/client"
"github.com/mozilla/markfluence/internal/completion"
"github.com/mozilla/markfluence/internal/convert"
Expand Down Expand Up @@ -628,7 +627,7 @@ func publishOne(
) *createResult {
// SiteURL, not BaseURL: rewritten links are published into the page, so they
// must point at the site even when requests go through the gateway.
pageContent, err := convert.MdToConfluence(r.mdfile, r.root, r.index, c.SiteURL(), r.spaceKey, buildinfo.Stamp())
pageContent, err := convert.MdToConfluence(r.mdfile, r.root, r.index, c.SiteURL(), r.spaceKey)
if err != nil {
return res.fail(err, jsonout.CodeConvert)
}
Expand Down Expand Up @@ -843,7 +842,7 @@ func resolveFile(
// idx.anchors, fixed at Build time and identical in both phases. A change
// making SetPage also mark a file as existing would break this. Pinned by
// TestErrorDoesNotDependOnTheIndex in internal/convert.
if _, err := convert.MdToConfluence(mf, root, index, c.SiteURL(), spaceKey, buildinfo.Stamp()); err != nil {
if _, err := convert.MdToConfluence(mf, root, index, c.SiteURL(), spaceKey); err != nil {
return record{}, &convertFailure{err: err}
}

Expand Down
6 changes: 3 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ var rootCmd = &cobra.Command{
"must go through Atlassian's api.atlassian.com gateway. Leave it unset for an\n" +
"unscoped personal token. Find yours at\n" +
"https://YOUR-SITE.atlassian.net/_edge/tenant_info -- it isn't a secret.",
// --version prints the build stamp ("markfluence VERSION (SHA, DATE)"), the
// same string the converter substitutes for the <!-- markfluence-version -->
// token.
// --version prints the build stamp ("markfluence VERSION (SHA, DATE)"). The
// only use of it: nothing published carries a build stamp, and the converter
// takes no build state at all.
Version: buildinfo.Stamp(),
PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
if noColorFlag {
Expand Down
3 changes: 1 addition & 2 deletions cmd/update/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"strings"
"time"

"github.com/mozilla/markfluence/internal/buildinfo"
"github.com/mozilla/markfluence/internal/client"
"github.com/mozilla/markfluence/internal/completion"
"github.com/mozilla/markfluence/internal/convert"
Expand Down Expand Up @@ -294,7 +293,7 @@ func processFile(

// SiteURL, not BaseURL: rewritten links are published into the page, so they
// must point at the site even when requests go through the gateway.
pageContent, err := convert.MdToConfluence(mf, root, index, c.SiteURL(), r.space, buildinfo.Stamp())
pageContent, err := convert.MdToConfluence(mf, root, index, c.SiteURL(), r.space)
if err != nil {
return r.fail(err, jsonout.CodeConvert)
}
Expand Down
30 changes: 30 additions & 0 deletions docs/confluence/storage-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,36 @@ would churn ids forever.
So the guarantee is **semantic**, not byte-for-byte, which is also the
converter's stated design target.

### Confluence strips HTML comments on write

**Verified 2026-09-13.** A comment does not survive the write at all — it is not
stored and not rendered, and an *inline* one leaves its surrounding whitespace
behind:

```diff
- <p>before</p><!-- generic block comment --><p>mid <!-- inline comment --> text</p><p>after</p>
+ <p>before</p><p>mid text</p><p>after</p>
```

Note the doubled space in `mid text`: the removal is not even whitespace-clean,
so a comment cannot be treated as a no-op even positionally.

Two consequences, both about comparing a body markfluence sent against the body
Confluence stored:

- **`client.updateLanded` can never match for a page whose markdown contains an
HTML comment.** It recovers a lost response by re-reading the page and
accepting the write only when version, title *and* `body.storage` all equal
what was sent — and the stored body will always differ by the stripped
comment. So for such a page a write that actually landed is reported as a
failure. Narrow today, because nothing markfluence *generates* is a comment
(`<!-- bg:COLOR -->` is consumed by the AST transformer and
`<!-- confluence-toc -->` is substituted), but an author-written comment is
legal markdown and passes straight through `html.WithUnsafe()`.
- **A content-based idempotence check has to normalize comments away** or it
reports a difference on every run for the same file — the exact opposite of
what it is for. See #149, which proposes exactly that comparison.

## Table layout

Every table markfluence publishes carries `data-layout="align-start"`, which
Expand Down
11 changes: 8 additions & 3 deletions docs/markdown_file.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,9 +357,14 @@ over every real name in a tree.
### Comment directives

- `<!-- 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).

That is the only one. **Any other HTML comment you write is discarded**, and not
by markfluence: Confluence strips every comment on write, so it never reaches
the stored page (measured — see
[storage-format.md](confluence/storage-format.md#confluence-strips-html-comments-on-write)).
So a comment is not a way to leave a note on a published page. To say where a
page comes from, put a callout at the top of the markdown and link the source
file; a [GitHub alert](#github-alerts) converts to a Confluence panel.

### Raw Confluence storage format

Expand Down
12 changes: 8 additions & 4 deletions internal/buildinfo/buildinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,14 @@ func Revision() string {
return rev
}

// Stamp is the build stamp used both for --version and for the
// <!-- markfluence-version --> token embedded in published pages:
// "markfluence VERSION (SHA, DATE)". The commit hash and date are each omitted
// when unavailable (and the parenthetical drops entirely if both are).
// Stamp is the build stamp --version prints: "markfluence VERSION (SHA, DATE)".
// The commit hash and date are each omitted when unavailable (and the
// parenthetical drops entirely if both are).
//
// Only --version uses it, and nothing published carries it. Keep it out of
// internal/convert: that package's output depends only on its arguments and the
// files on disk, and a build stamp in a page body makes the same file render
// differently on every upgrade.
func Stamp() string {
s := "markfluence " + Version
var meta []string
Expand Down
14 changes: 8 additions & 6 deletions internal/convert/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ const (
// tocToken is replaced by the Confluence table-of-contents macro.
tocToken = "<!-- confluence-toc -->"
tocMacro = `<ac:structured-macro ac:name="toc" ac:schema-version="1" />`
// versionToken is replaced by the build version stamp passed to MdToConfluence.
versionToken = "<!-- markfluence-version -->"
)

// scanParser parses for inspection rather than for rendering: same extensions,
Expand Down Expand Up @@ -67,10 +65,15 @@ func newMarkdown(r *storageRenderer) goldmark.Markdown {
// MdToConfluence assuming the working directory. index is the tree-wide
// link/anchor index for root -- built once and shared across every file
// converted under it (internal/linkindex.Build), not rebuilt here per
// conversion. version is the build stamp substituted for the
// <!-- markfluence-version --> token.
// conversion.
//
// The output depends only on these arguments and the files under root: no build
// state, no clock, nothing from the environment. That is what makes the
// regression goldens deterministic by construction and check --show-html
// byte-identical between two machines at one commit -- so nothing that varies
// per build belongs in here, however small.
func MdToConfluence(
md *frontmatter.MarkdownFile, root *project.Root, index *linkindex.Index, baseURL, spaceKey, version string,
md *frontmatter.MarkdownFile, root *project.Root, index *linkindex.Index, baseURL, spaceKey string,
) (*ConfluencePage, error) {
// Shield raw ac:/ri: storage tags so goldmark passes them through instead of
// escaping them; restore them after rendering.
Expand Down Expand Up @@ -104,7 +107,6 @@ func MdToConfluence(
}
out := unshield(buf.String())
out = strings.ReplaceAll(out, tocToken, tocMacro)
out = strings.ReplaceAll(out, versionToken, version)

page := &ConfluencePage{
HTML: out,
Expand Down
2 changes: 1 addition & 1 deletion internal/convert/errorindex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func convertSeeded(
for key, entry := range seed {
idx.SetPage(key, entry)
}
return convert.MdToConfluence(md, r, idx, "https://wiki.example.net", "ENG", "vtest")
return convert.MdToConfluence(md, r, idx, "https://wiki.example.net", "ENG")
}

// siblingEntry is what create's reserve phase seeds for an in-set file: the id
Expand Down
2 changes: 1 addition & 1 deletion internal/convert/namecollision_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func convertBody(t *testing.T, root, body string, images ...string) (*convert.Co
t.Fatal(err)
}
defer func() { _ = r.FS.Close() }()
return convert.MdToConfluence(md, r, testIndex(t, r), "https://wiki.example.net", "ENG", "vtest")
return convert.MdToConfluence(md, r, testIndex(t, r), "https://wiki.example.net", "ENG")
}

// TestRefusesTwoAssetsWithOneName is the refusal itself. It names both paths,
Expand Down
6 changes: 4 additions & 2 deletions internal/convert/regression_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,10 @@ func runCase(t *testing.T, caseDir string) []byte {
t.Fatalf("building link index: %v", err)
}

// A fixed version stamp keeps goldens deterministic; no case uses the token.
page, err := convert.MdToConfluence(md, root, index, cfg.baseURL, cfg.spaceKey, "markfluence vtest")
// Goldens are deterministic by construction: the converter reads nothing but
// its arguments and the files under root -- no build state, no clock -- so
// nothing here has to be pinned to keep them stable.
page, err := convert.MdToConfluence(md, root, index, cfg.baseURL, cfg.spaceKey)
if err != nil {
t.Fatalf("MdToConfluence: %v", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/convert/roundtrip_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ func publishPage(t *testing.T, md string) *convert.ConfluencePage {
}
defer func() { _ = r.FS.Close() }()

page, err := convert.MdToConfluence(mf, r, testIndex(t, r), "https://wiki.example.net", "ENG", "vtest")
page, err := convert.MdToConfluence(mf, r, testIndex(t, r), "https://wiki.example.net", "ENG")
if err != nil {
t.Fatalf("markdown -> storage: %v", err)
}
Expand Down
14 changes: 7 additions & 7 deletions internal/convert/storage_to_md_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ func TestRoundTripStableCallouts(t *testing.T) {
t.Fatal(err)
}
root := testRoot(t, "")
page, err := convert.MdToConfluence(md, root, testIndex(t, root), "https://wiki.example.net", "ENG", "vtest")
page, err := convert.MdToConfluence(md, root, testIndex(t, root), "https://wiki.example.net", "ENG")
if err != nil {
t.Fatalf("MdToConfluence: %v", err)
}
Expand Down Expand Up @@ -145,7 +145,7 @@ func TestRoundTripTableAlignment(t *testing.T) {
t.Fatal(err)
}
root := testRoot(t, "")
page, err := convert.MdToConfluence(md, root, testIndex(t, root), "https://wiki.example.net", "ENG", "vtest")
page, err := convert.MdToConfluence(md, root, testIndex(t, root), "https://wiki.example.net", "ENG")
if err != nil {
t.Fatalf("MdToConfluence: %v", err)
}
Expand Down Expand Up @@ -186,7 +186,7 @@ func TestRoundTripTableCellBG(t *testing.T) {
t.Fatal(err)
}
root := testRoot(t, "")
page, err := convert.MdToConfluence(md, root, testIndex(t, root), "https://wiki.example.net", "ENG", "vtest")
page, err := convert.MdToConfluence(md, root, testIndex(t, root), "https://wiki.example.net", "ENG")
if err != nil {
t.Fatalf("MdToConfluence: %v", err)
}
Expand Down Expand Up @@ -239,7 +239,7 @@ func TestStorageToMarkdownJoinsMultilineCells(t *testing.T) {
t.Fatal(err)
}
root := testRoot(t, "")
page, err := convert.MdToConfluence(md, root, testIndex(t, root), "https://wiki.example.net", "ENG", "vtest")
page, err := convert.MdToConfluence(md, root, testIndex(t, root), "https://wiki.example.net", "ENG")
if err != nil {
t.Fatalf("MdToConfluence: %v", err)
}
Expand Down Expand Up @@ -295,7 +295,7 @@ func TestStorageToMarkdownPassesThroughListsInCells(t *testing.T) {
t.Fatal(err)
}
root := testRoot(t, "")
page, err := convert.MdToConfluence(md, root, testIndex(t, root), "https://wiki.example.net", "ENG", "vtest")
page, err := convert.MdToConfluence(md, root, testIndex(t, root), "https://wiki.example.net", "ENG")
if err != nil {
t.Fatalf("MdToConfluence: %v", err)
}
Expand Down Expand Up @@ -389,7 +389,7 @@ func TestRoundTripPassthrough(t *testing.T) {
t.Fatal(err)
}
root := testRoot(t, "")
page, err := convert.MdToConfluence(md, root, testIndex(t, root), "https://wiki.example.net", "ENG", "vtest")
page, err := convert.MdToConfluence(md, root, testIndex(t, root), "https://wiki.example.net", "ENG")
if err != nil {
t.Fatalf("MdToConfluence: %v", err)
}
Expand Down Expand Up @@ -679,7 +679,7 @@ func publishAlert(t *testing.T, alert string) string {
t.Fatal(err)
}
root := testRoot(t, "")
page, err := convert.MdToConfluence(md, root, testIndex(t, root), "https://wiki.example.net", "ENG", "vtest")
page, err := convert.MdToConfluence(md, root, testIndex(t, root), "https://wiki.example.net", "ENG")
if err != nil {
t.Fatalf("MdToConfluence: %v", err)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/convert/symlink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func TestRenderImageRefusesSymlinkedLeaf(t *testing.T) {
defer func() { _ = r.FS.Close() }()

idx := testIndex(t, r)
page, err := convert.MdToConfluence(md, r, idx, "https://wiki.example.net", "ENG", "vtest")
page, err := convert.MdToConfluence(md, r, idx, "https://wiki.example.net", "ENG")
if err != nil {
t.Fatalf("MdToConfluence: %v", err)
}
Expand Down Expand Up @@ -92,7 +92,7 @@ func TestRenderImageRefusesEscapeThroughSymlinkedDirectory(t *testing.T) {
defer func() { _ = r.FS.Close() }()

idx := testIndex(t, r)
page, err := convert.MdToConfluence(md, r, idx, "https://wiki.example.net", "ENG", "vtest")
page, err := convert.MdToConfluence(md, r, idx, "https://wiki.example.net", "ENG")
if err != nil {
t.Fatalf("MdToConfluence: %v", err)
}
Expand Down
32 changes: 0 additions & 32 deletions internal/convert/version_test.go

This file was deleted.