Skip to content

Commit 4d8adbb

Browse files
NetdocsCopilot
andcommitted
feat(link-notes): add tooltip mode via link_snippet templates
Add a `link_snippet` option that switches a rule into "tooltip mode": each matching link is replaced inline with the snippet rendered as a template (${url}/${text}/${domain} substituted, HTML-escaped), producing a pretty hover popup instead of a footnote. The rule's disclosure box is emitted once per page rather than as per-link footnotes. Because the replacement is inline HTML (not a footnote reference), tooltip mode also works inside pipe-table cells, so links generated from CSVs by the table-reader get the same popup. A referenced-but-missing link_snippet fails the build, matching note_snippet behavior. Footnote mode is unchanged and remains the default for rules without a link_snippet. Adds 4 tests (345 total pass) and documents the new mode + placeholders. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5602301 commit 4d8adbb

3 files changed

Lines changed: 301 additions & 37 deletions

File tree

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

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ without you having to hand-add a footnote to every post. It is a first-party Net
1919
**alias**, and the legacy `programs` / `disclosure` config keys are still accepted — no
2020
changes are required to existing configs.
2121

22+
## Two render modes: footnote or hover popup
23+
24+
Each rule renders its note in one of two ways:
25+
26+
- **Footnote mode** (default) — the link gets a small footnote marker; the note appears both as a
27+
hover tooltip (with Material's `content.footnote.tooltips`) and once in the page's footnote list.
28+
- **Tooltip mode** (`link_snippet` set) — each matching link is *replaced inline* with a snippet
29+
rendered as a template (a hover popup), and the rule's disclosure box is emitted **once per page**
30+
instead of per-link footnotes. See [Pretty hover popups](#pretty-hover-popups-tooltip-mode) below.
31+
2232
## A common use-case: affiliate disclosures
2333

2434
Attaching an affiliate-disclosure note to eBay Partner Network / tagged Amazon links means
@@ -89,6 +99,49 @@ When the snippet is a single **admonition** — the usual pretty affiliate box:
8999
a footnote, so the body is used directly there; the pretty box is reproduced for
90100
table-only links via the standalone admonition.)
91101

102+
## Pretty hover popups (tooltip mode)
103+
104+
Footnotes are great for a plain disclosure, but sometimes you want a *nicer* per-link popup — a
105+
styled card that appears on hover — instead of a superscript number and a footnote list. Point a
106+
rule at a **`link_snippet`** to switch it into tooltip mode:
107+
108+
```json
109+
{
110+
"name": "ebay",
111+
"domains": [ "ebay.us" ],
112+
"link_snippet": "snippets/ebay-link.html",
113+
"note_snippet": "snippets/ebay-affiliate.md"
114+
}
115+
```
116+
117+
In this mode, **every matching link is replaced inline** with `link_snippet` rendered as a template,
118+
and the `note_snippet` disclosure box is emitted **once at the bottom of the page** (no per-link
119+
footnotes at all). The snippet receives the matched link as template parameters, using the same
120+
`${key}` convention as parameterized [snippets](snippets.md) includes:
121+
122+
| Placeholder | Value |
123+
|---|---|
124+
| `${url}` | The matched link URL (HTML-escaped). |
125+
| `${text}` | The link's display text (HTML-escaped). |
126+
| `${domain}` | The URL host, e.g. `ebay.us` (HTML-escaped). |
127+
128+
A `link_snippet` is typically a small HTML fragment that wraps the link with a CSS-styled tooltip:
129+
130+
```html
131+
<span class="affiliate-wrapper"><a href="${url}" target="_blank" rel="nofollow sponsored noopener"
132+
class="affiliate-link">${text}</a><span class="affiliate-tooltip"><span class="tooltip-title">eBay
133+
Affiliate Link</span><span class="tooltip-content">This is an eBay affiliate link…</span></span></span>
134+
```
135+
136+
Because the replacement is inline HTML (not a footnote), tooltip mode also works **inside pipe-table
137+
cells** — where footnote references can't go — so links generated from CSVs by the
138+
[table-reader](table-reader.md) get the same pretty popup. A referenced-but-missing `link_snippet`
139+
**fails the build**, exactly like `note_snippet`.
140+
141+
!!! tip "Style it once"
142+
Put the tooltip CSS (`.affiliate-wrapper` / `.affiliate-tooltip` etc.) in your `extra_css` and
143+
reuse the same classes across every affiliate snippet so all popups look consistent.
144+
92145
## How it works
93146

94147
The plugin runs as a Markdown preprocessor (order `30`, after
@@ -173,14 +226,15 @@ Each **rule** object:
173226
| `name` | string | yes | Rule id; used to build the footnote label (`linknote-<name>`). |
174227
| `note` | string | yes* | Markdown shown as the tooltip and footer note. Legacy alias: `disclosure`. |
175228
| `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`. |
229+
| `link_snippet` | string | no | Path to an HTML/Markdown snippet template. When set the rule switches to **tooltip mode**: each matching link is replaced inline with this snippet rendered with `${url}`/`${text}`/`${domain}` substituted, and the `note`/`note_snippet` box is emitted once per page (no footnotes). A referenced-but-missing snippet **fails the build**. |
176230
| `domains` | array | no† | Hosts that identify the link. Each entry is a domain string or `{ "domain": "...", "query_contains": "..." }`. Subdomains match automatically. |
177231
| `patterns` | array | no† | Regular expressions matched (case-insensitively) against the full URL. |
178232
| `query_contains` | string | no | Default substring a matching URL must contain (per-domain values override this). |
179233
| `label` | string | no | Title for the standalone fallback admonition (table-only links). Defaults to the snippet's admonition title, else `Links`. |
180234

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.
235+
\* A rule must provide either a note (`note` or `note_snippet`) or a `link_snippet`. If `note_snippet`
236+
is set it takes precedence over `note`; any referenced snippet that cannot be found fails the build
237+
rather than falling back, so a mistyped path is caught instead of silently dropping the note.
184238

185239
† A rule must provide at least one of `domains` or `patterns`.
186240

src/Netdocs.Plugins/LinkNotesPlugin.cs

Lines changed: 121 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,22 @@
66
namespace Netdocs.Plugins;
77

88
/// <summary>
9-
/// Annotates outbound links that match configured rules with an automatic footnote
10-
/// reference. The footnote carries a note (arbitrary markdown), so:
9+
/// Annotates outbound links that match configured rules, so a note (arbitrary markdown) is
10+
/// attached to them automatically. Two render modes are supported per rule:
1111
/// <list type="bullet">
12-
/// <item>hovering the link shows the note as a tooltip (via
13-
/// <c>content.footnote.tooltips</c>), and</item>
14-
/// <item>the note is emitted once at the bottom of the page (the footnote list).</item>
12+
/// <item><b>Footnote mode</b> (default): the link gets a footnote reference whose definition is
13+
/// emitted once at the bottom of the page. With <c>content.footnote.tooltips</c> the note also
14+
/// shows on hover.</item>
15+
/// <item><b>Tooltip mode</b> (<c>link_snippet</c> set): each matching link is replaced inline
16+
/// with the snippet rendered as a template — the matched URL and link text are substituted for
17+
/// <c>${url}</c>/<c>${text}</c> — producing a hover popup, and the rule's disclosure box is
18+
/// emitted once per page instead of a footnote. Works inside pipe-table cells too.</item>
1519
/// </list>
1620
/// The plugin is generic and data-driven: each <em>rule</em> declares the domains
1721
/// (with an optional query marker) and/or regular expressions that identify its links,
18-
/// plus the note markdown. A common use-case is attaching affiliate-disclosure text to
19-
/// eBay Partner Network / tagged Amazon links (which also satisfies the once-per-page
20-
/// disclosure requirement automatically), but any note works.
22+
/// plus the note markdown and/or link snippet. A common use-case is attaching an
23+
/// affiliate-disclosure to eBay Partner Network / tagged Amazon links (which also satisfies the
24+
/// once-per-page disclosure requirement automatically), but any note works.
2125
/// <para>
2226
/// It runs after snippets/table-reader/macros so links injected by those plugins are
2327
/// covered. Registered as both <c>link-notes</c> and the legacy alias
@@ -28,7 +32,7 @@ namespace Netdocs.Plugins;
2832
public sealed partial class LinkNotesPlugin : IPlugin, IMarkdownPreprocessor
2933
{
3034
private sealed record DomainRule(string Domain, string? QueryContains);
31-
private sealed record Rule(string Id, DomainRule[] Domains, Regex[] Patterns, string Note, string Label, string Kind);
35+
private sealed record Rule(string Id, DomainRule[] Domains, Regex[] Patterns, string Note, string Label, string Kind, string? LinkSnippet);
3236

3337
private readonly List<Rule> _rules = [];
3438
private readonly List<string> _snippetBasePaths = [];
@@ -122,14 +126,36 @@ public void Configure(IPluginContext ctx)
122126
(kind, snippetTitle, note) = ExtractNote(content);
123127
}
124128

125-
if (string.IsNullOrWhiteSpace(note))
129+
// `link_snippet` opts the rule into *tooltip mode*: instead of appending a footnote,
130+
// every matching link is replaced inline with this snippet rendered as a template
131+
// (the matched URL and link text are substituted for `${url}`/`${text}`), producing a
132+
// hover popup. This is the pretty per-link affiliate popup; a referenced-but-missing
133+
// snippet fails the build for the same reason `note_snippet` does.
134+
var linkSnippetPath = map.TryGetValue("link_snippet", out var lsp) ? lsp?.ToString() : null;
135+
string? linkSnippet = null;
136+
if (!string.IsNullOrWhiteSpace(linkSnippetPath))
126137
{
127-
_log.LogWarning("link-notes: rule '{Id}' has no note (inline or snippet); skipping", id);
138+
linkSnippet = ReadSnippet(linkSnippetPath!);
139+
if (linkSnippet is null)
140+
{
141+
_configErrors.Add(
142+
$"rule '{id}' references link_snippet '{linkSnippetPath}' which was not found " +
143+
$"(searched: {string.Join(", ", _snippetBasePaths)})");
144+
continue;
145+
}
146+
linkSnippet = linkSnippet.Replace("\r\n", "\n").Trim('\n');
147+
}
148+
149+
// A rule needs *something* to attach: either a note (footnote / page box) or a
150+
// link_snippet (per-link popup). A rule with neither is a no-op.
151+
if (string.IsNullOrWhiteSpace(note) && linkSnippet is null)
152+
{
153+
_log.LogWarning("link-notes: rule '{Id}' has no note (inline or snippet) or link_snippet; skipping", id);
128154
continue;
129155
}
130156

131157
var label = explicitLabel ?? snippetTitle ?? "Links";
132-
_rules.Add(new Rule(id!, domains, patterns, note!.Trim('\n'), label, kind));
158+
_rules.Add(new Rule(id!, domains, patterns, note?.Trim('\n') ?? "", label, kind, linkSnippet));
133159
}
134160
}
135161

