Skip to content

Commit 45f5701

Browse files
NetdocsCopilot
andcommitted
feat(urls): add opt-in slugify for content page URLs
Content files previously mapped to URLs mirroring their filenames verbatim, so names with spaces produced percent-encoded paths (e.g. `Openshift Registry Setup/` -> `Openshift%20Registry%20Setup/`). Some static hosts (notably S3 + CloudFront) fail to resolve those `%20` paths and 404. Add a `slugify.urls` toggle (default off, mkdocs-compatible) that slugifies each content URL path segment using the existing `case`/`separator`/`ascii` settings. Internal `.md` links are rewritten to the slugified targets automatically via the existing link map (keyed on real paths, valued on URLs). Move the `Slug` helper from Netdocs.Plugins to Netdocs.Abstractions so the core content discovery can reuse it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b8645db commit 45f5701

7 files changed

Lines changed: 69 additions & 13 deletions

File tree

docs-site/docs/reference/configuration.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,36 @@ produced by a configurable slugifier. Add a `slugify` block to control it:
113113
| `case` | `lower` | Letter casing: `lower`, `upper`, or `none` (preserve). |
114114
| `separator` | `-` | String inserted between words (e.g. `_` or `.`). |
115115
| `ascii` | `false` | When `true`, drop non-ASCII letters/digits instead of keeping them. |
116+
| `urls` | `false` | When `true`, slugify **content page URLs** too (see below). |
116117

117118
With the defaults, `"Hello World!"` becomes `hello-world`. Set `"separator": "_"` to get
118119
`hello_world`, or `"case": "none"` to keep the original casing.
119120

121+
### Slugifying content URLs
122+
123+
By default a content file maps to a URL that mirrors its path exactly, so
124+
`Openshift Registry Setup.md` becomes `Openshift%20Registry%20Setup/`. Spaces (and other
125+
characters that must be percent-encoded) can trip up static hosts — for example an S3 +
126+
CloudFront site may fail to resolve `%20` paths and return `404`.
127+
128+
Set `"urls": true` to slugify every path segment of content URLs using the same `case`,
129+
`separator`, and `ascii` rules:
130+
131+
```json
132+
{
133+
"Netdocs": {
134+
"slugify": {
135+
"urls": true
136+
}
137+
}
138+
}
139+
```
140+
141+
With this enabled, `docs/cluster-setup/Openshift Registry Setup.md` is published at
142+
`cluster-setup/openshift-registry-setup/`. Internal `*.md` links are rewritten to the
143+
slugified targets automatically, so cross-page links keep working. Leaving `urls` off (the
144+
default) preserves mkdocs-compatible, filename-based URLs.
145+
120146
## Deployment
121147

122148
A `deploy` block tells `netdocs deploy` where to publish the built site. See

src/Netdocs.Abstractions/SiteConfig.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ public sealed class SiteConfig
4343
/// <summary>Controls how titles/ids are turned into URL slugs (blog, categories, authors, tags).</summary>
4444
public SlugifyConfig Slugify { get; set; } = new();
4545

46+
/// <summary>When true, slugify content file paths into their output URLs (e.g.
47+
/// <c>Openshift Registry Setup.md</c> → <c>openshift-registry-setup/</c>) using the
48+
/// <see cref="Slugify"/> settings. Off by default to keep URLs identical to source
49+
/// filenames (mkdocs-compatible). Turn on to guarantee clean, space-free URLs that
50+
/// static hosts (S3/CloudFront) can serve without percent-encoding issues. Internal
51+
/// <c>.md</c> links are rewritten to the slugified targets automatically.</summary>
52+
public bool SlugifyUrls { get; set; }
53+
4654
/// <summary>Behaviour of the abbreviations feature (<c>&lt;abbr&gt;</c> tooltips).</summary>
4755
public AbbreviationsConfig Abbreviations { get; set; } = new();
4856

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
using System.Globalization;
22
using System.Text;
3-
using Netdocs.Abstractions;
43

5-
namespace Netdocs.Plugins;
4+
namespace Netdocs.Abstractions;
65

76
/// <summary>Slugifies text for URLs. Default: lowercase, hyphen-separated, accents folded.</summary>
87
public static class Slug

