From 02f485b5209f40c5c6f5c6d305907dc752c0fcc4 Mon Sep 17 00:00:00 2001 From: Will Kahn-Greene Date: Sun, 30 Aug 2026 22:08:12 -0400 Subject: [PATCH] fix(convert): stop check misreporting a self-referencing same-page anchor as a broken cross-file link A same-page anchor (#heading) in a file with no page_id yet resolved to a real heading fine, but rewriteHref's fully-qualifying step could only report it through the generic "link not resolved: TARGET" warning, naming the current file as if it were some other unresolved reference. Give the self-reference case its own "same-page anchor not resolved" message, gated on the anchor having actually matched a heading so a self-link with a bad fragment still falls through to the existing (redundant but accurate) warning pair rather than claiming a resolution that didn't happen. Fixes #118. --- cmd/check/check.go | 10 +++--- cmd/check/check_test.go | 32 +++++++++++++++++++ internal/convert/links.go | 29 +++++++++++++++-- .../same-page-anchor-unpublished/main.md | 16 ++++++++++ .../same-page-anchor-unpublished/test.output | 10 ++++++ 5 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 internal/convert/testdata/regression/same-page-anchor-unpublished/main.md create mode 100644 internal/convert/testdata/regression/same-page-anchor-unpublished/test.output diff --git a/cmd/check/check.go b/cmd/check/check.go index 1c8e4ef..343ad92 100644 --- a/cmd/check/check.go +++ b/cmd/check/check.go @@ -49,11 +49,11 @@ var Cmd = &cobra.Command{ "not fail.\n\n" + "\"link not resolved: TARGET\" means TARGET is a sibling .md file that exists\n" + "under the documentation root but has no page_id yet -- the normal state of\n" + - "a tree that hasn't been published, not a defect. A same-page anchor\n" + - "(#heading) hits the same warning when the current file itself has no\n" + - "page_id yet, which can read as though the file names itself as an\n" + - "unresolved target -- it doesn't; that's just this file, before its first\n" + - "publish.", + "a tree that hasn't been published, not a defect. \"same-page anchor not\n" + + "resolved: #heading\" is the same situation for a same-page anchor: it\n" + + "resolves to a real heading in the current file, but can't be turned into\n" + + "an absolute URL until this file itself has a page_id -- resolved by this\n" + + "file's own first publish, nothing to fix.", Args: cobra.MinimumNArgs(1), ValidArgsFunction: completion.MarkdownFiles, RunE: run, diff --git a/cmd/check/check_test.go b/cmd/check/check_test.go index f172a2f..b379eb0 100644 --- a/cmd/check/check_test.go +++ b/cmd/check/check_test.go @@ -85,6 +85,38 @@ func TestRunWarnings(t *testing.T) { } } +func TestRunSamePageAnchorUnpublishedWarnsDistinctly(t *testing.T) { + dir := t.TempDir() + write(t, filepath.Join(dir, "draft.md"), "# Draft\n\n[back to top](#draft)\n") + + out, err := captureOutput(t, func() error { return run(testCmd(t, ""), []string{filepath.Join(dir, "draft.md")}) }) + if err != nil { + t.Fatalf("run: %v (warnings alone must not fail)", err) + } + if !strings.Contains(out, "same-page anchor not resolved: #draft") { + t.Errorf("output = %q, want the same-page-anchor warning", out) + } + if strings.Contains(out, "link not resolved") { + t.Errorf("output = %q, must not read as an unresolved cross-file link to itself", out) + } +} + +func TestRunSelfReferenceWithBadFragmentDoesNotClaimAnchorResolved(t *testing.T) { + dir := t.TempDir() + write(t, filepath.Join(dir, "draft.md"), "# Draft\n\n[bad self ref](draft.md#does-not-exist)\n") + + out, err := captureOutput(t, func() error { return run(testCmd(t, ""), []string{filepath.Join(dir, "draft.md")}) }) + if err != nil { + t.Fatalf("run: %v (warnings alone must not fail)", err) + } + if !strings.Contains(out, "anchor not found: draft.md#does-not-exist") { + t.Errorf("output = %q, want the anchor-not-found warning", out) + } + if strings.Contains(out, "same-page anchor not resolved") { + t.Errorf("output = %q, must not claim the anchor resolved when it didn't", out) + } +} + func TestRunBroken(t *testing.T) { dir := t.TempDir() write(t, filepath.Join(dir, "main.md"), "# Main\n\n![missing](nope.png)\n") diff --git a/internal/convert/links.go b/internal/convert/links.go index 875a1a9..d5f413e 100644 --- a/internal/convert/links.go +++ b/internal/convert/links.go @@ -66,6 +66,7 @@ func (r *storageRenderer) rewriteHref( href string, node ast.Node, source []byte, ) (newHref string, rewritten bool, brokenText string) { prefix := r.linePrefix(node, source) + original := href // as authored, before any same-page/anchor rewrite -- see rewriteDocLink's self-reference case. if strings.HasPrefix(href, "#") { if nf, ok := r.index.Anchor(r.currentDocKey, decodeDestination(href[1:])); ok { // Same-page anchors become fake cross-file links to the current @@ -96,7 +97,7 @@ func (r *storageRenderer) rewriteHref( } } - newHref, ok, brokenText := r.rewriteDocLink(href, prefix) + newHref, ok, brokenText := r.rewriteDocLink(href, original, rewritten, prefix) if brokenText != "" { return "", false, brokenText } @@ -118,7 +119,20 @@ func (r *storageRenderer) rewriteHref( // entirely" apart from "not published yet": both look identical to // index.Page (a miss), but only the first is a defect. prefix is rewriteHref's // already-computed line prefix, threaded through rather than recomputed. -func (r *storageRenderer) rewriteDocLink(href, prefix string) (newHref string, ok bool, brokenText string) { +// original is href as the author wrote it, before rewriteHref's same-page +// anchor branch turned it into a fake cross-file link to the current file -- +// needed only to word the self-reference case of the "not published yet" +// warning below without naming this file as if it were some other target. +// anchorResolved is rewriteHref's rewritten flag: whether the fragment was +// actually matched to a heading before this call, which is what tells a +// genuine same-page anchor (fully resolved, just pending this page's own +// publish) apart from a self-referencing href whose fragment never matched +// anything -- that one already got its own "anchor not found" warning +// upstream, and must fall through to the generic message below rather than +// claim a resolution that didn't happen. +func (r *storageRenderer) rewriteDocLink( + href, original string, anchorResolved bool, prefix string, +) (newHref string, ok bool, brokenText string) { path, fragment := href, "" if i := strings.Index(href, "#"); i >= 0 { path, fragment = href[:i], href[i:] @@ -137,6 +151,17 @@ func (r *storageRenderer) rewriteDocLink(href, prefix string) (newHref string, o return "", false, r.reportLinkBroken(prefix, href, "outside the documentation root") case !r.index.FileExists(key): return "", false, r.reportLinkBroken(prefix, href, "not found") + case key == r.currentDocKey && anchorResolved: + // The target is this file itself -- a same-page anchor (or an + // explicit self-referencing "thisfile.md#frag") that resolved to a + // real heading but can't be fully qualified until this page has + // its own page_id. "link not resolved: thisfile.md#Frag" would + // read as if some other file were the broken reference; it isn't. + // A self-reference whose fragment never matched anything falls + // through to the default case instead: anchorResolved is false, and + // "anchor not found" already covered it upstream. + r.warnings = append(r.warnings, prefix+fmt.Sprintf("same-page anchor not resolved: %s", original)) + return "", false, "" default: // The file exists but has no page_id yet -- the normal state of // every page in a tree that hasn't been published, not an error. diff --git a/internal/convert/testdata/regression/same-page-anchor-unpublished/main.md b/internal/convert/testdata/regression/same-page-anchor-unpublished/main.md new file mode 100644 index 0000000..8e009cb --- /dev/null +++ b/internal/convert/testdata/regression/same-page-anchor-unpublished/main.md @@ -0,0 +1,16 @@ +# Same Page Anchor Unpublished + +This file has no `page_id` yet, so a same-page anchor resolves to a real +heading but can't be turned into an absolute URL -- a Warning distinct from +the "link not resolved" one a genuinely unpublished sibling produces (#118), +since the target here is this file itself. + +Jump to [Getting Started](#getting-started). + +A self-reference whose fragment matches no heading at all is a different +case -- it must not claim the anchor resolved, since it didn't: +[bad self anchor](main.md#does-not-exist). + +## Getting Started + +Some getting-started content. diff --git a/internal/convert/testdata/regression/same-page-anchor-unpublished/test.output b/internal/convert/testdata/regression/same-page-anchor-unpublished/test.output new file mode 100644 index 0000000..05a369d --- /dev/null +++ b/internal/convert/testdata/regression/same-page-anchor-unpublished/test.output @@ -0,0 +1,10 @@ +{ + "attachments": [], + "broken": [], + "html": "

Same Page Anchor Unpublished

\n

This file has no page_id yet, so a same-page anchor resolves to a real heading but can't be turned into an absolute URL -- a Warning distinct from the "link not resolved" one a genuinely unpublished sibling produces (#118), since the target here is this file itself.

\n

Jump to Getting Started.

\n

A self-reference whose fragment matches no heading at all is a different case -- it must not claim the anchor resolved, since it didn't: bad self anchor.

\n

Getting Started

\n

Some getting-started content.

\n", + "warnings": [ + "line 8: same-page anchor not resolved: #getting-started", + "line 12: anchor not found: main.md#does-not-exist", + "line 12: link not resolved: main.md#does-not-exist" + ] +}