Skip to content

Commit 217319b

Browse files
NetdocsCopilot
andcommitted
feat(theme): add extra.footer_links for footer meta links
Render an optional row of text links (e.g. Disclaimers, Privacy, Terms) in the footer meta area beneath the copyright/attribution line, driven by extra.footer_links ([{ name, link }]). Internal paths are resolved against base_url so they work from any page depth; absolute URLs (http/https or //) are left as-is and open in a new tab. Adds .md-footer-links styling, docs, and tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 428b1a7 commit 217319b

4 files changed

Lines changed: 132 additions & 1 deletion

File tree

docs-site/docs/reference/theme.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ your `customDir` and it wins over the bundled version.
170170
| `partials/breadcrumbs.html` | Breadcrumb trail above the page title (`navigation.path`, driven by `breadcrumbs`). |
171171
| `partials/source-file.html` | "Last updated / Created" line under the article body (`show_source_meta`, non-post pages). |
172172
| `partials/toc.html` | Right-hand table of contents. |
173-
| `partials/footer.html` | Site footer: prev/next nav, copyright, social links. |
173+
| `partials/footer.html` | Site footer: prev/next nav, copyright, footer links, social links. |
174174
| `partials/post-meta.html` | Blog-post byline: author avatar/name, date, reading time, categories. |
175175
| `partials/blog-nav.html` | Blog sidebar: recent posts / categories / archive listing. |
176176
| `partials/scripts.html` | End-of-body scripts: Material `__config` blob, the vendored bundle, hashed site scripts, inline scripts, and the highlight/mermaid loaders. |
@@ -344,3 +344,27 @@ only the last path segment is used.
344344
Need one that isn't listed? Add it via [`extra.social_icons`](#custom-icons) — the override map
345345
feeds nav icons and [badges](../plugins/macros.md) too.
346346

347+
## Footer links
348+
349+
Add a row of text links to the footer meta area (below the copyright/attribution line) with
350+
`extra.footer_links`. Each entry has a `name` and a `link`. Use this for pages such as
351+
Disclaimers, Privacy, or Terms:
352+
353+
```json
354+
{
355+
"Netdocs": {
356+
"extra": {
357+
"footer_links": [
358+
{ "name": "Disclaimers", "link": "/pages/disclaimers/" },
359+
{ "name": "Privacy", "link": "/pages/privacy/" },
360+
{ "name": "Source", "link": "https://github.com/you/repo" }
361+
]
362+
}
363+
}
364+
}
365+
```
366+
367+
Links are resolved relative to the current page automatically: an internal path (e.g.
368+
`/pages/disclaimers/`) is prefixed with the correct `base_url` so it works from any depth,
369+
while absolute URLs (starting with `http` or `//`) are left as-is and open in a new tab.
370+

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,20 @@ body {
2828
fill: currentColor;
2929
}
3030

31+
/* Footer meta links (e.g. Disclaimers) rendered from `extra.footer_links`.
32+
Sit on their own line under the copyright/attribution text. */
33+
.md-footer-links {
34+
margin-top: 0.3rem;
35+
}
36+
37+
.md-footer-links__link {
38+
color: inherit;
39+
}
40+
41+
.md-footer-links__sep {
42+
opacity: 0.5;
43+
}
44+
3145
/* ----------------------------------------------------------------------------
3246
Header: logo + site title on the left; social links, palette toggle and
3347
search on the right. Top-level navigation lives in the separate `.md-tabs`

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@
3030
<div class="md-copyright">
3131
{{~ if config.copyright ~}}<div class="md-copyright__highlight">{{ config.copyright }}</div>{{~ end ~}}
3232
Built with Netdocs, a derivative of <a href="https://github.com/squidfunk/mkdocs-material" target="_blank" rel="noopener">MkDocs-Material</a>
33+
{{~ if extra.footer_links ~}}
34+
<nav class="md-footer-links" aria-label="Footer links">
35+
{{~ for l in extra.footer_links ~}}
36+
{{~ href = l.link ~}}
37+
{{~ external = (href | string.starts_with "http") || (href | string.starts_with "//") ~}}
38+
{{~ if !external ~}}{{~ href = base_url + (strip_slash href) ~}}{{~ end ~}}
39+
{{~ if !for.first ~}}<span class="md-footer-links__sep"> &middot; </span>{{~ end ~}}
40+
<a href="{{ href }}" class="md-footer-links__link"{{ if external }} target="_blank" rel="noopener"{{ end }}>{{ l.name }}</a>
41+
{{~ end ~}}
42+
</nav>
43+
{{~ end ~}}
3344
</div>
3445
{{~ if extra.social ~}}
3546
<div class="md-social">
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
using Netdocs.Core.Templating;
2+
using Xunit;
3+
4+
namespace Netdocs.Core.Tests;
5+
6+
/// <summary>
7+
/// Covers the <c>extra.footer_links</c> rendering logic used by the theme footer:
8+
/// internal paths are resolved against <c>base_url</c> (so they work from any page depth)
9+
/// while absolute URLs are left untouched and open in a new tab.
10+
/// </summary>
11+
public class FooterLinksTests : IDisposable
12+
{
13+
private readonly string _dir = Path.Combine(Path.GetTempPath(), "netdocs-footer-" + Guid.NewGuid().ToString("N"));
14+
15+
public FooterLinksTests()
16+
{
17+
Directory.CreateDirectory(_dir);
18+
// Mirrors the resolution logic in partials/footer.html.
19+
File.WriteAllText(Path.Combine(_dir, "t.html"),
20+
"{{~ for l in extra.footer_links ~}}" +
21+
"{{~ href = l.link ~}}" +
22+
"{{~ external = (href | string.starts_with \"http\") || (href | string.starts_with \"//\") ~}}" +
23+
"{{~ if !external ~}}{{~ href = base_url + (strip_slash href) ~}}{{~ end ~}}" +
24+
"<a href=\"{{ href }}\"{{ if external }} target=\"_blank\"{{ end }}>{{ l.name }}</a>" +
25+
"{{~ end ~}}");
26+
}
27+
28+
public void Dispose()
29+
{
30+
try { Directory.Delete(_dir, recursive: true); } catch (IOException) { }
31+
GC.SuppressFinalize(this);
32+
}
33+
34+
private string Render(string baseUrl, params (string name, string link)[] links)
35+
{
36+
var engine = new TemplateEngine([_dir]);
37+
var model = new Dictionary<string, object?>
38+
{
39+
["base_url"] = baseUrl,
40+
["extra"] = new Dictionary<string, object?>
41+
{
42+
["footer_links"] = links
43+
.Select(l => (object?)new Dictionary<string, object?> { ["name"] = l.name, ["link"] = l.link })
44+
.ToList(),
45+
},
46+
};
47+
return engine.Render("t.html", model);
48+
}
49+
50+
[Fact]
51+
public void InternalLinkIsPrefixedWithBaseUrl()
52+
{
53+
var html = Render("../../", ("Disclaimers", "/pages/disclaimers/"));
54+
Assert.Contains("<a href=\"../../pages/disclaimers/\">Disclaimers</a>", html);
55+
Assert.DoesNotContain("target=\"_blank\"", html);
56+
}
57+
58+
[Fact]
59+
public void InternalLinkAtRootHasNoPrefix()
60+
{
61+
var html = Render("", ("Disclaimers", "/pages/disclaimers/"));
62+
Assert.Contains("<a href=\"pages/disclaimers/\">Disclaimers</a>", html);
63+
}
64+
65+
[Theory]
66+
[InlineData("https://github.com/you/repo")]
67+
[InlineData("//cdn.example.com/x")]
68+
public void AbsoluteLinkIsLeftAsIsAndOpensInNewTab(string link)
69+
{
70+
var html = Render("../../", ("Source", link));
71+
Assert.Contains($"<a href=\"{link}\" target=\"_blank\">Source</a>", html);
72+
}
73+
74+
[Fact]
75+
public void RendersMultipleLinksInOrder()
76+
{
77+
var html = Render("../", ("Disclaimers", "/pages/disclaimers/"), ("Privacy", "/pages/privacy/"));
78+
var disc = html.IndexOf("Disclaimers", StringComparison.Ordinal);
79+
var priv = html.IndexOf("Privacy", StringComparison.Ordinal);
80+
Assert.True(disc >= 0 && priv > disc);
81+
}
82+
}

0 commit comments

Comments
 (0)