Skip to content

Commit 5602301

Browse files
NetdocsCopilot
andcommitted
fix(link-notes): fail the build on a missing note_snippet
A note_snippet path that cannot be resolved was previously only a warning (fatal only under --strict) and fell back to the inline note or silently dropped the rule -- which for an affiliate disclosure means it could vanish with no signal. Record the unresolved reference and throw from ProcessAsync (outside PluginHost.Configure's try/catch) so the build fails with a clear "snippet not found" error and a non-zero exit even without --strict. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 901307f commit 5602301

3 files changed

Lines changed: 37 additions & 14 deletions

File tree

docs-site/docs/plugins/link-notes.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ stays consistent everywhere:
6969
The path is resolved against the project root and the `docs/` directory, each with a
7070
conventional `snippets` subdirectory — so `snippets/ebay-affiliate.md`,
7171
`docs/snippets/ebay-affiliate.md`, or a bare `ebay-affiliate.md` (found in `docs/snippets`)
72-
all work. If the snippet can't be found, the plugin logs a warning and falls back to the
73-
inline `note` (when one is set).
72+
all work. If a referenced snippet **cannot be found, the build fails** with a clear error
73+
(even without `--strict`) — a mistyped path can never silently drop an affiliate disclosure.
7474

7575
When the snippet is a single **admonition** — the usual pretty affiliate box:
7676

@@ -172,14 +172,15 @@ Each **rule** object:
172172
|---|---|---|---|
173173
| `name` | string | yes | Rule id; used to build the footnote label (`linknote-<name>`). |
174174
| `note` | string | yes* | Markdown shown as the tooltip and footer note. Legacy alias: `disclosure`. |
175-
| `note_snippet` | string | yes* | Path to a Markdown snippet whose content is used as the note (resolved against the project root / `docs` dir and their `snippets` subdirs). A single-admonition snippet contributes its title (→ `label`) and kind, and its body becomes the note. Legacy alias: `disclosure_snippet`. |
175+
| `note_snippet` | string | yes* | Path to a Markdown snippet whose content is used as the note (resolved against the project root / `docs` dir and their `snippets` subdirs). A single-admonition snippet contributes its title (→ `label`) and kind, and its body becomes the note. A referenced-but-missing snippet **fails the build**. Legacy alias: `disclosure_snippet`. |
176176
| `domains` | array | no† | Hosts that identify the link. Each entry is a domain string or `{ "domain": "...", "query_contains": "..." }`. Subdomains match automatically. |
177177
| `patterns` | array | no† | Regular expressions matched (case-insensitively) against the full URL. |
178178
| `query_contains` | string | no | Default substring a matching URL must contain (per-domain values override this). |
179179
| `label` | string | no | Title for the standalone fallback admonition (table-only links). Defaults to the snippet's admonition title, else `Links`. |
180180

181-
\* A rule must provide the note text via either `note` or `note_snippet` (if both are set and
182-
the snippet resolves, the snippet wins; otherwise `note` is the fallback).
181+
\* A rule must provide the note text via either `note` or `note_snippet`. If `note_snippet`
182+
is set it takes precedence; a referenced snippet that cannot be found fails the build rather
183+
than falling back, so a mistyped path is caught instead of silently dropping the note.
183184

184185
† A rule must provide at least one of `domains` or `patterns`.
185186

src/Netdocs.Plugins/LinkNotesPlugin.cs

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ private sealed record Rule(string Id, DomainRule[] Domains, Regex[] Patterns, st
3232

3333
private readonly List<Rule> _rules = [];
3434
private readonly List<string> _snippetBasePaths = [];
35+
private readonly List<string> _configErrors = [];
3536
private ILogger? _log;
3637

3738
public string Name => "link-notes";
@@ -104,11 +105,21 @@ public void Configure(IPluginContext ctx)
104105
string? snippetTitle = null;
105106
if (!string.IsNullOrWhiteSpace(snippetPath))
106107
{
107-
var content = ReadSnippet(snippetPath!, id!);
108-
if (content is not null)
108+
var content = ReadSnippet(snippetPath!);
109+
if (content is null)
109110
{
110-
(kind, snippetTitle, note) = ExtractNote(content);
111+
// An explicitly referenced snippet that cannot be found is a configuration
112+
// mistake (typically a typo in the path). Silently dropping the rule would
113+
// omit an affiliate disclosure without any signal, which is worse than a
114+
// failed build — so record it as a fatal error. It is thrown from
115+
// ProcessAsync (below) rather than here so it aborts the build even outside
116+
// `--strict` (plugin Configure exceptions are otherwise swallowed).
117+
_configErrors.Add(
118+
$"rule '{id}' references note_snippet '{snippetPath}' which was not found " +
119+
$"(searched: {string.Join(", ", _snippetBasePaths)})");
120+
continue;
111121
}
122+
(kind, snippetTitle, note) = ExtractNote(content);
112123
}
113124

114125
if (string.IsNullOrWhiteSpace(note))
@@ -126,16 +137,16 @@ public void Configure(IPluginContext ctx)
126137
_log.LogWarning("link-notes: no link rules configured; plugin is a no-op");
127138
}
128139

129-
// Resolves a snippet path against the configured base directories and returns its text, or null.
130-
private string? ReadSnippet(string path, string ruleId)
140+
// Resolves a snippet path against the configured base directories and returns its text, or null
141+
// when it cannot be found (the caller turns an unresolved explicit reference into a build error).
142+
private string? ReadSnippet(string path)
131143
{
132144
if (Path.IsPathRooted(path) && File.Exists(path)) return File.ReadAllText(path);
133145
foreach (var basePath in _snippetBasePaths)
134146
{
135147
var candidate = Path.GetFullPath(Path.Combine(basePath, path));
136148
if (File.Exists(candidate)) return File.ReadAllText(candidate);
137149
}
138-
_log?.LogWarning("link-notes: rule '{Id}' snippet '{Path}' not found; falling back to inline note", ruleId, path);
139150
return null;
140151
}
141152

@@ -174,6 +185,14 @@ private static (string Kind, string? Title, string Body) ExtractNote(string cont
174185

175186
public Task<string> ProcessAsync(Page page, string markdown, SiteContext site, CancellationToken ct)
176187
{
188+
// A referenced-but-missing note_snippet is a fatal configuration error. Throwing here (from
189+
// the build's preprocess loop, outside PluginHost.Configure's try/catch) aborts the build
190+
// with a non-zero exit even without `--strict`, so a mistyped snippet path can never
191+
// silently omit an affiliate disclosure.
192+
if (_configErrors.Count > 0)
193+
throw new FileNotFoundException(
194+
"link-notes: " + string.Join("; ", _configErrors));
195+
177196
if (_rules.Count == 0 || markdown.Length == 0) return Task.FromResult(markdown);
178197

179198
// Rules whose links got an inline footnote reference (definition will be rendered by the

tests/Netdocs.Core.Tests/LinkNotesPluginTests.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ public void NoteSnippet_Admonition_UsesBodyForFootnoteAndTitleForFallback()
311311
}
312312

313313
[Fact]
314-
public void NoteSnippet_MissingFile_FallsBackToInlineNote()
314+
public void NoteSnippet_MissingFile_FailsBuild()
315315
{
316316
var dir = Path.Combine(Path.GetTempPath(), "netdocs-linknotes-" + Guid.NewGuid().ToString("N"));
317317
Directory.CreateDirectory(dir);
@@ -321,12 +321,15 @@ public void NoteSnippet_MissingFile_FallsBackToInlineNote()
321321
{
322322
["name"] = "ebay",
323323
["domains"] = new List<object?> { "ebay.us" },
324+
// Even with an inline note present, a referenced-but-missing snippet is a fatal
325+
// configuration error rather than a silent fallback.
324326
["note"] = "Inline fallback note.",
325327
["note_snippet"] = "does-not-exist.md",
326328
};
327329
var plugin = ConfiguredWithRoot(dir, rule);
328-
var result = Run(plugin, "Buy on [eBay](https://ebay.us/abc).");
329-
Assert.Contains("[^linknote-ebay]: Inline fallback note.", result);
330+
var ex = Assert.Throws<FileNotFoundException>(
331+
() => Run(plugin, "Buy on [eBay](https://ebay.us/abc)."));
332+
Assert.Contains("does-not-exist.md", ex.Message);
330333
}
331334
finally
332335
{

0 commit comments

Comments
 (0)