Skip to content

Commit 681a3e0

Browse files
NetdocsCopilot
andcommitted
Resolve icon shortcodes to inline SVG + style grid cards
Adds mkdocs-material icon shortcode support so :material-*:, :octicons-*: and :fontawesome-*: resolve to inline SVG instead of rendering as literal text. The icon set (Material Design Icons, Octicons, FontAwesome Free - ~10k icons) ships as a gzipped JSON embedded resource, decompressed once on first use via IconRegistry. TwemojiExtension gains an IconInline parser and IconRenderer that carry attr_list classes (e.g. { .lg .middle }). Also styles `.grid.cards` (md_in_html card grids) and `.twemoji` icon sizing so grid-card landing pages render correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f01033c commit 681a3e0

6 files changed

Lines changed: 245 additions & 14 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
using System.IO.Compression;
2+
using System.Reflection;
3+
using System.Text.Json;
4+
5+
namespace Netdocs.Core.Markdown.Emoji;
6+
7+
/// <summary>
8+
/// Resolves mkdocs-material icon shortcodes (<c>:material-*:</c>, <c>:octicons-*:</c>,
9+
/// <c>:fontawesome-*:</c>) to inline SVG. The icon set (Material Design Icons, Octicons,
10+
/// FontAwesome Free) is bundled as a gzipped JSON embedded resource and decompressed
11+
/// once on first use.
12+
/// </summary>
13+
public static class IconRegistry
14+
{
15+
public sealed record Icon(string Path, string ViewBox);
16+
17+
private static readonly Lazy<IReadOnlyDictionary<string, Icon>> Icons = new(Load);
18+
19+
/// <summary>Number of icons available (for diagnostics/tests).</summary>
20+
public static int Count => Icons.Value.Count;
21+
22+
/// <summary>Look up an icon by shortcode name (without the surrounding colons),
23+
/// e.g. <c>material-application</c> or <c>octicons-arrow-right-24</c>.</summary>
24+
public static Icon? Get(string name) =>
25+
Icons.Value.TryGetValue(name, out var icon) ? icon : null;
26+
27+
/// <summary>True when the name looks like an icon shortcode we might resolve.</summary>
28+
public static bool IsIconName(string name) =>
29+
name.StartsWith("material-", StringComparison.Ordinal)
30+
|| name.StartsWith("octicons-", StringComparison.Ordinal)
31+
|| name.StartsWith("fontawesome-", StringComparison.Ordinal)
32+
|| name.StartsWith("simple-", StringComparison.Ordinal);
33+
34+
/// <summary>Render an icon as an inline SVG string, or null when unknown.</summary>
35+
public static string? RenderSvg(string name)
36+
{
37+
var icon = Get(name);
38+
if (icon is null) return null;
39+
return $"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"{icon.ViewBox}\"><path d=\"{icon.Path}\"></path></svg>";
40+
}
41+
42+
private static IReadOnlyDictionary<string, Icon> Load()
43+
{
44+
var assembly = typeof(IconRegistry).Assembly;
45+
using var stream = assembly.GetManifestResourceStream("icons/icons.json.gz");
46+
if (stream is null)
47+
return new Dictionary<string, Icon>();
48+
49+
using var gz = new GZipStream(stream, CompressionMode.Decompress);
50+
using var doc = JsonDocument.Parse(gz);
51+
52+
var map = new Dictionary<string, Icon>(StringComparer.Ordinal);
53+
foreach (var prop in doc.RootElement.EnumerateObject())
54+
{
55+
var p = prop.Value.GetProperty("p").GetString() ?? "";
56+
var v = prop.Value.GetProperty("v").GetString() ?? "0 0 24 24";
57+
map[prop.Name] = new Icon(p, v);
58+
}
59+
return map;
60+
}
61+
}

src/Netdocs.Core/Markdown/Emoji/TwemojiExtension.cs

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,19 @@ public sealed class TwemojiInline : LeafInline
1717
public required string Shortcode { get; init; }
1818
}
1919

