DocsHomePage.razor 8.1 KB

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