DocsHomePage.razor 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. @page "/docs"
  2. @page "/docs/{*Page}"
  3. @inject IDocsContentProvider Docs
  4. @inject NavigationManager Nav
  5. @inject IJSRuntime JS
  6. @using Markdig
  7. @using System.Text.Json
  8. @implements IDisposable
  9. <PageTitle>Docs@(!string.IsNullOrWhiteSpace(_activeTitle) ? $": {_activeTitle}" : "")</PageTitle>
  10. <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6">
  11. <!-- Header -->
  12. <div class="flex items-center justify-between mb-6">
  13. <div class="space-y-1">
  14. <div class="text-xs text-zinc-500 uppercase tracking-wider">
  15. Knowledge Base
  16. </div>
  17. <h1 class="text-lg text-zinc-100">
  18. Docs
  19. @if (!string.IsNullOrWhiteSpace(_activeTitle))
  20. {
  21. <span class="text-zinc-500">:</span>
  22. <span class="text-emerald-400">@_activeTitle</span>
  23. }
  24. </h1>
  25. </div>
  26. <NavLink href="docs"
  27. Match="NavLinkMatch.All"
  28. class="text-sm text-emerald-400 hover:text-emerald-300 transition"
  29. data-testid="docs-home-link">
  30. → Overview
  31. </NavLink>
  32. </div>
  33. <div class="grid grid-cols-1 md:grid-cols-12 gap-6">
  34. <!-- Sidebar -->
  35. <aside class="md:col-span-4 lg:col-span-3">
  36. <div class="border border-zinc-800 rounded-md p-4 bg-zinc-900/40">
  37. <div class="text-xs text-zinc-500 uppercase tracking-wider mb-3">
  38. Pages
  39. </div>
  40. <input class="w-full bg-zinc-800 text-zinc-200 p-2 rounded border border-zinc-700 text-sm"
  41. placeholder="Search docs…"
  42. data-testid="docs-search-input"
  43. @bind="_filter"/>
  44. <div class="mt-3 space-y-1 max-h-[60vh] overflow-auto pr-1">
  45. @if (_indexLoading)
  46. {
  47. <div class="text-zinc-500 text-sm">loading index…</div>
  48. }
  49. else if (_indexNotFound)
  50. {
  51. <div class="text-zinc-500 text-sm">
  52. docs index not found
  53. </div>
  54. }
  55. else
  56. {
  57. foreach (var item in FilteredIndex())
  58. {
  59. <NavLink href="@($"docs/{ToRouteSlug(item)}")"
  60. class="block text-sm text-zinc-300 hover:text-emerald-300 transition"
  61. activeClass="text-emerald-400 font-semibold"
  62. data-testid="@($"docs-index-link-{Sanitize(item)}")">
  63. @DisplayName(item)
  64. </NavLink>
  65. }
  66. }
  67. </div>
  68. </div>
  69. </aside>
  70. <!-- Content -->
  71. <main class="md:col-span-8 lg:col-span-9">
  72. <div class="border border-zinc-800 rounded-md p-4 bg-zinc-900/30"
  73. data-testid="docs-viewer">
  74. @if (_isLoading)
  75. {
  76. <div class="text-zinc-500">loading documentation…</div>
  77. }
  78. else if (_notFound)
  79. {
  80. <div class="text-zinc-500">
  81. document not found
  82. <div class="text-xs text-zinc-600 mt-1">
  83. Tried: <span class="text-zinc-400">@_lastFetchUrl</span>
  84. </div>
  85. </div>
  86. }
  87. else
  88. {
  89. <div class="markdown">
  90. @((MarkupString)_htmlContent!)
  91. </div>
  92. }
  93. </div>
  94. </main>
  95. </div>
  96. </div>
  97. @code {
  98. [Parameter] public string? Page { get; set; }
  99. private string? _htmlContent;
  100. private bool _isLoading = true;
  101. private bool _notFound;
  102. private bool _pendingScroll;
  103. private string _filter = "";
  104. private string _activeTitle = "";
  105. private string? _lastFetchUrl;
  106. private bool _indexLoading = true;
  107. private bool _indexNotFound;
  108. private List<string> _docsIndex = new();
  109. private static readonly MarkdownPipeline Pipeline =
  110. new MarkdownPipelineBuilder()
  111. .UseAdvancedExtensions()
  112. .UseAutoIdentifiers()
  113. .Build();
  114. protected override void OnInitialized()
  115. {
  116. Nav.LocationChanged += HandleLocationChanged;
  117. }
  118. protected override async Task OnInitializedAsync()
  119. {
  120. await LoadIndexAsync();
  121. }
  122. protected override async Task OnParametersSetAsync()
  123. {
  124. _isLoading = true;
  125. _notFound = false;
  126. _pendingScroll = true;
  127. _activeTitle = "";
  128. try
  129. {
  130. // DEFAULT TO overview.md
  131. var target = string.IsNullOrWhiteSpace(Page)
  132. ? "overview"
  133. : Page;
  134. var decoded = Uri.UnescapeDataString(target);
  135. if (!decoded.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
  136. decoded += ".md";
  137. var url = $"_content/Shared.Rcl/raw_docs/{decoded}";
  138. _lastFetchUrl = url;
  139. var markdown = await Docs.GetAsync(decoded);
  140. _htmlContent = Markdown.ToHtml(markdown, Pipeline);
  141. _activeTitle = DisplayName(decoded);
  142. }
  143. catch
  144. {
  145. _notFound = true;
  146. _htmlContent = null;
  147. _activeTitle = DisplayName(Page ?? "overview");
  148. }
  149. finally
  150. {
  151. _isLoading = false;
  152. }
  153. }
  154. protected override async Task OnAfterRenderAsync(bool firstRender)
  155. {
  156. if (_pendingScroll && !_isLoading && !_notFound)
  157. {
  158. _pendingScroll = false;
  159. await ScrollToFragmentAsync();
  160. }
  161. }
  162. private void HandleLocationChanged(object? sender, LocationChangedEventArgs e)
  163. {
  164. _pendingScroll = true;
  165. InvokeAsync(StateHasChanged);
  166. }
  167. private async Task ScrollToFragmentAsync()
  168. {
  169. var uri = new Uri(Nav.Uri);
  170. var fragment = uri.Fragment;
  171. if (!string.IsNullOrWhiteSpace(fragment))
  172. {
  173. var anchor = fragment.TrimStart('#');
  174. await JS.InvokeVoidAsync("scrollToAnchor", anchor);
  175. }
  176. }
  177. private async Task LoadIndexAsync()
  178. {
  179. _indexLoading = true;
  180. _indexNotFound = false;
  181. try
  182. {
  183. var json = await Docs.GetAsync("docs-index.json");
  184. var items = JsonSerializer.Deserialize<List<string>>(json);
  185. _docsIndex = (items ?? new List<string>())
  186. .Where(x => !string.IsNullOrWhiteSpace(x))
  187. .Select(x => x.Replace('\\', '/').Trim())
  188. .Distinct(StringComparer.OrdinalIgnoreCase)
  189. .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
  190. .ToList();
  191. }
  192. catch
  193. {
  194. _indexNotFound = true;
  195. _docsIndex = new List<string>();
  196. }
  197. finally
  198. {
  199. _indexLoading = false;
  200. }
  201. }
  202. private IEnumerable<string> FilteredIndex()
  203. {
  204. if (string.IsNullOrWhiteSpace(_filter))
  205. return _docsIndex;
  206. return _docsIndex.Where(x =>
  207. x.Contains(_filter, StringComparison.OrdinalIgnoreCase));
  208. }
  209. private static string ToRouteSlug(string path)
  210. {
  211. return path.EndsWith(".md", StringComparison.OrdinalIgnoreCase)
  212. ? path[..^3]
  213. : path;
  214. }
  215. private static string DisplayName(string path)
  216. {
  217. var p = path.Replace('\\', '/').Trim();
  218. if (p.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
  219. p = p[..^3];
  220. return p.Split('/', StringSplitOptions.RemoveEmptyEntries)
  221. .LastOrDefault() ?? p;
  222. }
  223. private static string Sanitize(string value)
  224. {
  225. return value.Replace(" ", "-")
  226. .Replace("/", "-")
  227. .Replace("\\", "-")
  228. .Replace(".", "-");
  229. }
  230. public void Dispose()
  231. {
  232. Nav.LocationChanged -= HandleLocationChanged;
  233. }
  234. }