20+
/// <summary>Inline node for an icon shortcode (<c>:material-*:</c> etc.) resolved to inline SVG.</summary>
21+
public sealed class IconInline : LeafInline
22+
{
23+
public required string Name { get; init; }
24+
public required string Svg { get; init; }
25+
}
26+
2027
/// <summary>
2128
/// Parses <c>:shortcode:</c> emoji using Markdig's built-in shortcode table and emits a
2229
/// <see cref="TwemojiInline"/> so it can be rendered as a Twemoji SVG image (mirrors
23-
/// mkdocs-material's <c>pymdownx.emoji</c> + twemoji generator).
30+
/// mkdocs-material's <c>pymdownx.emoji</c> + twemoji generator). Shortcodes that aren't
31+
/// emoji but resolve to a bundled icon (<c>:material-*:</c>, <c>:octicons-*:</c>,
32+
/// <c>:fontawesome-*:</c>) emit an <see cref="IconInline"/> with inline SVG.
2433
/// </summary>
2534
public sealed class TwemojiInlineParser : InlineParser
2635
{
@@ -48,19 +57,63 @@ public override bool Match(InlineProcessor processor, ref StringSlice slice)
4857
if (p > end || text[p] != ':') return false;
4958

5059
var shortcode = text.Substring(start, p - start + 1);
51-
if (!Map.TryGetValue(shortcode, out var unicode)) return false;
52-
5360
var startPosition = processor.GetSourcePosition(start, out var line, out var column);
54-
processor.Inline = new TwemojiInline
61+
62+
if (Map.TryGetValue(shortcode, out var unicode))
5563
{
56-
Unicode = unicode,
57-
Shortcode = shortcode,
58-
Span = new(startPosition, processor.GetSourcePosition(p)),
59-
Line = line,
60-
Column = column,
61-
};
62-
slice.Start = p + 1;
63-
return true;
64+
processor.Inline = new TwemojiInline
65+
{
66+
Unicode = unicode,
67+
Shortcode = shortcode,
68+
Span = new(startPosition, processor.GetSourcePosition(p)),
69+
Line = line,
70+
Column = column,
71+
};
72+
slice.Start = p + 1;
73+
return true;
74+
}
75+
76+
// Not a Unicode emoji — try the bundled icon set (material/octicons/fontawesome).
77+
var name = shortcode.Trim(':');
78+
if (IconRegistry.IsIconName(name) && IconRegistry.RenderSvg(name) is { } svg)
79+
{
80+
processor.Inline = new IconInline
81+
{
82+
Name = name,
83+
Svg = svg,
84+
Span = new(startPosition, processor.GetSourcePosition(p)),
85+
Line = line,
86+
Column = column,
87+
};
88+
slice.Start = p + 1;
89+
return true;
90+
}
91+
92+
return false;
93+
}
94+
}
95+
96+
/// <summary>Renders an <see cref="IconInline"/> as an inline SVG wrapped in a
97+
/// <c>&lt;span class="twemoji"&gt;</c>, carrying any attr_list classes/attributes
98+
/// (e.g. <c>{ .lg .middle }</c>) so mkdocs-material icon usage renders identically.</summary>
99+
public sealed class IconRenderer : HtmlObjectRenderer<IconInline>
100+
{
101+
protected override void Write(HtmlRenderer renderer, IconInline obj)
102+
{
103+
if (!renderer.EnableHtmlForInline) return;
104+
105+
var attrs = obj.TryGetAttributes();
106+
renderer.Write("<span class=\"twemoji");
107+
if (attrs?.Classes is { Count: > 0 } classes)
108+
foreach (var c in classes)
109+
renderer.Write(' ').Write(c);
110+
renderer.Write('"');
111+
if (!string.IsNullOrEmpty(attrs?.Id))
112+
renderer.Write(" id=\"").Write(attrs!.Id).Write('"');
113+
if (attrs?.Properties is { Count: > 0 } props)
114+
foreach (var prop in props)
115+
renderer.Write(' ').Write(prop.Key).Write("=\"").Write(prop.Value ?? "").Write('"');
116+
renderer.Write('>').Write(obj.Svg).Write("</span>");
64117
}
65118
}
66119

@@ -119,7 +172,12 @@ public void Setup(MarkdownPipelineBuilder pipeline)
119172

120173
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
121174
{
122-
if (renderer is HtmlRenderer html && !html.ObjectRenderers.Contains<TwemojiRenderer>())
123-
html.ObjectRenderers.Insert(0, new TwemojiRenderer(_baseUrl));
175+
if (renderer is HtmlRenderer html)
176+
{
177+
if (!html.ObjectRenderers.Contains<TwemojiRenderer>())
178+
html.ObjectRenderers.Insert(0, new TwemojiRenderer(_baseUrl));
179+
if (!html.ObjectRenderers.Contains<IconRenderer>())
180+
html.ObjectRenderers.Insert(0, new IconRenderer());
181+
}
124182
}
125183
}
1.33 MB
Binary file not shown.

src/Netdocs.Core/Netdocs.Core.csproj

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,14 @@
2424
<LogicalName>theme/assets/%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
2525
</EmbeddedResource>
2626
</ItemGroup>
27+
<!--
28+
Bundled icon set (Material Design Icons, Octicons, FontAwesome Free) used to
29+
resolve `:material-*:` / `:octicons-*:` / `:fontawesome-*:` shortcodes to inline
30+
SVG, mirroring mkdocs-material. Stored gzipped (~1.3MB) and decompressed lazily.
31+
-->
32+
<ItemGroup>
33+
<EmbeddedResource Include="Markdown\Emoji\data\icons.json.gz">
34+
<LogicalName>icons/icons.json.gz</LogicalName>
35+
</EmbeddedResource>
36+
</ItemGroup>
2737
</Project>

