Skip to content

Commit 428b1a7

Browse files
NetdocsCopilot
andcommitted
feat(theme): friendly social link tooltips instead of raw icon names
Social links previously used the raw icon name (e.g. "fontawesome/solid/rss") as their hover tooltip, which is meaningless to visitors. Add a `social_label` template helper that derives a human-friendly label from an icon name — mapping well-known brands/terms to proper casing (rss -> "RSS Feed", x-twitter -> "X (Twitter)", globe -> "Website", ...) and title-casing the last path segment for anything else (some/cool-thing -> "Cool Thing"). The header and footer social partials now resolve each link's label as: an explicit `name` field on the entry (new, optional) first, otherwise the derived `social_label`. The resolved value is used for both `title` and `aria-label`, improving usability and accessibility. Adds tests for the curated map, title-case fallback, and blank input, and documents `name` / `social_label` under the theme reference. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6d32137 commit 428b1a7

5 files changed

Lines changed: 137 additions & 8 deletions

File tree

docs-site/docs/reference/theme.md

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -210,15 +210,17 @@ Material's Jinja partials map fairly directly onto Scriban. The common substitut
210210
| `{{ config.site_name }}` | `{{ config.site_name }}` (unchanged) |
211211

212212
Use the trim markers `{{~` / `~}}` to strip surrounding whitespace, and the theme helpers
213-
(`social_icon`, `strip_slash`, `base_url`) instead of Material's Jinja filters. For example,
214-
an override that renders the configured social links in the header:
213+
(`social_icon`, `social_label`, `strip_slash`, `base_url`) instead of Material's Jinja filters.
214+
For example, an override that renders the configured social links in the header:
215215

216216
```html
217217
{{~ if extra.social ~}}
218218
<div class="header-social">
219219
{{~ for link in extra.social ~}}
220+
{{~ label = link.name ~}}
221+
{{~ if !label || (label | string.strip) == "" ~}}{{~ label = social_label link.icon ~}}{{~ end ~}}
220222
<a href="{{ link.link }}" class="md-social__link md-header__button md-icon"
221-
title="{{ link.icon }}" target="_blank" rel="noopener">{{ social_icon link.icon }}</a>
223+
title="{{ label }}" aria-label="{{ label }}" target="_blank" rel="noopener">{{ social_icon link.icon }}</a>
222224
{{~ end ~}}
223225
</div>
224226
{{~ end ~}}
@@ -227,7 +229,9 @@ an override that renders the configured social links in the header:
227229
## Social links & icons
228230

229231
Configure the links shown in the header and footer under `extra.social`. Each entry has an
230-
`icon` name (Material/FontAwesome-style, e.g. `fontawesome/brands/github`) and a `link`:
232+
`icon` name (Material/FontAwesome-style, e.g. `fontawesome/brands/github`) and a `link`. You
233+
can optionally add a `name` to control the hover tooltip / `aria-label`; when omitted, a
234+
friendly label is derived automatically from the icon (see [Tooltips & labels](#tooltips--labels)):
231235

232236
```json
233237
{
@@ -236,7 +240,7 @@ Configure the links shown in the header and footer under `extra.social`. Each en
236240
"social": [
237241
{ "icon": "fontawesome/brands/github", "link": "https://github.com/you/repo" },
238242
{ "icon": "fontawesome/brands/mastodon", "link": "https://fosstodon.org/@you" },
239-
{ "icon": "material/rss", "link": "feed_rss_created.xml" }
243+
{ "icon": "material/rss", "link": "feed_rss_created.xml", "name": "Subscribe via RSS" }
240244
]
241245
}
242246
}
@@ -260,6 +264,23 @@ substring, so both the Material (`material/github`) and FontAwesome
260264
| `mail` / `email` / `envelope` | Email |
261265
| *(anything else)* | Globe (generic link) |
262266

267+
### Tooltips & labels
268+
269+
Each social link needs readable hover text for usability and accessibility. The theme resolves
270+
the label in this order:
271+
272+
1. An explicit `name` on the entry (e.g. `"name": "Subscribe via RSS"`) — always wins.
273+
2. Otherwise the `social_label` helper derives a friendly name from the icon: it takes the last
274+
path segment and maps well-known ones to proper casing — `fontawesome/solid/rss`**RSS
275+
Feed**, `fontawesome/brands/x-twitter`**X (Twitter)**, `fontawesome/brands/linkedin`
276+
**LinkedIn**, `fontawesome/solid/globe`**Website**, and so on.
277+
3. Anything unrecognised is title-cased from its last segment, so `simple/my-service` still
278+
reads as **My Service** rather than the raw icon path.
279+
280+
The resolved label is used for both the `title` (tooltip) and `aria-label`. Set an explicit
281+
`name` whenever the icon alone is ambiguous — for example a generic globe that actually points
282+
to your main website.
283+
263284
### Custom icons
264285

