Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d5e2953
docs: plan for reading settings from markfluence.yaml
willkg Sep 12, 2026
a4b3881
refactor(frontmatter): expose the YAML dialect reader for whole docum…
willkg Sep 12, 2026
3bdd6b4
feat(project): read markfluence.yaml, refusing a file it cannot under…
willkg Sep 12, 2026
2c715f9
fix(client): stop swallowing a root-discovery failure when locating .env
willkg Sep 12, 2026
774d089
feat(create): default space and page_width from markfluence.yaml
willkg Sep 12, 2026
bf145e7
feat(update): default page_width from markfluence.yaml
willkg Sep 12, 2026
05864c8
feat(check): validate markfluence.yaml offline
willkg Sep 12, 2026
1250f2f
feat(project): report a project file's settings under --debug
willkg Sep 12, 2026
db79447
docs(export): the marker file is read now, not merely present
willkg Sep 12, 2026
103fc1b
docs: record the project-file settings and the precedence chain
willkg Sep 12, 2026
0c8e064
docs: record why create still persists a project-wide space
willkg Sep 12, 2026
5516a31
fix(frontmatter): restore the flat-mapping refusal's wording
willkg Sep 12, 2026
70bddca
fix(check): do not fail a file that overrides the project's page_width
willkg Sep 12, 2026
f3ccd97
refactor(project): report settings from the command layer, not from t…
willkg Sep 12, 2026
2ac2668
fix(attachment-upload): report a malformed project file as a local de…
willkg Sep 12, 2026
8eda729
test(update): pin the project-wide width on the wire
willkg Sep 12, 2026
be9d66c
docs: correct two renamed identifiers and record the review
willkg Sep 12, 2026
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
5 changes: 3 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,21 @@ storage markup.
# relative to this directory. https://github.com/mozilla/markfluence
```

It can also carry **project-wide defaults**, which is what saves a hundred
files from each repeating `space: ENG`:

```yaml
space: ENG
page_width: max
```

Each is a default a file overrides: the chain is **flag > frontmatter >
project file**, so the answer closest to the content wins. A key markfluence
does not recognise is an error rather than something ignored — a typo in a
project-wide default is wrong for every file at once. Credentials are
deliberately not settings here; see
[docs/root-model.md](docs/root-model.md#what-it-deliberately-does-not-hold).

The rest of this section is the precise version of the same idea. Every
markdown file has a **documentation root**: the directory holding
`markfluence.yaml`, found by walking up from the file's own directory, or —
Expand All @@ -494,8 +509,8 @@ overrides discovery for the whole invocation — and, for `create`, `update`,
and `attachment-upload`, also redirects where `.env` is read from (see
[Configure](#configure)).

For the reasoning behind this model — why a bare marker file, what it fixes,
what it costs — see [docs/root-model.md](docs/root-model.md) and
For the reasoning behind this model — what it fixes, what it costs, and every
project-wide setting — see [docs/root-model.md](docs/root-model.md) and
[_plans/025_file-organization.md](_plans/025_file-organization.md).

### Moving files and assets
Expand Down
440 changes: 440 additions & 0 deletions _plans/038_project-file-settings.md

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion cmd/attachmentupload/attachmentupload.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,14 @@ func rootRelativeSource(f string, roots *project.Cache) (string, error) {
}
root, err := roots.Resolve(filepath.Dir(abs))
if err != nil {
return "", fmt.Errorf("resolving the documentation root: %w", err)
if project.IsConfigError(err) {
// A markfluence.yaml that cannot be understood is a local defect in
// a file the author can open and fix, so it travels as badInput and
// is reported VALIDATION rather than IO -- matching create, update
// and check.
return "", badInput{err}
}
return "", project.RootError(err)
}
rel, err := filepath.Rel(root.Dir, abs)
if err != nil {
Expand Down
42 changes: 41 additions & 1 deletion cmd/attachmentupload/attachmentupload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ import (
"github.com/mozilla/markfluence/internal/project"
)

// writeMarker plants a valid markfluence.yaml -- a comment and nothing else,
// which is what ships and what export plants.
func writeMarker(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, project.Filename)
if err := os.WriteFile(path, []byte("# Marks the root of a markfluence project.\n"), 0o644); err != nil {
t.Fatal(err)
}
return path
}

func writeFile(t *testing.T, dir, name string) string {
t.Helper()
path := filepath.Join(dir, name)
Expand Down Expand Up @@ -132,7 +143,9 @@ func TestLocalAttachmentsRefusesABatchCollision(t *testing.T) {
b := writeFile(t, root, "deploy/diagram.png")
cache := project.NewCache("")
if declareRoot {
writeFile(t, root, "markfluence.yaml")
// A real marker, not writeFile's placeholder bytes: the project
// file is parsed now, and a bare scalar in it is refused.
writeMarker(t, root)
cache = project.NewCache(root)
}

Expand Down Expand Up @@ -307,3 +320,30 @@ func TestPlanFailureCodeSeparatesServerFromLocal(t *testing.T) {
})
}
}

// A markfluence.yaml that cannot be understood is a local defect in a file the
// author can open and fix, so it is reported VALIDATION rather than IO --
// matching create, update and check, and the CLAUDE.md bullet that says those
// helpers exist for exactly that.
func TestLocalAttachmentsReportsAMalformedProjectFileAsValidation(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, project.Filename),
[]byte("spce: ENG\n"), 0o644); err != nil {
t.Fatal(err)
}
file := writeFile(t, root, "assets/x.png")

_, err := localAttachments([]string{file}, "", project.NewCache(""))
if err == nil {
t.Fatal("localAttachments succeeded with a malformed project file, want an error")
}
if got := localAttachmentsCode(err); got != jsonout.CodeValidation {
t.Errorf("code = %q, want VALIDATION", got)
}
if strings.Contains(err.Error(), "resolving the documentation root") {
t.Errorf("error = %q, want no root-resolution heading: the root was found", err)
}
if !strings.Contains(err.Error(), `unknown setting "spce"`) {
t.Errorf("error = %q, want the unknown-setting message", err)
}
}
52 changes: 45 additions & 7 deletions cmd/check/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/mozilla/markfluence/internal/buildinfo"
"github.com/mozilla/markfluence/internal/completion"
Expand Down Expand Up @@ -90,6 +91,10 @@ func run(cmd *cobra.Command, args []string) error {
for _, dir := range roots.Roots() {
ui.Info("root: " + dir)
}
// Under --debug only, and beside the root it belongs to: a project-wide
// default takes effect for a file that says nothing about it, so it has no
// answer anywhere in the file a reader would open.
project.ReportSettings(roots)

if ui.IsJSON() {
items := make([]any, len(results))
Expand Down Expand Up @@ -148,15 +153,24 @@ func processFile(filename string, roots *project.Cache, indexes *linkindex.Cache
}
root, err := roots.Resolve(filepath.Dir(abs))
if err != nil {
return r.fail(fmt.Errorf("resolving the documentation root: %w", err), jsonout.CodeIO)
code := jsonout.CodeIO
if project.IsConfigError(err) {
// A markfluence.yaml that cannot be understood is a local defect in
// a file the author can open and fix, which is check's whole
// subject -- reporting it as I/O would send the reader looking for
// a disk fault.
code = jsonout.CodeValidation
}
return r.fail(project.RootError(err), code)
}
index, err := indexes.Get(root)
if err != nil {
return r.fail(fmt.Errorf("building the link index: %w", err), jsonout.CodeIO)
}

// Collected before the conversion, which can bail out: a frontmatter defect
// is independent of anything the converter finds, and reporting it only when
// Collected before the conversion, which can bail out: a defect found
// without the converter -- in the frontmatter or in the project file -- is
// independent of anything the converter finds, and reporting it only when
// the body happens to convert would hide it behind an unrelated failure.
//
// A title that is present and empty is a guaranteed publish failure needing
Expand All @@ -165,9 +179,33 @@ func processFile(filename string, roots *project.Cache, indexes *linkindex.Cache
// because check cannot know which verb is coming, and that reasoning stops
// applying once both verbs agree. An absent title stays unreported: update
// accepts it and keeps the live page's title.
var frontmatterBroken []string
var localBroken []string
// A project-wide page_width Confluence does not accept, reported only for a
// file that would actually use it -- one whose own frontmatter declares no
// width. A file that declares its own wins over the project file (the
// chain is flag > frontmatter > project file), so reporting the project's
// bad value there would fail a file that publishes perfectly well, and
// check's rule is that a false positive is worse than a miss.
//
// This is where a project-wide width is validated offline at all:
// internal/project cannot check its own value, since it would have to
// import internal/pagewidth, which imports internal/client, which holds a
// *project.Cache. check is the one verb that can find it without
// publishing.
//
// Broken rather than a warning, matching an invalid frontmatter page_width:
// for the files it is reported on, the publish really would fail. And
// reported per file rather than once for the run, which is what keeps every
// diagnostic scoped to the files actually named -- a file under a different
// project hears nothing about this one.
if root.Config.PageWidth != "" && strings.TrimSpace(mf.Frontmatter["page_width"]) == "" {
if _, err := pagewidth.Declared(
map[string]string{"page_width": root.Config.PageWidth}); err != nil {
localBroken = append(localBroken, fmt.Sprintf("%s: %s", root.File, err))
}
}
if title, present := mf.TitleField(); present && title == "" {
frontmatterBroken = append(frontmatterBroken,
localBroken = append(localBroken,
"frontmatter has an empty 'title:'; give it a value or remove it")
}

Expand All @@ -187,13 +225,13 @@ func processFile(filename string, roots *project.Cache, indexes *linkindex.Cache
// past a document it has already refused to publish.
var collision *convert.NameCollisionError
if errors.As(err, &collision) {
r.broken = append(frontmatterBroken, collision.Error())
r.broken = append(localBroken, collision.Error())
r.status = statusBroken
return r
}
return r.fail(err, jsonout.CodeConvert)
}
r.broken = append(frontmatterBroken, page.Broken...)
r.broken = append(localBroken, page.Broken...)
// Label warnings lead: they are a property of the frontmatter, so they hold
// whatever the converter went on to find in the body.
r.warnings = append(labelSet.Warnings, page.Warnings...)
Expand Down
135 changes: 135 additions & 0 deletions cmd/check/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -494,3 +494,138 @@ func TestRunScalarLabelsIsFailed(t *testing.T) {
t.Errorf("output = %q, want it to name the list form", out)
}
}

// check is the one verb that can find a project-wide page_width Confluence
// does not accept without publishing: internal/project cannot validate its own
// value, since it would have to import internal/pagewidth, which imports
// internal/client, which holds a *project.Cache.
func TestRunInvalidProjectPageWidthIsBroken(t *testing.T) {
dir := t.TempDir()
write(t, filepath.Join(dir, "markfluence.yaml"), "page_width: huge\n")
write(t, filepath.Join(dir, "main.md"), "---\ntitle: Main\npage_id: 1\n---\n# Main\n")

out, err := captureOutput(t, func() error { return run(testCmd(t, ""), []string{filepath.Join(dir, "main.md")}) })
if !ui.IsSilent(err) || ui.ExitCode(err) != 1 {
t.Fatalf("run = %v, want a silent exit-1 error", err)
}
if !strings.Contains(out, "invalid page_width") {
t.Errorf("output = %q, want the invalid-width message", out)
}
// The message has to name the project file, not the markdown file, which
// has no page_width in it at all.
if !strings.Contains(out, "markfluence.yaml") {
t.Errorf("output = %q, want it to name markfluence.yaml", out)
}
}

// A valid project-wide width is a default, not a per-file requirement.
func TestRunValidProjectPageWidthIsClean(t *testing.T) {
dir := t.TempDir()
write(t, filepath.Join(dir, "markfluence.yaml"), "page_width: wide\nspace: ENG\n")
write(t, filepath.Join(dir, "main.md"), "---\ntitle: Main\npage_id: 1\n---\n# Main\n")

if _, err := captureOutput(t, func() error {
return run(testCmd(t, ""), []string{filepath.Join(dir, "main.md")})
}); err != nil {
t.Fatalf("run = %v, want success", err)
}
}

// A markfluence.yaml that cannot be understood is a local defect in a file the
// author can open and fix, which is exactly check's subject -- so it fails the
// file as VALIDATION rather than as I/O, and without the "resolving the
// documentation root" heading, since the root was found.
func TestRunMalformedProjectFileIsAValidationFailure(t *testing.T) {
dir := t.TempDir()
write(t, filepath.Join(dir, "markfluence.yaml"), "spce: ENG\n")
write(t, filepath.Join(dir, "main.md"), "---\ntitle: Main\npage_id: 1\n---\n# Main\n")

var env struct {
Results []struct {
Status string `json:"status"`
Code *string `json:"code"`
Error *string `json:"error"`
} `json:"results"`
}
ui.SetJSON(true)
t.Cleanup(func() { ui.SetJSON(false) })
out, err := captureOutput(t, func() error {
return run(testCmd(t, ""), []string{filepath.Join(dir, "main.md")})
})
if !ui.IsSilent(err) || ui.ExitCode(err) != 1 {
t.Fatalf("run = %v, want a silent exit-1 error", err)
}
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, out)
}
if len(env.Results) != 1 {
t.Fatalf("results = %#v, want one", env.Results)
}
got := env.Results[0]
if got.Status != "failed" {
t.Errorf("status = %q, want failed", got.Status)
}
if got.Code == nil || *got.Code != "VALIDATION" {
t.Errorf("code = %v, want VALIDATION", got.Code)
}
if got.Error == nil {
t.Fatal("error = nil, want a message")
}
if !strings.Contains(*got.Error, `unknown setting "spce"`) {
t.Errorf("error = %q, want the unknown-setting message", *got.Error)
}
if strings.Contains(*got.Error, "resolving the documentation root") {
t.Errorf("error = %q, want no root-resolution heading", *got.Error)
}
}

// A project file under a *different* root says nothing about a file checked
// elsewhere: diagnostics stay scoped to the file they apply to. Both files are
// named in one run so the scoping is actually exercised -- naming only the good
// one would pass against any implementation, since nothing would touch the bad
// tree at all.
func TestRunProjectDefectIsScopedToItsOwnRoot(t *testing.T) {
base := t.TempDir()
bad := filepath.Join(base, "bad")
good := filepath.Join(base, "good")
write(t, filepath.Join(bad, "markfluence.yaml"), "page_width: huge\n")
write(t, filepath.Join(bad, "bad.md"), "---\ntitle: Bad\npage_id: 1\n---\n# Bad\n")
write(t, filepath.Join(good, "markfluence.yaml"), "page_width: wide\n")
write(t, filepath.Join(good, "good.md"), "---\ntitle: Good\npage_id: 2\n---\n# Good\n")

out, err := captureOutput(t, func() error {
return run(testCmd(t, ""), []string{
filepath.Join(good, "good.md"), filepath.Join(bad, "bad.md")})
})
if !ui.IsSilent(err) || ui.ExitCode(err) != 1 {
t.Fatalf("run = %v, want a silent exit-1 error (bad.md is broken)", err)
}
if !strings.Contains(out, "1 of 2 file(s) failed") {
t.Errorf("output = %q, want exactly one of the two files to fail", out)
}
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "good.md") && strings.Contains(line, "invalid page_width") {
t.Errorf("good.md was blamed for the other project's width: %q", line)
}
}
}

// A file declaring its own page_width wins over the project file, so the
// project's bad value must not fail it: check's rule is that a false positive
// is worse than a miss (CLAUDE.md), and update would publish this file fine.
func TestRunInvalidProjectPageWidthIsNotReportedForAFileThatOverridesIt(t *testing.T) {
dir := t.TempDir()
write(t, filepath.Join(dir, "markfluence.yaml"), "page_width: huge\n")
write(t, filepath.Join(dir, "main.md"),
"---\ntitle: Main\npage_id: 1\npage_width: wide\n---\n# Main\n")

out, err := captureOutput(t, func() error {
return run(testCmd(t, ""), []string{filepath.Join(dir, "main.md")})
})
if err != nil {
t.Fatalf("run = %v, want success: the file declares its own width", err)
}
if strings.Contains(out, "invalid page_width") {
t.Errorf("output = %q, want no complaint: the file's own width wins", out)
}
}
Loading