88@inject IHttpContextAccessor HttpContextAccessor
99@inject SiteBranding Brand
1010
11- <PageTitle >@_pageTitleFull </PageTitle >
11+ <PageTitle >@PageTitleFull </PageTitle >
1212
1313<HeadContent >
14- @* Basic *@
1514 <meta name =" application-name" content =" @Brand.SiteName" />
1615 @if (! string .IsNullOrWhiteSpace (Brand .ThemeColor ))
1716 {
1817 <meta name =" theme-color" content =" @Brand.ThemeColor" />
1918 }
2019
21- @if (! string .IsNullOrWhiteSpace (_canonical ))
20+ @if (! string .IsNullOrWhiteSpace (Canonical ))
2221 {
23- <link rel =" canonical" href =" @_canonical " />
24- <meta property =" og:url" content =" @_canonical " />
22+ <link rel =" canonical" href =" @Canonical " />
23+ <meta property =" og:url" content =" @Canonical " />
2524 }
2625
27- @if (! string .IsNullOrWhiteSpace (_description ))
26+ @if (! string .IsNullOrWhiteSpace (Description ))
2827 {
29- <meta name =" description" content =" @_description " />
28+ <meta name =" description" content =" @Description " />
3029 }
3130
32- @* Social cards *@
3331 <meta property =" og:site_name" content =" @Brand.SiteName" />
34- <meta property =" og:title" content =" @_pageTitleFull " />
32+ <meta property =" og:title" content =" @PageTitleFull " />
3533 <meta property =" og:type" content =" article" />
3634
37- @if (! string .IsNullOrWhiteSpace (_description ))
35+ @if (! string .IsNullOrWhiteSpace (Description ))
3836 {
39- <meta property =" og:description" content =" @_description " />
37+ <meta property =" og:description" content =" @Description " />
4038 }
4139
42- @{
43- var ogImage = ! string .IsNullOrWhiteSpace (_ogImage ) ? _ogImage : Brand .DefaultOgImage ;
44- var squareImage = ! string .IsNullOrWhiteSpace (_squareImage ) ? _squareImage : " " ;
45- }
46-
47-
48-
49- @if (! string .IsNullOrWhiteSpace (squareImage ))
40+ @if (! string .IsNullOrWhiteSpace (SquareImageAbs ))
5041 {
51- <meta property =" og:image" content =" @ToAbsolute(squareImage) " />
42+ <meta property =" og:image" content =" @SquareImageAbs " />
5243 <meta property =" og:image:width" content =" 1024" />
5344 <meta property =" og:image:height" content =" 1024" />
54-
5545 }
5646
57-
58- <meta name =" twitter:title" content =" @_pageTitleFull" />
59- @if (! string .IsNullOrWhiteSpace (_description ))
47+ <meta name =" twitter:title" content =" @PageTitleFull" />
48+ @if (! string .IsNullOrWhiteSpace (Description ))
6049 {
61- <meta name =" twitter:description" content =" @_description " />
50+ <meta name =" twitter:description" content =" @Description " />
6251 }
6352
6453 @if (! string .IsNullOrWhiteSpace (Brand .TwitterHandle ))
6554 {
6655 <meta name =" twitter:site" content =" @Brand.TwitterHandle" />
6756 }
6857
69- @if (_noIndex )
58+ @if (NoIndex )
7059 {
7160 <meta name =" robots" content =" noindex,nofollow" />
7261 }
7362
74- @* JSON-LD *@
7563 <script type =" application/ld+json" >@_jsonLd </script >
7664</HeadContent >
7765
78-
7966@if (_notFound )
8067{
8168 <h1 >Not found </h1 >
9380 <div class =" doc-hero2-inner" >
9481 <div class =" doc-hero2-meta" >
9582 <p class =" doc-kicker" >Guide </p >
96- <h1 id =" doc-title" class =" doc-title" >@_title </h1 >
83+ <h1 id =" doc-title" class =" doc-title" >@Title </h1 >
9784
98- @if (! string .IsNullOrWhiteSpace (_description ))
85+ @if (! string .IsNullOrWhiteSpace (Description ))
9986 {
100- <p class =" doc-lead" >@_description </p >
87+ <p class =" doc-lead" >@Description </p >
10188 }
10289
10390 <div class =" doc-actions" role =" navigation" aria-label =" Document actions" >
104- <a class =" doc-action" href =" /guide" >All guides </a >
105- @if (! string .IsNullOrWhiteSpace (_canonical ))
91+ <a class =" doc-action" href =" /guide" >
92+ <i class =" bi bi-file-earmark-text" ></i >
93+ All guides
94+ </a >
95+
96+ @if (! string .IsNullOrWhiteSpace (Canonical ))
10697 {
107- <a class =" doc-action" href =" @_canonical" >Permalink </a >
98+ <a class =" doc-action" href =" @Canonical" >
99+ <i class =" bi bi-link-45deg" ></i >
100+ Permalink
101+ </a >
102+ }
103+
104+ @if (! string .IsNullOrWhiteSpace (LeaderboardUrl ))
105+ {
106+ <a class =" doc-action" href =" @LeaderboardUrl" >
107+ <i class =" bi bi-trophy" ></i >
108+ Leaderboard
109+ </a >
110+ }
111+
112+ @if (! string .IsNullOrWhiteSpace (DiscordUrl ))
113+ {
114+ <a class =" doc-action" href =" @DiscordUrl" >
115+ <i class =" bi bi-discord" ></i >
116+ Discord
117+ </a >
108118 }
109119 </div >
110120 </div >
@@ -155,25 +165,42 @@ else
155165 </div >
156166}
157167
158-
159168@code {
160169 [Parameter ] public string ? slug { get ; set ; }
161170
162- private string _title = " Loading..." ;
163- private string ? _description ;
164- private string ? _ogImage ;
165- private string ? _squareImage ;
166-
167- private string ? _canonical ;
168- private bool _noIndex ;
169-
171+ private MarkdownDoc ? _doc ;
170172 private bool _notFound ;
173+
171174 private string ? _html ;
175+ private string _canonical = " " ;
172176 private string _pageTitleFull = " Loading..." ;
173177 private string _jsonLd = " " ;
178+
174179 private NavNode ? _nav ;
175180 private string _navRootSlug = " " ;
176- private List <DocEntry > _children = new ();
181+
182+ // ---- computed doc fields ----
183+ private string Title => _doc ? .Title ?? (_notFound ? " Not found" : " Loading..." );
184+ private string ? Description => _doc ? .Description ;
185+ private bool NoIndex => _doc ? .NoIndex ?? _notFound ;
186+
187+ private string Canonical => _canonical ;
188+ private string PageTitleFull => _pageTitleFull ;
189+
190+ private string ? LeaderboardUrl => _doc ? .LeaderboardUrl ;
191+ private string ? DiscordUrl => _doc ? .DiscordURL ;
192+
193+ private string ? OgImagePath => ! string .IsNullOrWhiteSpace (_doc ? .OgImage ) ? _doc ! .OgImage : Brand .DefaultOgImage ;
194+ private string ? SquareImagePath => _doc ? .SquareImage ;
195+
196+ private string ? OgImageAbs => ! string .IsNullOrWhiteSpace (OgImagePath ) ? ToAbsolute (OgImagePath ! ) : null ;
197+ private string ? SquareImageAbs => ! string .IsNullOrWhiteSpace (SquareImagePath ) ? ToAbsolute (SquareImagePath ! ) : null ;
198+
199+ private string HeroStyle
200+ => ! string .IsNullOrWhiteSpace (OgImageAbs )
201+ ? $" --hero-image: url('{OgImageAbs }');"
202+ : " " ;
203+
177204 private RenderFragment RenderNav (NavNode node ) => @<ul class =" nav-list nav-list--nested" >
178205@foreach ( var child in node .Children )
179206 {
@@ -192,94 +219,67 @@ else
192219 </li >
193220 }
194221</ul >;
195- private string HeroStyle
196- => string .IsNullOrWhiteSpace (_ogImage )
197- ? " "
198- : $" --hero-image: url('{ToAbsolute (_ogImage )}');" ;
199- private string ToGuideRoute (string contentSlug )
222+
223+ protected override async Task OnParametersSetAsync ()
224+ {
225+ var ctx = HttpContextAccessor .HttpContext ! ;
226+ var host = ctx .Request .Host .Host ;
227+
228+ var (doc , _ ) = await Store .TryGetByRequestAsync (slug , host );
229+ _doc = doc ;
230+
231+ var normalized = (slug ?? " " ).Trim ('/' );
232+ _navRootSlug = Store .GetNavRootSlug (normalized );
233+ _nav = Store .BuildNavTree (_navRootSlug , normalized );
234+
235+ _notFound = doc is null ;
236+
237+ // canonical works even for not-found
238+ _canonical = _notFound
239+ ? BuildCanonical (ctx , slug ?? " " )
240+ : BuildCanonical (ctx , doc ! .Slug , doc .Canonical );
241+
242+ _pageTitleFull = $" {Title } | {Brand .SiteName }" ;
243+
244+ _jsonLd = BuildJsonLd (
245+ _pageTitleFull ,
246+ Description ?? Brand .Tagline ,
247+ _canonical ,
248+ OgImageAbs
249+ );
250+
251+ _html = _notFound ? null : Renderer .ToHtml (doc ! .Html );
252+ }
253+
254+ private string ToGuideRoute (string contentSlug )
200255{
201256 contentSlug = (contentSlug ?? " " ).Trim ('/' ).Replace ('\\ ' , '/' );
202257
203- // the page route is already /guide/{**slug}
204258 if (contentSlug .Equals (" guide" , StringComparison .OrdinalIgnoreCase ))
205259 return " /guide" ;
206260
207261 if (contentSlug .StartsWith (" guide/" , StringComparison .OrdinalIgnoreCase ))
208262 contentSlug = contentSlug [" guide/" .Length .. ];
209263
210- // If the slug accidentally contains ".md", remove it
211264 if (contentSlug .EndsWith (" .md" , StringComparison .OrdinalIgnoreCase ))
212265 contentSlug = contentSlug [.. ^ 3 ].TrimEnd ('/' );
213266
214- // Map "timer/index" to "/guide/timer" (nicer URLs)
215267 if (contentSlug .EndsWith (" /index" , StringComparison .OrdinalIgnoreCase ))
216268 contentSlug = contentSlug [.. ^ " /index" .Length ].TrimEnd ('/' );
217269
218270 return string .IsNullOrWhiteSpace (contentSlug ) ? " /guide" : $" /guide/{contentSlug }" ;
219271}
220272
221- protected override async Task OnParametersSetAsync ()
222- {
223- var ctx = HttpContextAccessor .HttpContext ! ;
224- var host = ctx .Request .Host .Host ;
225-
226- var (doc , _ ) = await Store .TryGetByRequestAsync (slug , host );
227- var normalized = (slug ?? " " ).Trim ('/' );
228-
229-
230- _navRootSlug = Store .GetNavRootSlug (normalized );
231- _nav = Store .BuildNavTree (_navRootSlug , normalized );
232-
233-
234- if (doc is null )
235- {
236- _notFound = true ;
237- _title = " Not found" ;
238- _description = null ;
239- _ogImage = null ;
240- _squareImage = doc .SquareImage ;
241-
242- _noIndex = true ;
243- _canonical = BuildCanonical (ctx , slug ?? " " );
244- return ;
245- }
246-
247- _notFound = false ;
248- _title = doc .Title ;
249- _description = doc .Description ;
250- _ogImage = doc .OgImage ;
251- _squareImage = doc .SquareImage ;
252-
253- _noIndex = doc .NoIndex ;
254- _canonical = BuildCanonical (ctx , doc .Slug , doc .Canonical );
255-
256- _pageTitleFull = $" {_title } | {Brand .SiteName }" ;
257-
258- var ogAbs = ! string .IsNullOrWhiteSpace (_ogImage )
259- ? ToAbsolute (_ogImage )
260- : (! string .IsNullOrWhiteSpace (Brand .DefaultOgImage ) ? ToAbsolute (Brand .DefaultOgImage ) : null );
261-
262- _jsonLd = BuildJsonLd (_pageTitleFull , _description ?? Brand .Tagline , _canonical , ogAbs );
263-
264- _html = Renderer .ToHtml (doc .Html );
265-
266-
267- }
268-
269273private string BuildCanonical (HttpContext ctx , string ? contentSlug , string ? canonicalOverride = null )
270274{
271275 if (! string .IsNullOrWhiteSpace (canonicalOverride ))
272276 return canonicalOverride .Trim ();
273277
274278 var baseUrl = $" {ctx .Request .Scheme }://{ctx .Request .Host }" .TrimEnd ('/' );
275-
276- // Use the same routing rules as the UI links
277- var path = ToGuideRoute (contentSlug ?? " " ); // returns "/guide" or "/guide/{slug}"
278-
279+ var path = ToGuideRoute (contentSlug ?? " " );
279280 return baseUrl + path ;
280281}
281282
282-
283283private string ToAbsolute (string urlOrPath )
284284{
285285 if (string .IsNullOrWhiteSpace (urlOrPath )) return urlOrPath ;
@@ -302,15 +302,14 @@ private string BuildJsonLd(string pageTitle, string? description, string canonic
302302 var siteName = Brand .SiteName ;
303303 var tagline = Brand .Tagline ;
304304
305- // WebSite + WebPage/Article
306305 return $@"
307306{{
308307 "" @context"" : "" https://schema.org"" ,
309308 "" @graph"" : [
310309 {{
311310 "" @type"" : "" WebSite"" ,
312311 "" name"" : "" {J (siteName )}"" ,
313- "" url"" : "" {J ($" {HttpContextAccessor .HttpContext ! .Request .Scheme }://{HttpContextAccessor .HttpContext ! .Request .Host }/" )}"" ,
312+ "" url"" : "" {J ($" {HttpContextAccessor .HttpContext ! .Request .Scheme }://{HttpContextAccessor .HttpContext ! .Request .Host }/" )}"" ,
314313 "" description"" : "" {J (tagline )}""
315314 }},
316315 {{
@@ -323,5 +322,4 @@ private string BuildJsonLd(string pageTitle, string? description, string canonic
323322 ]
324323}}" .Trim ();
325324}
326-
327325}
0 commit comments