Skip to content

Commit 60e7be0

Browse files
committed
feat: add multi-locale support and hreflang generation
- Introduced `LocalizationOptions` for multi-locale content and documentation. - Enhanced `SitemapRssService` to generate hreflang links for alternate locales. - Updated navigation and TOC services to filter content by locale. - Added language switcher component for alternate language navigation. - Improved Markdown services to handle locale-specific content paths. - Added comprehensive tests for localization and alternate link generation. - Updated SPA engine to respect locale-aware settings.
1 parent f38276e commit 60e7be0

20 files changed

Lines changed: 489 additions & 44 deletions

.claude/hookify.prefer-builtin-tools.local.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
name: prefer-builtin-tools
33
enabled: true
44
event: bash
5-
pattern: \b(find|grep|rg|dir|ls|cat|sed|awk|echo)\b
5+
pattern: \b(find|grep|rg|dir|ls|sed|awk|echo)\b
66
action: block
77
---
88

@@ -14,7 +14,6 @@ You're attempting to use a Bash command that has a dedicated built-in tool:
1414
|--------------|-------------|
1515
| `find`, `ls`, `dir` | **Glob** tool for file pattern matching |
1616
| `grep`, `rg` | **Grep** tool for content searching |
17-
| `cat` | **Read** tool for reading files |
1817
| `sed`, `awk` | **Edit** tool for file modifications |
1918
| `echo` | Direct text output in your response |
2019

@@ -24,4 +23,4 @@ You're attempting to use a Bash command that has a dedicated built-in tool:
2423
- They handle permissions and access correctly
2524
- Results are easier to parse and use
2625

27-
**Action:** Use the appropriate built-in tool instead.
26+
**Action:** Use the appropriate built-in tool instead.

MyLittleContentEngine.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
<Folder Name="/examples/">
2626
<File Path="examples/Directory.Build.props" />
2727
<Project Path="examples/BlogExample/BlogExample.csproj" />
28+
<Project Path="examples/LocalizationExample/LocalizationExample.csproj" />
2829
<Project Path="examples/MinimalExample/MinimalExample.csproj" />
2930
<Project Path="examples/MultipleContentSourceExample/MultipleContentSourceExample.csproj" />
3031
<Project Path="examples/RecipeExample/RecipeExample.csproj" />

src/MyLittleContentEngine.DocSite/Components/App.razor

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
@using Microsoft.AspNetCore.Hosting
33
@using Microsoft.Extensions.Hosting
44
@using MyLittleContentEngine
5+
@using MyLittleContentEngine.Services.Content
56
@inject IWebHostEnvironment WebHostEnvironment
67
@inject DocSiteOptions DocSiteOptions
8+
@inject ILocalizedContentService<DocSiteFrontMatter> LocalizedContentService
79

810
<!DOCTYPE html>
9-
<html lang="en">
11+
<html lang="@_htmlLang" dir="@_htmlDir">
1012

1113
<head>
1214
<meta charset="utf-8"/>
@@ -44,7 +46,19 @@
4446
@code{
4547
static readonly string Version = DateTime.Now.Ticks.ToString();
4648

49+
private string _htmlLang = "en";
50+
private string _htmlDir = "ltr";
51+
52+
protected override void OnInitialized()
53+
{
54+
var defaultLocale = LocalizedContentService.DefaultLocale;
55+
var localeInfo = LocalizedContentService.GetLocaleInfo(defaultLocale);
56+
57+
_htmlLang = localeInfo?.HtmlLang ?? defaultLocale;
58+
_htmlDir = localeInfo?.Direction ?? "ltr";
59+
}
60+
4761
string GetVersioned(string url) => WebHostEnvironment.IsDevelopment()
4862
? url
4963
: QueryHelpers.AddQueryString(url, "v", Version);
50-
}
64+
}

src/MyLittleContentEngine.DocSite/Components/Layout/MainLayout.razor

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
@using System.Collections.Immutable
2+
@using MyLittleContentEngine.Services.Content
23
@using MyLittleContentEngine.Services.Content.TableOfContents
34
@using Microsoft.AspNetCore.Components.Sections
45
@inherits LayoutComponentBase
56
@inject ITableOfContentService TableOfContentService
7+
@inject ILocalizedContentService<DocSiteFrontMatter> LocalizedContentService
68
@inject NavigationManager NavigationManager
79
@inject DocSiteOptions DocSiteOptions
810

@@ -105,6 +107,10 @@
105107
</div>
106108