@@ -200,6 +226,9 @@ public Task<string> ProcessAsync(Page page, string markdown, SiteContext site, C
200226
// (pipe-table cells), which need a standalone note block appended instead.
201227
var referenced = new HashSet<string>();
202228
var tableOnly = new HashSet<string>();
229+
// Tooltip-mode rules (with a link_snippet) that matched at least one link on this page and so
230+
// need their disclosure box emitted once at the bottom.
231+
var boxed = new HashSet<string>();
203232

204233
var lines = markdown.Split('\n');
205234
var inFence = false;
@@ -214,26 +243,19 @@ public Task<string> ProcessAsync(Page page, string markdown, SiteContext site, C
214243
}
215244
if (inFence) continue;
216245

217-
// Markdig does not reliably parse footnote references inside pipe-table cells, so injecting
218-
// one there breaks the table. Detect matching links there (to guarantee the footer
219-
// note) but don't annotate the link.
220-
if (trimmed.StartsWith("|", StringComparison.Ordinal))
221-
{
222-
foreach (Match m in LinkRegex.Matches(lines[i]))
223-
{
224-
var rule = MatchRule(m.Groups["url"].Value);
225-
if (rule is not null && !m.Groups["existing"].Success) tableOnly.Add(rule.Id);
226-
}
227-
continue;
228-
}
229-
230-
lines[i] = LinkRegex.Replace(lines[i], m => AnnotateLink(m, referenced, tableOnly));
246+
// Inside a pipe-table cell a footnote reference breaks the table (Markdig can't parse it
247+
// there), but a tooltip-mode rule replaces the link with inline HTML, which is safe in a
248+
// cell — so table rows are still processed; AnnotateLink is told it is in a table so a
249+
// footnote-mode rule falls back to the standalone box instead of injecting a reference.
250+
var inTable = trimmed.StartsWith("|", StringComparison.Ordinal);
251+
lines[i] = LinkRegex.Replace(lines[i], m => AnnotateLink(m, referenced, tableOnly, boxed, inTable));
231252
}
232253