src/Netdocs.Theme.Material/assets/netdocs.css

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,78 @@ body {
343343
}
344344

345345

346+
/* ---------------------------------------------------------------------------
347+
Grid cards (`<div class="grid cards" markdown>` + a bullet list, the
348+
mkdocs-material landing-page pattern). Lays the list items out as responsive
349+
cards and styles inline icons (`:material-*:{ .lg .middle }`).
350+
--------------------------------------------------------------------------- */
351+
.md-typeset .grid {
352+
display: grid;
353+
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
354+
gap: 0.8rem;
355+
margin: 1em 0;
356+
}
357+
358+
.md-typeset .grid.cards > ul {
359+
display: contents;
360+
}
361+
362+
.md-typeset .grid.cards > ul > li,
363+
.md-typeset .grid > .card {
364+
margin: 0;
365+
padding: 0.8rem;
366+
border: 0.05rem solid var(--md-default-fg-color--lightest);
367+
border-radius: 0.2rem;
368+
box-shadow: var(--nd-code-shadow);
369+
transition: border-color 0.25s, box-shadow 0.25s;
370+
}
371+
372+
.md-typeset .grid.cards > ul > li:hover,
373+
.md-typeset .grid > .card:hover {
374+
border-color: var(--md-accent-fg-color);
375+
box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.1);
376+
}
377+
378+
.md-typeset .grid.cards > ul > li > hr {
379+
margin: 0.6rem 0;
380+
}
381+
382+
.md-typeset .grid.cards > ul > li > :first-child {
383+
margin-top: 0;
384+
}
385+
386+
.md-typeset .grid.cards > ul > li > :last-child {
387+
margin-bottom: 0;
388+
}
389+
390+
/* Inline icons emitted from `:material-*:` / `:octicons-*:` / `:fontawesome-*:`. */
391+
.md-typeset .twemoji {
392+
display: inline-flex;
393+
height: 1.125em;
394+
vertical-align: text-top;
395+
}
396+
397+
.md-typeset .twemoji svg {
398+
width: 1.125em;
399+
height: 1.125em;
400+
fill: currentColor;
401+
}
402+
403+
.md-typeset .twemoji.lg svg {
404+
width: 2.2em;
405+
height: 2.2em;
406+
}
407+
408+
.md-typeset .twemoji.xl svg {
409+
width: 3em;
410+
height: 3em;
411+
}
412+
413+
.md-typeset .twemoji.middle {
414+
vertical-align: middle;
415+
}
416+
417+
346418
/* ---------------------------------------------------------------------------
347419
Calculator plugin (```calc fences -> interactive forms). Styles the input
348420
grid and the computed-output cards. Uses Material CSS custom properties so

tests/Netdocs.Core.Tests/CoreTests.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,36 @@ public void Emoji_RendersTwemojiImage()
120120
Assert.Contains("title=\":smile:\"", html);
121121
}
122122

123+
[Fact]
124+
public void Icons_RegistryLoadsBundledSet()
125+
{
126+
// The embedded icon dataset should decompress and expose thousands of icons.
127+
Assert.True(Netdocs.Core.Markdown.Emoji.IconRegistry.Count > 5000);
128+
Assert.NotNull(Netdocs.Core.Markdown.Emoji.IconRegistry.Get("material-home"));
129+
var svg = Netdocs.Core.Markdown.Emoji.IconRegistry.RenderSvg("material-home");
130+
Assert.NotNull(svg);
131+
Assert.Contains("<svg", svg);
132+
Assert.Contains("<path", svg);
133+
}
134+
135+
[Fact]
136+
public void Icons_MaterialShortcodeRendersInlineSvg()
137+
{
138+
var html = Render("Launch :material-home: now\n");
139+
Assert.Contains("class=\"twemoji", html);
140+
Assert.Contains("<svg", html);
141+
Assert.DoesNotContain(":material-home:", html); // shortcode fully resolved
142+
}
143+
144+
[Fact]
145+
public void Icons_ShortcodeCarriesAttrListClasses()
146+
{
147+
var html = Render("Big :material-home:{ .lg .middle } icon\n");
148+
Assert.Contains("<svg", html);
149+
Assert.Contains("lg", html);
150+
Assert.Contains("middle", html);
151+
}
152+
123153
[Fact]
124154
public void Emoji_ZwjSequenceKeepsVariationSelector()
125155
{

0 commit comments

Comments
 (0)