107109
<div class="flex items-center gap-x-2">
110+
@if (_alternateLanguages.Count > 1)
111+
{
112+
<LanguageSwitcher AlternateLanguages="@_langSwitcherItems" />
113+
}
108114
<!-- sun icon -->
109115
<button aria-label="Toggle Dark Mode"
110116
class="hover:bg-base-200 dark:hover:bg-base-800 rounded-md p-1" data-theme-toggle>
@@ -172,6 +178,8 @@
172178

173179
@code{
174180
private ImmutableList<NavigationTreeItem>? _toc;
181+
private ImmutableList<AlternateLanguagePage> _alternateLanguages = [];
182+
private IReadOnlyList<LanguageSwitcher.AlternateLanguageItem> _langSwitcherItems = [];
175183

176184
[Parameter] public RenderFragment? SidebarChildContent { get; set; }
177185

@@ -195,7 +203,15 @@
195203

196204
protected override async Task OnInitializedAsync()
197205
{
198-
_toc = await TableOfContentService.GetNavigationTocAsync(NavigationManager.ToAbsoluteUri(NavigationManager.Uri).AbsolutePath);
206+
var currentPath = NavigationManager.ToAbsoluteUri(NavigationManager.Uri).AbsolutePath;
207+
var locale = LocalizedContentService.GetLocaleFromUrl(currentPath);
208+
209+
_toc = await TableOfContentService.GetNavigationTocForLocaleAsync(currentPath, locale);
210+
_alternateLanguages = await LocalizedContentService.GetAlternateLanguages(currentPath);
211+
_langSwitcherItems = _alternateLanguages
212+
.Select(a => new LanguageSwitcher.AlternateLanguageItem(a.Locale, a.DisplayName, a.Url, a.IsCurrentLocale))
213+
.ToList();
214+
199215
await base.OnInitializedAsync();
200216
}
201217
}

src/MyLittleContentEngine.DocSite/Components/Layout/Pages.razor

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
@using Microsoft.AspNetCore.Components.Sections
77
@using MyLittleContentEngine.Services.Content.TableOfContents
88
@inject ContentEngineOptions ContentEngineOptions
9-
@inject IMarkdownContentService<DocSiteFrontMatter> MarkdownContentService
9+
@inject ILocalizedContentService<DocSiteFrontMatter> LocalizedContentService
1010
@inject ITableOfContentService TableOfContentService
1111
@inject DocSiteOptions DocSiteOptions
1212

@@ -28,7 +28,17 @@
2828
<meta property="og:image" content="@(DocSiteOptions.CanonicalBaseUrl?.TrimEnd('/') + '/' + DocSiteOptions.SocialImageUrl.TrimStart('/'))" />
2929
<meta name="twitter:image" content="@(DocSiteOptions.CanonicalBaseUrl?.TrimEnd('/') + '/' + DocSiteOptions.SocialImageUrl.TrimStart('/'))" />
3030
}
31+
@foreach (var alt in _alternateLanguages)
32+
{
33+
<link rel="alternate" hreflang="@alt.Locale" href="@(DocSiteOptions.CanonicalBaseUrl?.TrimEnd('/') + alt.Url)" />
34+
}
3135
</HeadContent>
36+
37+
@if (_isFallback)
38+
{
39+
<FallbackNotice RequestedLocale="@_requestedLocale" DefaultLocale="@LocalizedContentService.DefaultLocale" />
40+
}
41+
3242
<DocSiteArticle Title="@_post.FrontMatter.Title"
3343
HtmlContent="@_postContent"
3444
PreviousPageName="@_previousPage?.Name"
@@ -57,6 +67,9 @@ else
5767
private NavigationTreeItem? _previousPage;
5868
private NavigationTreeItem? _nextPage;
5969
private Type? _homeComponentType;
70+
private bool _isFallback;
71+
private string? _requestedLocale;
72+
private ImmutableList<AlternateLanguagePage> _alternateLanguages = [];
6073

6174
[MemberNotNull(nameof(_postContent))]
6275
[MemberNotNull(nameof(_outline))]
@@ -73,19 +86,18 @@ else
7386
fileName = "index";
7487
}
7588

