Skip to content

Commit 901307f

Browse files
NetdocsCopilot
andcommitted
feat(link-notes): support note_snippet for reusable affiliate notes
Rules can now source their note from a Markdown snippet via `note_snippet` (legacy alias `disclosure_snippet`) instead of duplicating the text inline in config. Paths resolve against the project root and docs dir plus their conventional `snippets` subdirectories. When the snippet is a single admonition (the typical pretty affiliate box), the plugin extracts its title (used as the standalone fallback box label unless a `label` is set) and kind, and uses its de-indented body as the note. An admonition cannot render inside a footnote definition, so the body is used directly for the tooltip/footer note while the box is reproduced for table-only links. Also fixes multi-line footnote definitions to indent continuation lines so multi-paragraph notes stay within the footnote. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a99cf06 commit 901307f

3 files changed

Lines changed: 243 additions & 16 deletions

File tree

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

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,44 @@ page's footnote list.
5151
posts readable and avoids per-link markup such as
5252
`--8<-- "ebay.html" text="..." url="..."`, which is noisier and easy to get wrong.
5353

54+
## Reusing a snippet for the note
55+
56+
Instead of typing the note text inline in your config, a rule can point at a Markdown
57+
**snippet file** with `note_snippet`. This keeps a single source of truth for a disclosure
58+
that you may also include manually elsewhere (via [snippets](snippets.md)), so the wording
59+
stays consistent everywhere:
60+
61+
```json
62+
{
63+
"name": "ebay",
64+
"domains": [ "ebay.us" ],
65+
"note_snippet": "snippets/ebay-affiliate.md"
66+
}
67+
```
68+
69+
The path is resolved against the project root and the `docs/` directory, each with a
70+
conventional `snippets` subdirectory — so `snippets/ebay-affiliate.md`,
71+
`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).
74+
75+
When the snippet is a single **admonition** — the usual pretty affiliate box:
76+
77+
```markdown
78+
!!! info "E-Bay Affiliate Links Used"
79+
This post **DOES** include eBay affiliate links. ...
80+
81+
You will pay the same amount as normal ...
82+
```
83+
84+
…the plugin is admonition-aware:
85+
86+
- its **title** (`E-Bay Affiliate Links Used`) becomes the standalone fallback box's header
87+
(unless the rule sets an explicit `label`), and its admonition **kind** (`info`) is reused;
88+
- its **body** becomes the tooltip / footer-note text. (An admonition can't render *inside*
89+
a footnote, so the body is used directly there; the pretty box is reproduced for
90+
table-only links via the standalone admonition.)
91+
5492
## How it works
5593

5694
The plugin runs as a Markdown preprocessor (order `30`, after
@@ -133,13 +171,17 @@ Each **rule** object:
133171
| Field | Type | Required | Description |
134172
|---|---|---|---|
135173
| `name` | string | yes | Rule id; used to build the footnote label (`linknote-<name>`). |
136-
| `note` | string | yes | Markdown shown as the tooltip and footer note. Legacy alias: `disclosure`. |
137-
| `domains` | array | no* | Hosts that identify the link. Each entry is a domain string or `{ "domain": "...", "query_contains": "..." }`. Subdomains match automatically. |
138-
| `patterns` | array | no* | Regular expressions matched (case-insensitively) against the full URL. |
174+
| `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`. |
176+
| `domains` | array | no† | Hosts that identify the link. Each entry is a domain string or `{ "domain": "...", "query_contains": "..." }`. Subdomains match automatically. |
177+
| `patterns` | array | no† | Regular expressions matched (case-insensitively) against the full URL. |
139178
| `query_contains` | string | no | Default substring a matching URL must contain (per-domain values override this). |
140-
| `label` | string | no | Title for the standalone fallback admonition (table-only links). Default `Links`. |
179+
| `label` | string | no | Title for the standalone fallback admonition (table-only links). Defaults to the snippet's admonition title, else `Links`. |
180+
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).
141183

142-
\* A rule must provide at least one of `domains` or `patterns`.
184+
A rule must provide at least one of `domains` or `patterns`.
143185

144186
!!! tip
145187
Enable [`content.footnote.tooltips`](../reference/theme.md) in your theme `features`

src/Netdocs.Plugins/LinkNotesPlugin.cs

Lines changed: 95 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,13 @@ namespace Netdocs.Plugins;
2525
/// still accepted.
2626
/// </para>
2727
/// </summary>
28-
public sealed class LinkNotesPlugin : IPlugin, IMarkdownPreprocessor
28+
public sealed partial class LinkNotesPlugin : IPlugin, IMarkdownPreprocessor
2929
{
3030
private sealed record DomainRule(string Domain, string? QueryContains);
31-
private sealed record Rule(string Id, DomainRule[] Domains, Regex[] Patterns, string Note, string Label);
31+
private sealed record Rule(string Id, DomainRule[] Domains, Regex[] Patterns, string Note, string Label, string Kind);
3232

3333
private readonly List<Rule> _rules = [];
34+
private readonly List<string> _snippetBasePaths = [];
3435
private ILogger? _log;
3536

3637
public string Name => "link-notes";
@@ -49,6 +50,16 @@ public void Configure(IPluginContext ctx)
4950
{
5051
_log = ctx.Logger;
5152

53+
// Snippet search roots (mirrors SnippetsPlugin): the project root and docs dir, each with a
54+
// conventional `snippets` subdirectory. A `note_snippet` path is resolved against these so a
55+
// rule can reuse the same admonition snippet included elsewhere on the site.
56+
var root = ctx.Config.ProjectRoot ?? "";
57+
void AddBase(string p) { if (p.Length > 0 && !_snippetBasePaths.Contains(p)) _snippetBasePaths.Add(p); }
58+
AddBase(Path.GetFullPath(root.Length == 0 ? "." : root));
59+
AddBase(Path.GetFullPath(Path.Combine(root, "snippets")));
60+
AddBase(Path.GetFullPath(ctx.Config.AbsoluteDocsDir));
61+
AddBase(Path.GetFullPath(Path.Combine(ctx.Config.AbsoluteDocsDir, "snippets")));
62+
5263
// Accept the new `rules` key; fall back to the legacy `programs` key (affiliate-links).
5364
if (!ctx.PluginOptions.TryGetValue("rules", out var raw) || raw is not IEnumerable<object?>)
5465
ctx.PluginOptions.TryGetValue("programs", out raw);
@@ -60,10 +71,11 @@ public void Configure(IPluginContext ctx)
6071
if (item is not IReadOnlyDictionary<string, object?> map) continue;
6172

6273
var id = map.TryGetValue("name", out var n) ? n?.ToString() : null;
63-
// `note` (new) or `disclosure` (legacy alias).
74+
if (string.IsNullOrWhiteSpace(id)) continue;
75+
76+
// `note` (new) or `disclosure` (legacy alias) supply the note markdown inline.
6477
var note = (map.TryGetValue("note", out var nt) ? nt?.ToString() : null)
6578
?? (map.TryGetValue("disclosure", out var d) ? d?.ToString() : null);
66-
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(note)) continue;
6779

6880
// Rule-level query marker is the default for any domain that doesn't override it.
6981
var defaultQuery = map.TryGetValue("query_contains", out var q) ? q?.ToString() : null;
@@ -77,19 +89,89 @@ public void Configure(IPluginContext ctx)
7789
continue;
7890
}
7991

80-
// Title used for the standalone fallback admonition (table-only links).
81-
var label = map.TryGetValue("label", out var lv) && !string.IsNullOrWhiteSpace(lv?.ToString())
92+
// Explicit `label` overrides everything; otherwise a snippet's admonition title is used.
93+
var explicitLabel = map.TryGetValue("label", out var lv) && !string.IsNullOrWhiteSpace(lv?.ToString())
8294
? lv!.ToString()!.Trim()
83-
: "Links";
95+
: null;
96+
97+
// `note_snippet` (or legacy `disclosure_snippet`) points at a markdown snippet whose
98+
// content becomes the note. When the snippet is a single admonition, its title/kind
99+
// drive the standalone fallback box and its body becomes the tooltip/footnote text.
100+
var snippetPath = (map.TryGetValue("note_snippet", out var ns) ? ns?.ToString() : null)
101+
?? (map.TryGetValue("disclosure_snippet", out var ds) ? ds?.ToString() : null);
102+
103+
var kind = "info";
104+
string? snippetTitle = null;
105+
if (!string.IsNullOrWhiteSpace(snippetPath))
106+
{
107+
var content = ReadSnippet(snippetPath!, id!);
108+
if (content is not null)
109+
{
110+
(kind, snippetTitle, note) = ExtractNote(content);
111+
}
112+
}
84113

85-
_rules.Add(new Rule(id!, domains, patterns, note!.Trim(), label));
114+
if (string.IsNullOrWhiteSpace(note))
115+
{
116+
_log.LogWarning("link-notes: rule '{Id}' has no note (inline or snippet); skipping", id);
117+
continue;
118+
}
119+
120+
var label = explicitLabel ?? snippetTitle ?? "Links";
121+
_rules.Add(new Rule(id!, domains, patterns, note!.Trim('\n'), label, kind));
86122
}
87123
}
88124

89125
if (_rules.Count == 0)
90126
_log.LogWarning("link-notes: no link rules configured; plugin is a no-op");
91127
}
92128

129+
// Resolves a snippet path against the configured base directories and returns its text, or null.
130+
private string? ReadSnippet(string path, string ruleId)
131+
{
132+
if (Path.IsPathRooted(path) && File.Exists(path)) return File.ReadAllText(path);
133+
foreach (var basePath in _snippetBasePaths)
134+
{
135+
var candidate = Path.GetFullPath(Path.Combine(basePath, path));
136+
if (File.Exists(candidate)) return File.ReadAllText(candidate);
137+
}
138+
_log?.LogWarning("link-notes: rule '{Id}' snippet '{Path}' not found; falling back to inline note", ruleId, path);
139+
return null;
140+
}
141+
142+
// Splits snippet content into (kind, title, body). If the snippet is a single admonition
143+
// (`!!! kind "Title"` with a 4-space indented body), the title/kind are returned and the body is
144+
// de-indented so it renders as plain paragraphs inside a footnote tooltip (an admonition cannot
145+
// render inside a footnote) while still driving the standalone fallback box. Otherwise the whole
146+
// trimmed content is treated as the body with kind "info" and no title.
147+
private static (string Kind, string? Title, string Body) ExtractNote(string content)
148+
{
149+
var text = content.Replace("\r\n", "\n").Trim('\n');
150+
var lines = text.Split('\n');
151+
var first = lines.Length > 0 ? lines[0] : "";
152+
var m = AdmonitionHeader().Match(first);
153+
if (!m.Success)
154+
return ("info", null, text.Trim());
155+
156+
var kind = m.Groups["kind"].Value.Trim();
157+
if (kind.Length == 0) kind = "info";
158+
var title = m.Groups["title"].Success ? m.Groups["title"].Value : null;
159+
160+
// De-indent the admonition body (strip one 4-space / tab indent from each line).
161+
var body = new StringBuilder();
162+
for (var i = 1; i < lines.Length; i++)
163+
{
164+
var line = lines[i];
165+
if (line.StartsWith(" ", StringComparison.Ordinal)) line = line[4..];
166+
else if (line.StartsWith("\t", StringComparison.Ordinal)) line = line[1..];
167+
body.Append(line).Append('\n');
168+
}
169+
return (kind, string.IsNullOrWhiteSpace(title) ? null : title.Trim(), body.ToString().Trim('\n'));
170+
}
171+
172+
[GeneratedRegex("""^(?:!!!|\?\?\?\+?)\s+(?<kind>[^"\n]*?)\s*(?:"(?<title>[^"]*)")?\s*$""")]
173+
private static partial Regex AdmonitionHeader();
174+
93175
public Task<string> ProcessAsync(Page page, string markdown, SiteContext site, CancellationToken ct)
94176
{
95177
if (_rules.Count == 0 || markdown.Length == 0) return Task.FromResult(markdown);
@@ -138,15 +220,17 @@ public Task<string> ProcessAsync(Page page, string markdown, SiteContext site, C
138220
sb.Append("\n\n");
139221

140222
// Footnote definitions for referenced rules: Markdig renders these at the bottom of the
141-
// page (the footer note) and links every reference to them (the hover tooltip).
223+
// page (the footer note) and links every reference to them (the hover tooltip). Continuation
224+
// lines are indented 4 spaces so multi-paragraph notes stay within the footnote definition.
142225
foreach (var rule in _rules.Where(r => referenced.Contains(r.Id)))
143-
sb.Append("[^linknote-").Append(rule.Id).Append("]: ").Append(rule.Note).Append('\n');
226+
sb.Append("[^linknote-").Append(rule.Id).Append("]: ")
227+
.Append(rule.Note.Replace("\n", "\n ", StringComparison.Ordinal)).Append('\n');
144228

145229
// Rules seen only inside tables get a standalone note admonition so the footer note
146230
// requirement is still met even though the individual links can't carry a tooltip.
147231
foreach (var rule in _rules.Where(r => tableOnly.Contains(r.Id)))
148232
{
149-
sb.Append("\n!!! info \"").Append(rule.Label).Append("\"\n ");
233+
sb.Append("\n!!! ").Append(rule.Kind).Append(" \"").Append(rule.Label).Append("\"\n ");
150234
sb.Append(rule.Note.Replace("\n", "\n ", StringComparison.Ordinal));
151235
sb.Append('\n');
152236
}

tests/Netdocs.Core.Tests/LinkNotesPluginTests.cs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ private static LinkNotesPlugin Configured(params Dictionary<string, object?>[] r
3030
return plugin;
3131
}
3232

33+
private static LinkNotesPlugin ConfiguredWithRoot(string projectRoot, params Dictionary<string, object?>[] rules)
34+
{
35+
var opts = new Dictionary<string, object?> { ["rules"] = rules.Cast<object?>().ToList() };
36+
var plugin = new LinkNotesPlugin();
37+
plugin.Configure(new FakeContext(opts) { Config = new SiteConfig { ProjectRoot = projectRoot } });
38+
return plugin;
39+
}
40+
3341
private static Dictionary<string, object?> Rule(string name, string[] domains, string note, string? query = null)
3442
{
3543
var d = new Dictionary<string, object?>
@@ -260,4 +268,97 @@ public void InvalidRegexPattern_IsSkippedNotThrown()
260268
var plugin = Configured(d);
261269
Assert.DoesNotContain("linknote", Run(plugin, "[a](https://ebay.us/x)"));
262270
}
271+
272+
[Fact]
273+
public void NoteSnippet_Admonition_UsesBodyForFootnoteAndTitleForFallback()
274+
{
275+
var dir = Path.Combine(Path.GetTempPath(), "netdocs-linknotes-" + Guid.NewGuid().ToString("N"));
276+
Directory.CreateDirectory(dir);
277+
try
278+
{
279+
var snippet =
280+
"!!! info \"E-Bay Affiliate Links Used\"\n" +
281+
" This post **DOES** include eBay affiliate links.\n\n" +
282+
" You pay the same price.\n";
283+
File.WriteAllText(Path.Combine(dir, "ebay-affiliate.md"), snippet);
284+
285+
var rule = new Dictionary<string, object?>
286+
{
287+
["name"] = "ebay",
288+
["domains"] = new List<object?> { "ebay.us" },
289+
["note_snippet"] = "ebay-affiliate.md",
290+
};
291+
var plugin = ConfiguredWithRoot(dir, rule);
292+
293+
// Inline link: footnote definition carries the de-indented body (NOT a nested admonition,
294+
// which can't render inside a footnote) so the tooltip shows the text.
295+
var inline = Run(plugin, "Buy on [eBay](https://ebay.us/abc).");
296+
Assert.Contains("[^linknote-ebay]", inline);
297+
Assert.Contains("This post **DOES** include eBay affiliate links.", inline);
298+
Assert.DoesNotContain("!!! info", inline);
299+
// Continuation lines are indented so the second paragraph stays in the footnote.
300+
Assert.Contains("\n You pay the same price.", inline);
301+
302+
// Table-only link: standalone admonition reuses the snippet's title as the box header.
303+
var table = Run(plugin, "| Item | Buy |\n| --- | --- |\n| Widget | [eBay](https://ebay.us/xyz) |");
304+
Assert.Contains("!!! info \"E-Bay Affiliate Links Used\"", table);
305+
Assert.Contains("This post **DOES** include eBay affiliate links.", table);
306+
}
307+
finally
308+
{
309+
Directory.Delete(dir, recursive: true);
310+
}
311+
}
312+
313+
[Fact]
314+
public void NoteSnippet_MissingFile_FallsBackToInlineNote()
315+
{
316+
var dir = Path.Combine(Path.GetTempPath(), "netdocs-linknotes-" + Guid.NewGuid().ToString("N"));
317+
Directory.CreateDirectory(dir);
318+
try
319+
{
320+
var rule = new Dictionary<string, object?>
321+
{
322+
["name"] = "ebay",
323+
["domains"] = new List<object?> { "ebay.us" },
324+
["note"] = "Inline fallback note.",
325+
["note_snippet"] = "does-not-exist.md",
326+
};
327+
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+
}
331+
finally
332+
{
333+
Directory.Delete(dir, recursive: true);
334+
}
335+
}
336+
337+
[Fact]
338+
public void NoteSnippet_ResolvesFromDocsSnippetsDir()
339+
{
340+
var dir = Path.Combine(Path.GetTempPath(), "netdocs-linknotes-" + Guid.NewGuid().ToString("N"));
341+
var snippetsDir = Path.Combine(dir, "docs", "snippets");
342+
Directory.CreateDirectory(snippetsDir);
343+
try
344+
{
345+
File.WriteAllText(Path.Combine(snippetsDir, "amazon-affiliate.md"),
346+
"!!! info \"Amazon Affiliate Links Used\"\n Amazon disclosure body.\n");
347+
348+
var rule = new Dictionary<string, object?>
349+
{
350+
["name"] = "amazon",
351+
["domains"] = new List<object?> { "amzn.to" },
352+
// Bare filename resolves via the conventional <docs>/snippets directory.
353+
["note_snippet"] = "amazon-affiliate.md",
354+
};
355+
var plugin = ConfiguredWithRoot(dir, rule);
356+
var result = Run(plugin, "Buy on [Amazon](https://amzn.to/abc).");
357+
Assert.Contains("[^linknote-amazon]: Amazon disclosure body.", result);
358+
}
359+
finally
360+
{
361+
Directory.Delete(dir, recursive: true);
362+
}
363+
}
263364
}

0 commit comments

Comments
 (0)