265286
Add or override glyphs without editing the theme by supplying `extra.social_icons` — a map

src/Netdocs.Core/Templating/ThemeTemplateLoader.cs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ public static void Register(ScriptObject globals, IDictionary<string, object?>?
4545

4646
var overrides = ExtractIconOverrides(model);
4747
globals.Import("social_icon", (string? name) => SocialIcon(name ?? "", overrides));
48+
globals.Import("social_label", static (string? name) => SocialLabel(name ?? ""));
4849
globals.Import("nav_icon", (string? name) => NavIcon(name ?? "", overrides));
4950

5051
var versioner = (model is not null && model.TryGetValue("asset_versioner", out var v) ? v as AssetVersioner : null)
@@ -101,6 +102,82 @@ private static string SocialIcon(string name, IReadOnlyDictionary<string, string
101102
private static string Svg(string path) =>
102103
$"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path d=\"{path}\"/></svg>";
103104

105+
/// <summary>
106+
/// Turns an icon name into a human-friendly label for tooltips / <c>aria-label</c>s
107+
/// (e.g. <c>fontawesome/solid/rss</c> → "RSS Feed", <c>fontawesome/brands/x-twitter</c> →
108+
/// "X (Twitter)"). Falls back to a title-cased version of the last path segment for anything
109+
/// not in the curated map, so unknown icons still read nicely (e.g. <c>some/cool-thing</c> →
110+
/// "Cool Thing"). Sites can always override this per-link via a <c>name</c> field in config.
111+
/// </summary>
112+
public static string SocialLabel(string name)
113+
{
114+
if (string.IsNullOrWhiteSpace(name)) return string.Empty;
115+
116+
// Use the last path segment (mkdocs-style names look like "fontawesome/brands/github").
117+
var seg = name.Trim().Trim('/');
118+
var slash = seg.LastIndexOf('/');
119+
if (slash >= 0) seg = seg[(slash + 1)..];
120+
var key = seg.ToLowerInvariant();
121+
122+
return key switch
123+
{
124+
"github" => "GitHub",
125+
"gitlab" => "GitLab",
126+
"bitbucket" => "Bitbucket",
127+
"discord" => "Discord",
128+
"slack" => "Slack",
129+
"reddit" => "Reddit",
130+
"rss" or "feed" or "square-rss" => "RSS Feed",
131+
"globe" or "link" or "house" or "home" or "web" or "earth" or "language" => "Website",
132+
"x-twitter" or "twitter" or "x" => "X (Twitter)",
133+
"mastodon" => "Mastodon",
134+
"bluesky" or "butterfly" => "Bluesky",
135+
"linkedin" or "linkedin-in" => "LinkedIn",
136+
"youtube" or "youtube-play" => "YouTube",
137+
"facebook" or "facebook-f" => "Facebook",
138+
"instagram" => "Instagram",
139+
"threads" => "Threads",
140+
"tiktok" => "TikTok",
141+
"twitch" => "Twitch",
142+
"telegram" => "Telegram",
143+
"whatsapp" => "WhatsApp",
144+
"signal" => "Signal",
145+
"matrix" or "matrix-org" => "Matrix",
146+
"envelope" or "email" or "mail" or "at" => "Email",
147+
"phone" => "Phone",
148+
"stack-overflow" or "stackoverflow" => "Stack Overflow",
149+
"hacker-news" or "hackernews" or "y-combinator" => "Hacker News",
150+
"docker" => "Docker",
151+
"kubernetes" => "Kubernetes",
152+
"npm" => "npm",
153+
"nuget" => "NuGet",
154+
"steam" => "Steam",
155+
"patreon" => "Patreon",
156+
"ko-fi" => "Ko-fi",
157+
"paypal" => "PayPal",
158+
"medium" => "Medium",
159+
"dev" or "dev-to" => "DEV Community",
160+
"keybase" => "Keybase",
161+
"spotify" => "Spotify",
162+
"soundcloud" => "SoundCloud",
163+
_ => TitleCase(key),
164+
};
165+
}
166+
167+
/// <summary>Title-cases a dash/underscore/space separated token: "cool-thing" → "Cool Thing".</summary>
168+
private static string TitleCase(string value)
169+
{
170+
var words = value.Split(['-', '_', ' '], StringSplitOptions.RemoveEmptyEntries);
171+
for (var i = 0; i < words.Length; i++)
172+
{
173+
var w = words[i];
174+
words[i] = w.Length == 1 ? char.ToUpperInvariant(w[0]).ToString()
175+
: char.ToUpperInvariant(w[0]) + w[1..];
176+
}
177+
return words.Length == 0 ? value : string.Join(' ', words);
178+
}
179+
180+
104181
/// <summary>
105182
/// Resolves a nav/badge icon to inline markup. Resolution order: custom <c>extra.social_icons</c>
106183
/// override → curated Material glyph (<see cref="MaterialIcons"/>) → brand glyph

src/Netdocs.Theme.Material/templates/partials/footer.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@
3434
{{~ if extra.social ~}}
3535
<div class="md-social">
3636
{{~ for link in extra.social ~}}
37-
<a href="{{ link.link }}" target="_blank" rel="noopener" title="{{ link.icon }}" class="md-social__link">{{ social_icon link.icon }}</a>
37+
{{~ label = link.name ~}}
38+
{{~ if !label || (label | string.strip) == "" ~}}{{~ label = social_label link.icon ~}}{{~ end ~}}
39+
<a href="{{ link.link }}" target="_blank" rel="noopener" title="{{ label }}" aria-label="{{ label }}" class="md-social__link">{{ social_icon link.icon }}</a>
3840
{{~ end ~}}
3941
</div>
4042
{{~ end ~}}
Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
{{~ # ---------------------------------------------------------------------------
22
# partials/social.html — Social links shown at the right of the header.
33
#
4-
# Data: `extra.social` in your config — a list of { icon, link } entries.
4+
# Data: `extra.social` in your config — a list of { icon, link, name? } entries.
55
# `social_icon <name>` resolves an icon name (e.g. "fontawesome/brands/github")
66
# to an inline SVG.
7+
# `social_label <name>` derives a friendly tooltip/label from the icon name
8+
# (e.g. "fontawesome/solid/rss" -> "RSS Feed"). Set an explicit `name` on the
9+
# entry to override the auto-derived label.
710
#
811
# Override: drop a file at `<custom_dir>/partials/social.html` to change how the
912
# social links render, without touching the header. Return nothing to hide them.
1013
# --------------------------------------------------------------------------- ~}}
1114
{{~ if extra.social ~}}
1215
<nav class="md-header__social" aria-label="Social">
1316
{{~ for link in extra.social ~}}
14-
<a href="{{ link.link }}" class="md-header__button md-icon" title="{{ link.icon }}" target="_blank" rel="noopener">{{ social_icon link.icon }}</a>
17+
{{~ label = link.name ~}}
18+
{{~ if !label || (label | string.strip) == "" ~}}{{~ label = social_label link.icon ~}}{{~ end ~}}
19+
<a href="{{ link.link }}" class="md-header__button md-icon" title="{{ label }}" aria-label="{{ label }}" target="_blank" rel="noopener">{{ social_icon link.icon }}</a>
1520
{{~ end ~}}
1621
</nav>
1722
{{~ end ~}}

tests/Netdocs.Core.Tests/SocialIconTests.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,28 @@ public void CustomIconOverrideIsUsed()
5959
var html = Render("fontawesome/brands/bluesky", extra);
6060
Assert.Contains("d=\"M1 2 3 4\"", html);
6161
}
62+
63+
[Theory]
64+
[InlineData("fontawesome/brands/github", "GitHub")]
65+
[InlineData("fontawesome/brands/discord", "Discord")]
66+
[InlineData("fontawesome/brands/reddit", "Reddit")]
67+
[InlineData("fontawesome/solid/rss", "RSS Feed")]
68+
[InlineData("fontawesome/solid/globe", "Website")]
69+
[InlineData("fontawesome/brands/x-twitter", "X (Twitter)")]
70+
[InlineData("fontawesome/brands/linkedin", "LinkedIn")]
71+
[InlineData("fontawesome/brands/youtube", "YouTube")]
72+
[InlineData("material/email", "Email")]
73+
public void SocialLabelMapsKnownIconsToFriendlyNames(string icon, string expected)
74+
=> Assert.Equal(expected, TemplateFunctions.SocialLabel(icon));
75+
76+
[Theory]
77+
[InlineData("some/cool-thing", "Cool Thing")]
78+
[InlineData("simple/my_service", "My Service")]
79+
[InlineData("bare-name", "Bare Name")]
80+
public void SocialLabelTitleCasesUnknownIcons(string icon, string expected)
81+
=> Assert.Equal(expected, TemplateFunctions.SocialLabel(icon));
82+
83+
[Fact]
84+
public void SocialLabelReturnsEmptyForBlank()
85+
=> Assert.Equal("", TemplateFunctions.SocialLabel(" "));
6286
}

0 commit comments

Comments
 (0)