76-
var page = await MarkdownContentService.GetRenderedContentPageByUrlOrDefault(fileName);
77-
89+
var result = await LocalizedContentService.GetContentByUrl(fileName);
90+
7891
// If no index page found and we're on root, try to render Index component.
79-
// This allows the user to have a custom page rather than markdown for their home page of the site.
80-
if (page == null && fileName.Equals("index", StringComparison.OrdinalIgnoreCase))
92+
if (result == null && fileName.Equals("index", StringComparison.OrdinalIgnoreCase))
8193
{
8294
try
8395
{
84-
var homeType = Type.GetType("Index") ??
96+
var homeType = Type.GetType("Index") ??
8597
AppDomain.CurrentDomain.GetAssemblies()
8698
.SelectMany(a => a.GetTypes())
8799
.FirstOrDefault(t => t.Name == "Index" && typeof(IComponent).IsAssignableFrom(t));
88-
100+
89101
if (homeType != null)
90102
{
91103
_homeComponentType = homeType;
@@ -97,24 +109,31 @@ else
97109
// Fall through to show not found
98110
}
99111
}
100-
101-
if (page == null)
112+
113+
if (result == null)
102114
{
103115
IsLoaded = false;
104116
return;
105117
}
106118

107-
_outline = page.Value.Page.Outline;
108-
_post = page.Value.Page;
109-
_postContent = page.Value.HtmlContent;
119+
_outline = result.Page.Outline;
120+
_post = result.Page;
121+
_postContent = result.HtmlContent;
122+
_isFallback = result.IsFallback;
123+
_requestedLocale = result.RequestedLocale;
110124

111-
var nextPrevious = await TableOfContentService.GetNavigationInfoAsync(page.Value.Page.Url);
125+
// Get locale-filtered navigation
126+
var locale = LocalizedContentService.GetLocaleFromUrl(fileName);
127+
var nextPrevious = await TableOfContentService.GetNavigationInfoForLocaleAsync(result.Page.Url, locale);
112128
if (nextPrevious != null)
113129
{
114130
_nextPage = nextPrevious.NextPage;
115-
_previousPage = nextPrevious.PreviousPage;
131+
_previousPage = nextPrevious.PreviousPage;
116132
}
117-
133+
134+
// Get alternate language links
135+
_alternateLanguages = await LocalizedContentService.GetAlternateLanguages(fileName);
136+
118137
IsLoaded = true;
119138
}
120-
}
139+
}

src/MyLittleContentEngine.DocSite/Components/_Imports.razor

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,6 @@
1010
@using MyLittleContentEngine.DocSite.Components.Api
1111
@using MyLittleContentEngine.DocSite.Components.Layout
1212
@using MyLittleContentEngine.DocSite.Slots.Components
13-
@using MyLittleContentEngine.UI.Components
13+
@using MyLittleContentEngine.UI.Components
14+
@using System.Collections.Immutable
15+
@using MyLittleContentEngine.Services.Content

src/MyLittleContentEngine.DocSite/DocSiteOptions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ public record DocSiteOptions
105105
/// </summary>
106106
public string? SocialImageUrl { get; init; }
107107

108+
/// <summary>
109+
/// Optional localization configuration for multi-language documentation.
110+
/// When set, enables folder-based locale routing (e.g., Content/fr/ for French).
111+
/// </summary>
112+
public LocalizationOptions? Localization { get; init; }
113+
108114
/// <summary>
109115
/// Optional callback to configure custom TextMate language grammars for syntax highlighting.
110116
/// </summary>

src/MyLittleContentEngine.DocSite/DocSiteServiceExtensions.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,14 @@ public static IServiceCollection AddDocSite(this IServiceCollection services,
4949
SiteDescription = options.Description,
5050
ContentRootPath = options.ContentRootPath,
5151
CanonicalBaseUrl = options.CanonicalBaseUrl,
52-
ConfigureTextMate = options.ConfigureTextMate
52+
ConfigureTextMate = options.ConfigureTextMate,
53+
Localization = options.Localization,
5354
};
5455
})
55-
.WithMarkdownContentService(sp =>
56+
.WithLocalizedMarkdownContent(sp =>
5657
{
57-
// Configure content service
58+
// Configure content service -- always uses WithLocalizedMarkdownContent
59+
// which acts as a pass-through for single-locale sites
5860

5961
var options = sp.GetRequiredService<DocSiteOptions>();
6062

src/MyLittleContentEngine.UI/wwwroot/spa-engine.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
function isSpaLink(anchor) {
8787
if (!anchor.href) return false;
8888
if ((anchor.getAttribute('href') || '').startsWith('#')) return false;
89+
if (anchor.hasAttribute('data-spa-reload')) return false;
8990
try {
9091
const url = new URL(anchor.href);
9192
if (url.origin !== location.origin) return false;

0 commit comments

Comments
 (0)