233254
// A rule that got at least one inline reference doesn't also need a standalone block.
234255
tableOnly.ExceptWith(referenced);
235256

236-
if (referenced.Count == 0 && tableOnly.Count == 0) return Task.FromResult(markdown);
257+
if (referenced.Count == 0 && tableOnly.Count == 0 && boxed.Count == 0)
258+
return Task.FromResult(markdown);
237259

238260
var sb = new StringBuilder(string.Join('\n', lines));
239261
sb.Append("\n\n");
@@ -248,25 +270,54 @@ public Task<string> ProcessAsync(Page page, string markdown, SiteContext site, C
248270
// Rules seen only inside tables get a standalone note admonition so the footer note
249271
// requirement is still met even though the individual links can't carry a tooltip.
250272
foreach (var rule in _rules.Where(r => tableOnly.Contains(r.Id)))
251-
{
252-
sb.Append("\n!!! ").Append(rule.Kind).Append(" \"").Append(rule.Label).Append("\"\n ");
253-
sb.Append(rule.Note.Replace("\n", "\n ", StringComparison.Ordinal));
254-
sb.Append('\n');
255-
}
273+
AppendBox(sb, rule);
274+
275+
// Tooltip-mode rules emit their disclosure admonition once per page (the per-link popups
276+
// carry the hover text; this keeps a single always-visible disclosure without any footnotes).
277+
foreach (var rule in _rules.Where(r => boxed.Contains(r.Id) && r.Note.Length > 0))
278+
AppendBox(sb, rule);
256279

257280
return Task.FromResult(sb.ToString());
258281
}
259282