src/Netdocs.Core/Configuration/JsonConfigLoader.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ public static SiteConfig Load(string appSettingsPath)
4141
Exclude = StringList(root.Get("exclude")),
4242
Extra = root.Get("extra").AsMap(),
4343
Slugify = ParseSlugify(root.Get("slugify").AsMap()),
44+
SlugifyUrls = root.Get("slugify").AsMap().Get("urls").AsBool(false),
4445
Deploy = ParseDeploy(root.Get("deploy").AsMap()),
4546
Optimize = ParseOptimize(root.Get("optimize").AsMap()),
4647
Validation = ParseValidation(root.Get("validation").AsMap()),

src/Netdocs.Core/Content/ContentDiscovery.cs

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ private Page LoadPage(string absolutePath, string relative)
4646
var raw = File.ReadAllText(absolutePath);
4747
var (meta, body) = FrontMatter.Split(raw);
4848

49-
var url = UrlFor(relative);
49+
var url = UrlFor(relative, config.SlugifyUrls ? config.Slugify : null);
5050
var page = new Page
5151
{
5252
SourcePath = absolutePath,
@@ -70,18 +70,24 @@ private Page LoadPage(string absolutePath, string relative)
7070
return page;
7171
}
7272

73-
/// <summary>Directory-style URLs: foo/bar.md -> foo/bar/ ; index.md -> its dir.</summary>
74-
public static string UrlFor(string relative)
73+
/// <summary>Directory-style URLs: foo/bar.md -> foo/bar/ ; index.md -> its dir.
74+
/// When <paramref name="slugify"/> is supplied, each path segment is slugified
75+
/// (e.g. <c>Openshift Registry Setup.md</c> -> <c>openshift-registry-setup/</c>) so the
76+
/// resulting URL is free of spaces and other characters that static hosts must
77+
/// percent-encode.</summary>
78+
public static string UrlFor(string relative, SlugifyConfig? slugify = null)
7579
{
7680
var withoutExt = relative[..^Path.GetExtension(relative).Length];
7781
var segments = withoutExt.Split('/');
78-
if (segments[^1].Equals("index", StringComparison.OrdinalIgnoreCase)
79-
|| segments[^1].Equals("README", StringComparison.OrdinalIgnoreCase))
80-
{
81-
var dir = string.Join('/', segments[..^1]);
82-
return dir.Length == 0 ? "" : dir + "/";
83-
}
84-
return withoutExt + "/";
82+
var isIndex = segments[^1].Equals("index", StringComparison.OrdinalIgnoreCase)
83+
|| segments[^1].Equals("README", StringComparison.OrdinalIgnoreCase);
84+
85+
var urlSegments = isIndex ? segments[..^1] : segments;
86+
if (slugify is not null)
87+
urlSegments = [.. urlSegments.Select(s => Slug.Make(s, slugify))];
88+
89+
var dir = string.Join('/', urlSegments);
90+
return dir.Length == 0 ? "" : dir + "/";
8591
}
8692

8793
public static string OutputFileFor(string url)

tests/Netdocs.Core.Tests/CoreTests.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,23 @@ public class UrlTests
1818
public void UrlFor_ProducesDirectoryUrls(string relative, string expected)
1919
=> Assert.Equal(expected, ContentDiscovery.UrlFor(relative));
2020

21+
[Theory]
22+
[InlineData("index.md", "")]
23+
[InlineData("Openshift Registry Setup.md", "openshift-registry-setup/")]
24+
[InlineData("cluster-setup/Deploying MetalLB.md", "cluster-setup/deploying-metallb/")]
25+
[InlineData("Applications/Kubernetes/index.md", "applications/kubernetes/")]
26+
[InlineData("Guide_One/Step Two.md", "guide-one/step-two/")]
27+
public void UrlFor_WithSlugify_SlugifiesSegments(string relative, string expected)
28+
=> Assert.Equal(expected, ContentDiscovery.UrlFor(relative, new SlugifyConfig()));
29+
30+
[Fact]
31+
public void UrlFor_WithSlugify_HonorsCaseAndSeparator()
32+
=> Assert.Equal(
33+
"Openshift_Registry_Setup/",
34+
ContentDiscovery.UrlFor(
35+
"Openshift Registry Setup.md",
36+
new SlugifyConfig { Case = "none", Separator = "_" }));
37+
2138
[Theory]
2239
[InlineData("", "")]
2340
[InlineData("about/", "../")]

tests/Netdocs.Core.Tests/SlugTests.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using Netdocs.Abstractions;
2-
using Netdocs.Plugins;
32
using Xunit;
43

54
namespace Netdocs.Core.Tests;

0 commit comments

Comments
 (0)