260-
private string AnnotateLink(Match m, HashSet<string> referenced, HashSet<string> fallback)
283+
// Appends a standalone `!!! kind "label"` admonition carrying a rule's note (indented body).
284+
private static void AppendBox(StringBuilder sb, Rule rule)
285+
{
286+
sb.Append("\n!!! ").Append(rule.Kind).Append(" \"").Append(rule.Label).Append("\"\n ");
287+
sb.Append(rule.Note.Replace("\n", "\n ", StringComparison.Ordinal));
288+
sb.Append('\n');
289+
}
290+
291+
private string AnnotateLink(Match m, HashSet<string> referenced, HashSet<string> fallback, HashSet<string> boxed, bool inTable)
261292
{
262293
var whole = m.Value;
263294
var rule = MatchRule(m.Groups["url"].Value);
264295
if (rule is null) return whole;
265296

266297
// A footnote reference already follows this link (e.g. a hand-authored `[^ebay]`); leave it
267-
// untouched so we don't produce duplicate references during content migration.
298+
// untouched so we don't produce duplicate references / double-wrap during content migration.
268299
if (m.Groups["existing"].Success) return whole;
269300

301+
// Tooltip mode: replace the whole link with the link_snippet rendered as a template, passing
302+
// the matched URL and link text. This works in table cells and next to adjacent links (it is
303+
// inline HTML, not a footnote), and the disclosure box is emitted once per page.
304+
if (rule.LinkSnippet is not null)
305+
{
306+
boxed.Add(rule.Id);
307+
var text = ExtractLinkText(m.Groups["link"].Value);
308+
var popup = RenderLinkSnippet(rule.LinkSnippet, m.Groups["url"].Value, text);
309+
// Preserve any trailing attr-list the author added (rare, but keep it after the popup).
310+
return popup + m.Groups["attr"].Value;
311+
}
312+
313+
// Footnote mode inside a pipe-table cell: a reference would break the table, so record the
314+
// rule for a standalone box instead of annotating the link.
315+
if (inTable)
316+
{
317+
fallback.Add(rule.Id);
318+
return whole;
319+
}
320+
270321
// Another link/reference is glued directly after this one (e.g. `[a](x)[b](y)`); a footnote
271322
// ref wedged between the `][` renders ambiguously, so skip it and rely on the fallback block.
272323
if (m.Groups["adjacent"].Success)
@@ -279,6 +330,42 @@ private string AnnotateLink(Match m, HashSet<string> referenced, HashSet<string>
279330
return whole + $"[^linknote-{rule.Id}]";
280331
}
281332

333+
// Extracts the display text of a markdown link `[text](url ...)` — everything between the first
334+
// `[` and its matching `]`. Unescapes `\]` back to `]`.
335+
private static string ExtractLinkText(string link)
336+
{
337+
var open = link.IndexOf('[');
338+
if (open < 0) return "";
339+
var i = open + 1;
340+
var sb = new StringBuilder();
341+
while (i < link.Length && link[i] != ']')
342+
{
343+
if (link[i] == '\\' && i + 1 < link.Length && link[i + 1] == ']') { sb.Append(']'); i += 2; continue; }
344+
sb.Append(link[i]);
345+
i++;
346+
}
347+
return sb.ToString();
348+
}
349+
350+
// Renders a link_snippet template by substituting the matched URL / link text (HTML-escaped) for
351+
// the `${url}`, `${text}` and `${domain}` placeholders — the same `${key}` convention the
352+
// snippets plugin uses for parameterized includes.
353+
private static string RenderLinkSnippet(string template, string url, string text)
354+
{
355+
var domain = Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri.Host : "";
356+
return template
357+
.Replace("${url}", HtmlEscape(url), StringComparison.Ordinal)
358+
.Replace("${text}", HtmlEscape(text), StringComparison.Ordinal)
359+
.Replace("${domain}", HtmlEscape(domain), StringComparison.Ordinal);
360+
}
361+
362+
private static string HtmlEscape(string s) => s
363+
.Replace("&", "&amp;", StringComparison.Ordinal)
364+
.Replace("\"", "&quot;", StringComparison.Ordinal)
365+
.Replace("<", "&lt;", StringComparison.Ordinal)
366+
.Replace(">", "&gt;", StringComparison.Ordinal)
367+
.Replace("'", "&#39;", StringComparison.Ordinal);
368+
282369
private Rule? MatchRule(string url)
283370
{
284371
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return null;

0 commit comments

Comments
 (0)