diff --git a/StabilityMatrix.Avalonia/Controls/DocumentationMarkdownViewer.cs b/StabilityMatrix.Avalonia/Controls/DocumentationMarkdownViewer.cs new file mode 100644 index 000000000..c302c1e4b --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/DocumentationMarkdownViewer.cs @@ -0,0 +1,251 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; +using Avalonia.VisualTree; +using ColorTextBlock.Avalonia; +using Markdown.Avalonia; +using StabilityMatrix.Core.Models.Documentation; + +namespace StabilityMatrix.Avalonia.Controls; + +/// +/// A that routes hyperlink clicks through a +/// bindable (so relative .md links can navigate in-app +/// and external links can open in the browser) and resolves relative image paths against +/// via the engine's asset path root. +/// +public class DocumentationMarkdownViewer : BetterMarkdownScrollViewer +{ + /// + /// Command invoked when a hyperlink is clicked. The command parameter is the raw href string. + /// + public static readonly StyledProperty LinkCommandProperty = AvaloniaProperty.Register< + DocumentationMarkdownViewer, + ICommand? + >(nameof(LinkCommand)); + + /// + /// Base URL used to resolve relative image paths in the rendered markdown + /// (e.g. the raw URL of the current page's folder). + /// + public static readonly StyledProperty ImageBaseUrlProperty = AvaloniaProperty.Register< + DocumentationMarkdownViewer, + string? + >(nameof(ImageBaseUrl)); + + public ICommand? LinkCommand + { + get => GetValue(LinkCommandProperty); + set => SetValue(LinkCommandProperty, value); + } + + /// + /// Zoom factor applied to the rendered document content (1.0 = 100%). + /// Scales the content inside the internal scroll viewer, so the scrollbar is unaffected. + /// + public static readonly StyledProperty ContentZoomProperty = AvaloniaProperty.Register< + DocumentationMarkdownViewer, + double + >(nameof(ContentZoom), 1.0); + + public string? ImageBaseUrl + { + get => GetValue(ImageBaseUrlProperty); + set => SetValue(ImageBaseUrlProperty, value); + } + + public double ContentZoom + { + get => GetValue(ContentZoomProperty); + set => SetValue(ContentZoomProperty, value); + } + + /// + /// Hosts the document content inside the internal scroll viewer so zoom can scale the + /// content without scaling the scrollbar. Null if the base control's composition changes. + /// + private readonly LayoutTransformControl? zoomHost; + + public DocumentationMarkdownViewer() + { + ApplyLinkCommand(); + ApplyImageBaseUrl(); + + // The base ctor composes a non-templated inner ScrollViewer (a direct visual child) + // whose Content is the document wrapper, and never reassigns Content afterwards + // (page changes only swap the wrapper's Document). Re-parent the wrapper into a + // LayoutTransformControl so zoom scales the document but not the scrollbar, and add + // right margin so the overlay scrollbar doesn't cover the rightmost text. + if (this.GetVisualChildren().OfType().FirstOrDefault() is { } innerViewer) + { + if (innerViewer.Content is Control content) + { + innerViewer.Content = null; + zoomHost = new LayoutTransformControl + { + Child = content, + Margin = new Thickness(0, 0, 18, 0), + }; + innerViewer.Content = zoomHost; + } + else + { + // Fallback: at least keep the scrollbar off the content. + innerViewer.Padding = new Thickness(0, 0, 18, 0); + } + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == LinkCommandProperty) + { + ApplyLinkCommand(); + } + else if (change.Property == ImageBaseUrlProperty) + { + ApplyImageBaseUrl(); + } + else if (change.Property == ContentZoomProperty) + { + ApplyContentZoom(); + } + } + + private void ApplyContentZoom() + { + if (zoomHost is null) + return; + + // Guard against zero/negative values from bad bindings. + var zoom = Math.Clamp(ContentZoom, 0.25, 4.0); + zoomHost.LayoutTransform = new ScaleTransform(zoom, zoom); + } + + private void ApplyLinkCommand() + { + // The engine owns the HyperlinkCommand used for all rendered links. The Engine getter + // always returns an IMarkdownEngine2 (custom IMarkdownEngine values are upgraded to a + // wrapper that only implements IMarkdownEngine2), so match on that interface. + if (Engine is IMarkdownEngine2 engine) + { + engine.HyperlinkCommand = LinkCommand; + } + } + + private void ApplyImageBaseUrl() + { + // AssetPathRoot flows through to the engine's bitmap loader so relative image + // paths resolve against the raw docs URL. + AssetPathRoot = ImageBaseUrl ?? string.Empty; + } + + private static readonly string[] HeadingClasses = + [ + "Heading1", + "Heading2", + "Heading3", + "Heading4", + "Heading5", + "Heading6", + ]; + + /// + /// Scrolls the rendered content so the heading matching the given GitHub-style anchor slug + /// is brought to the top of the viewport. + /// + /// The bare heading slug (no leading #). + /// true if a matching heading was found at call time; otherwise false. + public bool ScrollToAnchor(string anchor) + { + if (string.IsNullOrWhiteSpace(anchor)) + return false; + + var slug = DocumentationPathResolver.Slugify(anchor); + if (slug.Length == 0) + return false; + + // Content is built synchronously when Markdown changes, but layout/measure (needed for + // TranslatePoint) only runs on the next layout pass — defer the actual scroll. + var found = FindHeadingBySlug(slug) is not null; + + Dispatcher.UIThread.Post( + () => + { + var target = FindHeadingBySlug(slug); + if (target is not null) + ScrollHeadingIntoView(target); + }, + DispatcherPriority.Background + ); + + return found; + } + + /// + /// Locates the heading control whose slug matches, applying GitHub-style duplicate suffixes + /// (-1, -2, ...) in document order. + /// + private CTextBlock? FindHeadingBySlug(string slug) + { + var seen = new Dictionary(StringComparer.Ordinal); + + foreach (var descendant in this.GetVisualDescendants()) + { + if (descendant is not CTextBlock textBlock || !IsHeading(textBlock)) + continue; + + var baseSlug = DocumentationPathResolver.Slugify(textBlock.Text ?? string.Empty); + if (baseSlug.Length == 0) + continue; + + string effectiveSlug; + if (seen.TryGetValue(baseSlug, out var count)) + { + effectiveSlug = $"{baseSlug}-{count}"; + seen[baseSlug] = count + 1; + } + else + { + effectiveSlug = baseSlug; + seen[baseSlug] = 1; + } + + if (string.Equals(effectiveSlug, slug, StringComparison.Ordinal)) + return textBlock; + } + + return null; + } + + private static bool IsHeading(StyledElement control) + { + foreach (var cls in HeadingClasses) + { + if (control.Classes.Contains(cls)) + return true; + } + + return false; + } + + private void ScrollHeadingIntoView(Visual heading) + { + // Position of the heading relative to this control's viewport, plus the current scroll + // offset, gives the heading's Y within the scrollable content. + var current = ScrollValue; + var point = heading.TranslatePoint(new Point(0, 0), this); + if (point is null) + return; + + var targetY = Math.Max(0, point.Value.Y + current.Y); + ScrollValue = new Vector(current.X, targetY); + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationNavNode.cs b/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationNavNode.cs new file mode 100644 index 000000000..2cd6c6269 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationNavNode.cs @@ -0,0 +1,14 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace StabilityMatrix.Avalonia.ViewModels.Documentation; + +/// +/// Base for nodes shown in the documentation navigation tree (sections and pages). +/// Exposes the expansion state consumed by the TreeView's TreeViewItem style binding. +/// +public abstract partial class DocumentationNavNode : ObservableObject +{ + /// Whether the corresponding TreeViewItem is expanded. + [ObservableProperty] + public partial bool IsExpanded { get; set; } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationPageNavItem.cs b/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationPageNavItem.cs new file mode 100644 index 000000000..490f7e22c --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationPageNavItem.cs @@ -0,0 +1,13 @@ +namespace StabilityMatrix.Avalonia.ViewModels.Documentation; + +/// +/// A single navigable documentation page entry (leaf) in the sidebar tree. +/// +public partial class DocumentationPageNavItem : DocumentationNavNode +{ + /// Display title, e.g. "Overview". + public required string Title { get; init; } + + /// Path relative to the docs root, e.g. getting-started/overview.md. + public required string Path { get; init; } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationSectionNavItem.cs b/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationSectionNavItem.cs new file mode 100644 index 000000000..498445671 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Documentation/DocumentationSectionNavItem.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace StabilityMatrix.Avalonia.ViewModels.Documentation; + +/// +/// A section grouping in the documentation sidebar (e.g. "Getting Started"). +/// +public partial class DocumentationSectionNavItem : DocumentationNavNode +{ + public DocumentationSectionNavItem() + { + // Sections are expanded by default. + IsExpanded = true; + } + + /// Section title. Empty for the root section (renders without a header). + public required string Title { get; init; } + + /// Whether this section has a visible header (i.e. is not the root section). + public bool HasHeader => !string.IsNullOrEmpty(Title); + + /// Pages within this section. + public required IReadOnlyList Pages { get; init; } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/DocumentationViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/DocumentationViewModel.cs new file mode 100644 index 000000000..7efcc2f7e --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/DocumentationViewModel.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Input; +using AsyncAwaitBestPractices; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using FluentAvalonia.UI.Controls; +using FluentIcons.Common; +using Injectio.Attributes; +using Microsoft.Extensions.Logging; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Avalonia.ViewModels.Documentation; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Models.Documentation; +using StabilityMatrix.Core.Processes; +using StabilityMatrix.Core.Services; +using Symbol = FluentIcons.Common.Symbol; +using SymbolIconSource = FluentIcons.Avalonia.Fluent.SymbolIconSource; + +namespace StabilityMatrix.Avalonia.ViewModels; + +[View(typeof(Views.DocumentationPage))] +[RegisterSingleton] +public partial class DocumentationViewModel : PageViewModelBase +{ + private const double MinZoom = 0.5; + private const double MaxZoom = 2.0; + private const double ZoomStep = 0.1; + + private readonly ILogger logger; + private readonly IDocumentationService documentationService; + private readonly ISettingsManager settingsManager; + + public override string Title => "Documentation"; + + public override IconSource IconSource => + new SymbolIconSource { Symbol = Symbol.BookOpen, IconVariant = IconVariant.Filled }; + + public ObservableCollection Sections { get; } = []; + + /// + /// Flattened source for the navigation TreeView: root-level pages are hoisted to + /// top-level leaves and each non-root section is a parent node. Items are either + /// or . + /// + public ObservableCollection TreeItems { get; } = []; + + [ObservableProperty] + private DocumentationPageNavItem? selectedPage; + + /// Two-way bound to the nav TreeView's SelectedItem; routes page leaves to . + [ObservableProperty] + private object? selectedTreeItem; + + [ObservableProperty] + private string? currentMarkdown; + + /// Raw base URL of the currently displayed page's folder (for relative image resolution). + [ObservableProperty] + private string? currentImageBaseUrl; + + [ObservableProperty] + private bool isTreeLoading; + + [ObservableProperty] + private bool isPageLoading; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasError))] + private string? errorMessage; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsContentVisible))] + private bool isDocsUnavailable; + + /// Content zoom factor for the markdown viewer (1.0 = 100%). Persisted in settings. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ZoomPercentText))] + public partial double ZoomFactor { get; set; } + + /// Current zoom level formatted for display, e.g. "100%". + public string ZoomPercentText => $"{Math.Round(ZoomFactor * 100)}%"; + + public bool HasError => !string.IsNullOrEmpty(ErrorMessage); + + public bool IsContentVisible => !IsDocsUnavailable; + + /// Command bound to the markdown viewer to intercept hyperlink clicks. + public ICommand LinkClickedCommand { get; } + + /// + /// Raised when an in-page anchor should be scrolled to. The argument is the bare heading slug. + /// The view subscribes and forwards to the markdown viewer (keeps the VM free of control refs). + /// + public event EventHandler? AnchorRequested; + + private CancellationTokenSource? pageCts; + private bool hasLoadedTree; + private bool hasRegisteredSettingsRelays; + + /// Anchor slug to scroll to after the next page load completes (cross-page anchor links). + private string? pendingAnchor; + + public DocumentationViewModel( + ILogger logger, + IDocumentationService documentationService, + ISettingsManager settingsManager + ) + { + this.logger = logger; + this.documentationService = documentationService; + this.settingsManager = settingsManager; + LinkClickedCommand = new RelayCommand(OnLinkClicked); + ZoomFactor = 1.0; + } + + public override async Task OnLoadedAsync() + { + await base.OnLoadedAsync(); + + if (!hasRegisteredSettingsRelays) + { + hasRegisteredSettingsRelays = true; + + // Load the persisted zoom level and keep it saved on change. + settingsManager.RelayPropertyFor( + this, + vm => vm.ZoomFactor, + settings => settings.DocumentationZoomFactor, + true + ); + } + + if (!hasLoadedTree) + { + await LoadTreeAsync(); + } + } + + [RelayCommand] + private async Task RetryAsync() + { + await LoadTreeAsync(forceRefresh: true); + } + + [RelayCommand] + private void ZoomIn() + { + ZoomFactor = Math.Min(MaxZoom, Math.Round(ZoomFactor + ZoomStep, 2)); + } + + [RelayCommand] + private void ZoomOut() + { + ZoomFactor = Math.Max(MinZoom, Math.Round(ZoomFactor - ZoomStep, 2)); + } + + [RelayCommand] + private void ResetZoom() + { + ZoomFactor = 1.0; + } + + private async Task LoadTreeAsync(bool forceRefresh = false) + { + IsTreeLoading = true; + ErrorMessage = null; + IsDocsUnavailable = false; + + try + { + var sections = await documentationService.GetSectionsAsync(forceRefresh); + + Sections.Clear(); + TreeItems.Clear(); + foreach (var section in sections) + { + var navSection = new DocumentationSectionNavItem + { + Title = section.Title, + Pages = section + .Pages.Select(p => new DocumentationPageNavItem { Title = p.Title, Path = p.Path }) + .ToList(), + }; + Sections.Add(navSection); + + // Hoist the root (empty-title) section's pages to top-level tree leaves; + // real sections become parent nodes. + if (navSection.HasHeader) + { + TreeItems.Add(navSection); + } + else + { + foreach (var page in navSection.Pages) + TreeItems.Add(page); + } + } + + hasLoadedTree = true; + + // Select the first available page (docs README landing page comes first). + var firstPage = Sections.SelectMany(s => s.Pages).FirstOrDefault(); + if (firstPage is not null) + { + SelectedPage = firstPage; + } + } + catch (DocumentationNotAvailableException e) + { + logger.LogInformation(e, "Documentation is not available yet"); + IsDocsUnavailable = true; + Sections.Clear(); + TreeItems.Clear(); + } + catch (Exception e) + { + logger.LogWarning(e, "Failed to load documentation tree"); + ErrorMessage = "Could not load the documentation listing. Check your connection and try again."; + Sections.Clear(); + TreeItems.Clear(); + } + finally + { + IsTreeLoading = false; + } + } + + partial void OnSelectedPageChanged(DocumentationPageNavItem? oldValue, DocumentationPageNavItem? newValue) + { + // Keep the tree's visual selection in sync (e.g. when navigation comes from a link click). + SelectedTreeItem = newValue; + + if (newValue is null) + return; + + // Capture any anchor queued by a cross-page link so it can't race a later navigation. + var anchor = pendingAnchor; + pendingAnchor = null; + LoadPageAsync(newValue.Path, anchor).SafeFireAndForget(); + } + + partial void OnSelectedTreeItemChanged(object? value) + { + // Only page leaves load content; selecting a section node is a no-op. + if (value is DocumentationPageNavItem page) + SelectedPage = page; + } + + private async Task LoadPageAsync(string docsRelativePath, string? anchor = null) + { + // Replace the CTS before any await so rapid selections can't race on the old one, + // then cancel the superseded load. + var oldCts = pageCts; + var newCts = new CancellationTokenSource(); + pageCts = newCts; + var ct = newCts.Token; + + if (oldCts is not null) + { + await oldCts.CancelAsync(); + oldCts.Dispose(); + } + + IsPageLoading = true; + ErrorMessage = null; + + try + { + var markdown = await documentationService.GetPageMarkdownAsync( + docsRelativePath, + cancellationToken: ct + ); + ct.ThrowIfCancellationRequested(); + + // Fold code-span link text ([`Home`](url) -> [Home](url)) that the markdown + // renderer can't parse, then absolutize relative image URLs. + markdown = DocumentationPathResolver.RewriteCodeSpanLinks(markdown); + CurrentMarkdown = DocumentationPathResolver.RewriteImageUrls(docsRelativePath, markdown); + CurrentImageBaseUrl = GetPageFolderRawUrl(docsRelativePath); + + // The page content is now set; ask the view to scroll to the requested anchor. + // The viewer defers the actual measurement to a layout pass, so this can't race + // the (already-completed) page load. + if (!string.IsNullOrEmpty(anchor)) + AnchorRequested?.Invoke(this, anchor); + } + catch (OperationCanceledException) + { + // Superseded by a newer selection; ignore. + } + catch (Exception e) + { + logger.LogWarning(e, "Failed to load documentation page {Path}", docsRelativePath); + ErrorMessage = "Could not load this page. Check your connection and try again."; + CurrentMarkdown = null; + } + finally + { + IsPageLoading = false; + } + } + + private void OnLinkClicked(string? href) + { + if (string.IsNullOrWhiteSpace(href)) + return; + + var currentPath = SelectedPage?.Path ?? "README.md"; + var resolved = DocumentationPathResolver.ResolveLink(currentPath, href); + + switch (resolved.Kind) + { + case DocumentationPathResolver.LinkKind.External: + ProcessRunner.OpenUrl(resolved.Target); + break; + + case DocumentationPathResolver.LinkKind.InternalPage: + NavigateToPath(resolved.Target, resolved.Fragment); + break; + + case DocumentationPathResolver.LinkKind.Anchor: + // Same-page anchor: ask the view to scroll to the heading. + if (!string.IsNullOrEmpty(resolved.Target)) + AnchorRequested?.Invoke(this, resolved.Target); + break; + } + } + + private void NavigateToPath(string docsRelativePath, string? fragment = null) + { + var match = Sections + .SelectMany(s => s.Pages) + .FirstOrDefault(p => string.Equals(p.Path, docsRelativePath, StringComparison.OrdinalIgnoreCase)); + + // Already on the target page: SelectedPage won't re-fire, so handle the anchor directly. + if (match is not null && ReferenceEquals(match, SelectedPage)) + { + if (!string.IsNullOrEmpty(fragment)) + AnchorRequested?.Invoke(this, fragment); + return; + } + + pendingAnchor = fragment; + + if (match is not null) + { + SelectedPage = match; + } + else + { + // Not part of the discovered tree (e.g. a page not yet listed) — load it directly. + var anchor = pendingAnchor; + pendingAnchor = null; + LoadPageAsync(docsRelativePath, anchor).SafeFireAndForget(); + } + } + + private static string GetPageFolderRawUrl(string docsRelativePath) + { + var separatorIndex = docsRelativePath.LastIndexOf('/'); + var folder = separatorIndex < 0 ? string.Empty : docsRelativePath[..(separatorIndex + 1)]; + return DocumentationConstants.GetRawUrl($"{DocumentationConstants.DocsRoot}/{folder}"); + } +} diff --git a/StabilityMatrix.Avalonia/Views/DocumentationPage.axaml b/StabilityMatrix.Avalonia/Views/DocumentationPage.axaml new file mode 100644 index 000000000..e401fa5a3 --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/DocumentationPage.axaml @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Views/DocumentationPage.axaml.cs b/StabilityMatrix.Avalonia/Views/DocumentationPage.axaml.cs new file mode 100644 index 000000000..a36541760 --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/DocumentationPage.axaml.cs @@ -0,0 +1,39 @@ +using System; +using Injectio.Attributes; +using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.ViewModels; + +namespace StabilityMatrix.Avalonia.Views; + +[RegisterSingleton] +public partial class DocumentationPage : UserControlBase +{ + private DocumentationViewModel? subscribedViewModel; + + public DocumentationPage() + { + InitializeComponent(); + DataContextChanged += OnDataContextChanged; + } + + private void OnDataContextChanged(object? sender, EventArgs e) + { + if (subscribedViewModel is not null) + { + subscribedViewModel.AnchorRequested -= OnAnchorRequested; + } + + subscribedViewModel = DataContext as DocumentationViewModel; + + if (subscribedViewModel is not null) + { + subscribedViewModel.AnchorRequested += OnAnchorRequested; + } + } + + private void OnAnchorRequested(object? sender, string anchor) + { + // Forward the anchor request to the markdown viewer (keeps the VM free of control refs). + MarkdownViewer?.ScrollToAnchor(anchor); + } +} diff --git a/StabilityMatrix.Avalonia/Views/MainWindow.axaml b/StabilityMatrix.Avalonia/Views/MainWindow.axaml index d6b9e41cd..85bf30701 100644 --- a/StabilityMatrix.Avalonia/Views/MainWindow.axaml +++ b/StabilityMatrix.Avalonia/Views/MainWindow.axaml @@ -127,6 +127,19 @@ + + + + + + + (new BetterEntranceNavigationTransition()); + } + private void TopLevel_OnBackRequested(object? sender, RoutedEventArgs e) { e.Handled = true; diff --git a/StabilityMatrix.Core/Models/Documentation/DocumentationConstants.cs b/StabilityMatrix.Core/Models/Documentation/DocumentationConstants.cs new file mode 100644 index 000000000..3e6782755 --- /dev/null +++ b/StabilityMatrix.Core/Models/Documentation/DocumentationConstants.cs @@ -0,0 +1,46 @@ +namespace StabilityMatrix.Core.Models.Documentation; + +/// +/// Central location for the documentation source repository coordinates. +/// The in-app documentation viewer fetches its content from this repo's docs/ folder. +/// +public static class DocumentationConstants +{ + /// GitHub repository owner. + public const string Owner = "LykosAI"; + + /// GitHub repository name. + public const string Repo = "StabilityMatrix"; + + /// Branch that the docs are read from. + public const string Branch = "main"; + + /// Root folder within the repository that contains the documentation. + public const string DocsRoot = "docs"; + + /// + /// Preferred display order of documentation section folders, matching the docs site navigation. + /// Folders not listed here are appended afterwards in alphabetical order. + /// + public static readonly string[] PreferredSectionOrder = + [ + "getting-started", + "package-manager", + "inference", + "advanced", + "tips", + "troubleshooting", + ]; + + /// + /// Base URL for raw file content, e.g. + /// https://raw.githubusercontent.com/{Owner}/{Repo}/{Branch}/. + /// + public static string RawBaseUrl => $"https://raw.githubusercontent.com/{Owner}/{Repo}/{Branch}/"; + + /// + /// Builds the raw content URL for a path relative to the repository root + /// (e.g. docs/getting-started/overview.md). + /// + public static string GetRawUrl(string repoRelativePath) => RawBaseUrl + repoRelativePath.TrimStart('/'); +} diff --git a/StabilityMatrix.Core/Models/Documentation/DocumentationPage.cs b/StabilityMatrix.Core/Models/Documentation/DocumentationPage.cs new file mode 100644 index 000000000..4651c768f --- /dev/null +++ b/StabilityMatrix.Core/Models/Documentation/DocumentationPage.cs @@ -0,0 +1,15 @@ +namespace StabilityMatrix.Core.Models.Documentation; + +/// +/// A single documentation page discovered in the docs tree. +/// +public record DocumentationPage +{ + /// + /// Path relative to the docs root, e.g. getting-started/overview.md or README.md. + /// + public required string Path { get; init; } + + /// Humanized display title, e.g. "Overview". + public required string Title { get; init; } +} diff --git a/StabilityMatrix.Core/Models/Documentation/DocumentationPathResolver.cs b/StabilityMatrix.Core/Models/Documentation/DocumentationPathResolver.cs new file mode 100644 index 000000000..d4e76069c --- /dev/null +++ b/StabilityMatrix.Core/Models/Documentation/DocumentationPathResolver.cs @@ -0,0 +1,333 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace StabilityMatrix.Core.Models.Documentation; + +/// +/// Pure helpers for the documentation viewer: humanizing file/folder names and +/// resolving links and image paths encountered inside rendered markdown. +/// Kept free of Avalonia/IO dependencies so it can be unit tested in isolation. +/// +public static class DocumentationPathResolver +{ + /// + /// The classification of a link clicked within a rendered markdown page. + /// + public enum LinkKind + { + /// Relative link to another markdown page inside the docs (navigate in-app). + InternalPage, + + /// Absolute http(s) link (open in the system browser). + External, + + /// In-page anchor (e.g. #section) or otherwise unhandled — ignore. + Anchor, + } + + /// + /// Result of classifying/resolving a clicked link. + /// + /// The classification of the link. + /// + /// For , the resolved docs-root-relative page path. + /// For , the absolute URL. + /// For , the bare heading slug (no leading #). + /// + /// + /// For an that carried a #fragment, the bare + /// heading slug to scroll to after the page loads; otherwise null. + /// + public readonly record struct ResolvedLink(LinkKind Kind, string Target, string? Fragment = null); + + /// + /// Produces a GitHub-style anchor slug from heading text: lowercased, with everything + /// except letters, digits, spaces and hyphens removed, and spaces collapsed to hyphens. + /// + /// + /// Duplicate-heading disambiguation (the -1, -2 suffixes GitHub adds) is applied + /// by the caller in document order, not here. + /// + public static string Slugify(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + var builder = new StringBuilder(text.Length); + foreach (var c in text.Trim().ToLowerInvariant()) + { + if (char.IsLetterOrDigit(c) || c == '-') + builder.Append(c); + else if (char.IsWhiteSpace(c)) + builder.Append(' '); + // all other characters (punctuation, parentheses, etc.) are dropped + } + + // Collapse runs of whitespace to single hyphens + var words = builder.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries); + return string.Join('-', words); + } + + /// + /// Converts a docs file or folder name into a human friendly title. + /// Strips a trailing .md, replaces kebab/snake separators with spaces, and title-cases. + /// README maps to "Home". + /// + public static string Humanize(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return string.Empty; + + // Drop a trailing .md extension if present + if (name.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + name = name[..^3]; + + if (name.Equals("README", StringComparison.OrdinalIgnoreCase)) + return "Home"; + + var words = name.Replace('-', ' ') + .Replace('_', ' ') + .Split(' ', StringSplitOptions.RemoveEmptyEntries); + + var builder = new StringBuilder(); + for (var i = 0; i < words.Length; i++) + { + if (i > 0) + builder.Append(' '); + builder.Append(TitleCaseWord(words[i])); + } + + return builder.ToString(); + } + + private static string TitleCaseWord(string word) + { + // Preserve all-caps acronyms (e.g. "GPU", "AMD", "CUDA") + if (word.Length > 1 && word.All(char.IsUpper)) + return word; + + return char.ToUpper(word[0], CultureInfo.InvariantCulture) + word[1..].ToLowerInvariant(); + } + + /// + /// Classifies a clicked link relative to the currently displayed page. + /// + /// + /// Path of the current page relative to the docs root (e.g. advanced/environment-variables.md). + /// + /// The raw href from the markdown link. + /// + /// For , Target is the resolved docs-root-relative path. + /// For , Target is the absolute URL. + /// For , Target is the original href. + /// + public static ResolvedLink ResolveLink(string currentPagePath, string href) + { + if (string.IsNullOrWhiteSpace(href)) + return new ResolvedLink(LinkKind.Anchor, href ?? string.Empty); + + href = href.Trim(); + + // In-page anchor -> carry the bare heading slug as the target + if (href.StartsWith('#')) + return new ResolvedLink(LinkKind.Anchor, Slugify(href.TrimStart('#'))); + + // Absolute http(s) (also covers mailto: etc. -> treat as external / open-in-browser) + if ( + Uri.TryCreate(href, UriKind.Absolute, out var abs) + && ( + abs.Scheme == Uri.UriSchemeHttp + || abs.Scheme == Uri.UriSchemeHttps + || abs.Scheme == Uri.UriSchemeMailto + ) + ) + { + return new ResolvedLink(LinkKind.External, href); + } + + // Split off any anchor fragment from a relative path before resolving the page + string? fragment = null; + var fragmentIndex = href.IndexOf('#'); + if (fragmentIndex >= 0) + { + var rawFragment = href[(fragmentIndex + 1)..]; + if (!string.IsNullOrWhiteSpace(rawFragment)) + fragment = Slugify(rawFragment); + href = href[..fragmentIndex]; + } + + // A bare "#" (or "path#" with no page part) was really an in-page anchor + if (string.IsNullOrEmpty(href)) + return new ResolvedLink(LinkKind.Anchor, fragment ?? string.Empty); + + // Relative markdown page -> resolve against the current page's folder, carrying any fragment + if (href.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + { + var resolved = ResolveRelativePath(currentPagePath, href); + return new ResolvedLink(LinkKind.InternalPage, resolved, fragment); + } + + // Anything else relative (rare) -> treat as external best-effort against raw base + return new ResolvedLink( + LinkKind.External, + DocumentationConstants.GetRawUrl(CombineDocsPath(ResolveRelativePath(currentPagePath, href))) + ); + } + + // Matches markdown image syntax ![alt](src "optional title") + private static readonly Regex ImageRegex = new( + @"!\[(?[^\]]*)\]\((?[^)\s]+)(?[^)]*)\)", + RegexOptions.Compiled + ); + + /// + /// Rewrites relative image sources in a markdown document into absolute raw URLs so they + /// render inside the viewer. Absolute URLs are left untouched. + /// + /// Docs-root-relative path of the current page. + /// The raw markdown content. + public static string RewriteImageUrls(string currentPagePath, string markdown) + { + if (string.IsNullOrEmpty(markdown)) + return markdown; + + return ImageRegex.Replace( + markdown, + match => + { + var alt = match.Groups["alt"].Value; + var src = match.Groups["src"].Value; + var rest = match.Groups["rest"].Value; + var resolved = ResolveImageUrl(currentPagePath, src); + return $"![{alt}]({resolved}{rest})"; + } + ); + } + + // Matches a (non-image) markdown link whose text contains inline code, e.g. [`Home`](../README.md) + private static readonly Regex CodeSpanLinkRegex = new( + @"(?[^\]]*`[^\]]*)\]\((?[^)]+)\)", + RegexOptions.Compiled + ); + + /// + /// Rewrites markdown links whose link text contains inline code spans + /// (e.g. [`Home`](../README.md)) into plain-text links ([Home](../README.md)), + /// because Markdown.Avalonia's inline parser cannot parse code spans inside link text and + /// renders the whole construct literally. Content inside fenced code blocks + /// (```/~~~) is left untouched. + /// + /// The raw markdown content. + public static string RewriteCodeSpanLinks(string markdown) + { + if (string.IsNullOrEmpty(markdown) || !markdown.Contains('`')) + return markdown; + + // Split into lines so fenced code blocks can be skipped; '\n' split keeps any '\r' + // at line ends, and rejoining with '\n' restores the original line endings. + var lines = markdown.Split('\n'); + var inFence = false; + + for (var i = 0; i < lines.Length; i++) + { + var trimmed = lines[i].TrimStart(); + if ( + trimmed.StartsWith("```", StringComparison.Ordinal) + || trimmed.StartsWith("~~~", StringComparison.Ordinal) + ) + { + inFence = !inFence; + continue; + } + + if (inFence) + continue; + + lines[i] = CodeSpanLinkRegex.Replace( + lines[i], + match => + { + var text = match.Groups["text"].Value.Replace("`", string.Empty); + return $"[{text}]({match.Groups["url"].Value})"; + } + ); + } + + return string.Join('\n', lines); + } + + /// + /// Resolves a relative image path in the current page into an absolute raw URL so it renders. + /// + /// Docs-root-relative path of the current page. + /// The raw image src from the markdown. + public static string ResolveImageUrl(string currentPagePath, string src) + { + if (string.IsNullOrWhiteSpace(src)) + return src; + + src = src.Trim(); + + // Already absolute -> leave as-is + if (Uri.TryCreate(src, UriKind.Absolute, out _)) + return src; + + var resolvedDocsRelative = ResolveRelativePath(currentPagePath, src); + return DocumentationConstants.GetRawUrl(CombineDocsPath(resolvedDocsRelative)); + } + + /// + /// Resolves a relative path (e.g. ../images/foo.png) against the folder of the + /// current page and returns a normalized docs-root-relative path (using forward slashes, + /// with any leading ./ and traversal .. segments collapsed). + /// + public static string ResolveRelativePath(string currentPagePath, string relative) + { + relative = relative.Replace('\\', '/'); + + // Absolute-from-docs-root (leading slash) -> relative to docs root + if (relative.StartsWith('/')) + return NormalizeSegments(relative.TrimStart('/').Split('/')); + + var currentDir = GetDirectory(currentPagePath); + var combined = string.IsNullOrEmpty(currentDir) ? relative : currentDir + "/" + relative; + return NormalizeSegments(combined.Split('/')); + } + + private static string NormalizeSegments(string[] segments) + { + var stack = new System.Collections.Generic.List(); + foreach (var segment in segments) + { + if (segment is "" or ".") + continue; + + if (segment == "..") + { + if (stack.Count > 0) + stack.RemoveAt(stack.Count - 1); + continue; + } + + stack.Add(segment); + } + + return string.Join('/', stack); + } + + private static string GetDirectory(string path) + { + path = path.Replace('\\', '/'); + var index = path.LastIndexOf('/'); + return index < 0 ? string.Empty : path[..index]; + } + + /// + /// Prefixes a docs-root-relative path with the repository docs root folder. + /// + private static string CombineDocsPath(string docsRelativePath) => + $"{DocumentationConstants.DocsRoot}/{docsRelativePath.TrimStart('/')}"; +} diff --git a/StabilityMatrix.Core/Models/Documentation/DocumentationSection.cs b/StabilityMatrix.Core/Models/Documentation/DocumentationSection.cs new file mode 100644 index 000000000..7a0fba8aa --- /dev/null +++ b/StabilityMatrix.Core/Models/Documentation/DocumentationSection.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; + +namespace StabilityMatrix.Core.Models.Documentation; + +/// +/// A group of documentation pages that share a section folder (e.g. "Getting Started"). +/// +public record DocumentationSection +{ + /// Humanized section title, e.g. "Getting Started". + public required string Title { get; init; } + + /// + /// The raw section folder name relative to the docs root, e.g. getting-started. + /// Empty string for the root-level section. + /// + public required string FolderName { get; init; } + + /// Pages within this section, in listing order. + public IReadOnlyList Pages { get; init; } = []; +} diff --git a/StabilityMatrix.Core/Models/Settings/Settings.cs b/StabilityMatrix.Core/Models/Settings/Settings.cs index dd0f25ccb..efb871038 100644 --- a/StabilityMatrix.Core/Models/Settings/Settings.cs +++ b/StabilityMatrix.Core/Models/Settings/Settings.cs @@ -260,6 +260,9 @@ public IReadOnlyDictionary EnvironmentVariables public double CivArchiveBrowserResizeFactor { get; set; } = 1.0d; + /// Content zoom factor for the in-app documentation viewer (1.0 = 100%). + public double DocumentationZoomFactor { get; set; } = 1.0d; + public bool CivArchiveBrowserFitCardImages { get; set; } = true; public bool HideEarlyAccessModels { get; set; } diff --git a/StabilityMatrix.Core/Services/DocumentationNotAvailableException.cs b/StabilityMatrix.Core/Services/DocumentationNotAvailableException.cs new file mode 100644 index 000000000..1fe761d02 --- /dev/null +++ b/StabilityMatrix.Core/Services/DocumentationNotAvailableException.cs @@ -0,0 +1,15 @@ +using System; + +namespace StabilityMatrix.Core.Services; + +/// +/// Thrown when the documentation folder is not present in the source repository yet. +/// +public class DocumentationNotAvailableException : Exception +{ + public DocumentationNotAvailableException(string message) + : base(message) { } + + public DocumentationNotAvailableException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/StabilityMatrix.Core/Services/DocumentationService.cs b/StabilityMatrix.Core/Services/DocumentationService.cs new file mode 100644 index 000000000..7293b8709 --- /dev/null +++ b/StabilityMatrix.Core/Services/DocumentationService.cs @@ -0,0 +1,351 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Injectio.Attributes; +using Microsoft.Extensions.Logging; +using Octokit; +using StabilityMatrix.Core.Models.Documentation; +using StabilityMatrix.Core.Models.FileInterfaces; + +namespace StabilityMatrix.Core.Services; + +[RegisterSingleton] +public class DocumentationService( + ILogger logger, + IGitHubClient gitHubClient, + IHttpClientFactory httpClientFactory +) : IDocumentationService +{ + private static readonly TimeSpan CacheTtl = TimeSpan.FromDays(1); + + private const string TreeCacheFileName = "docs-tree.md"; + + private static DirectoryPath CacheDir => + new(Path.Combine(Path.GetTempPath(), "StabilityMatrix", "Cache", "Docs")); + + /// + public async Task> GetSectionsAsync( + bool forceRefresh = false, + CancellationToken cancellationToken = default + ) + { + var cacheFile = CacheDir.JoinFile(TreeCacheFileName); + + // Fetch fresh listing, falling back to any cached copy on failure. + List? paths = null; + + if (!forceRefresh && IsFresh(cacheFile)) + { + paths = await ReadCachedPathsAsync(cacheFile, cancellationToken).ConfigureAwait(false); + } + + if (paths is null) + { + try + { + paths = await FetchDocsPathsAsync(cancellationToken).ConfigureAwait(false); + try + { + await WriteCachedPathsAsync(cacheFile, paths, cancellationToken).ConfigureAwait(false); + } + catch (Exception cacheEx) when (cacheEx is not OperationCanceledException) + { + logger.LogWarning(cacheEx, "Failed to write docs tree to cache"); + } + } + catch (DocumentationNotAvailableException) + { + throw; + } + catch (Exception e) when (e is not OperationCanceledException) + { + logger.LogWarning(e, "Failed to fetch docs tree, attempting to use cached copy"); + paths = await ReadCachedPathsAsync(cacheFile, cancellationToken).ConfigureAwait(false); + if (paths is null) + throw; + } + } + + return BuildSections(paths); + } + + /// + public async Task GetPageMarkdownAsync( + string docsRelativePath, + bool forceRefresh = false, + CancellationToken cancellationToken = default + ) + { + var cacheFile = CacheDir.JoinFile(GetCacheFileName(docsRelativePath)); + + if (!forceRefresh && IsFresh(cacheFile)) + { + try + { + return await cacheFile.ReadAllTextAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception e) when (e is not OperationCanceledException) + { + logger.LogWarning(e, "Failed to read cached markdown for {Path}", docsRelativePath); + } + } + + var url = DocumentationConstants.GetRawUrl($"{DocumentationConstants.DocsRoot}/{docsRelativePath}"); + + try + { + using var client = httpClientFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(30); + var markdown = await client.GetStringAsync(url, cancellationToken).ConfigureAwait(false); + + try + { + CacheDir.Create(); + await cacheFile.WriteAllTextAsync(markdown, cancellationToken).ConfigureAwait(false); + } + catch (Exception cacheEx) when (cacheEx is not OperationCanceledException) + { + logger.LogWarning(cacheEx, "Failed to write markdown to cache for {Path}", docsRelativePath); + } + + return markdown; + } + catch (Exception e) when (e is not OperationCanceledException) + { + logger.LogWarning( + e, + "Failed to fetch markdown for {Path}, attempting cached copy", + docsRelativePath + ); + + if (cacheFile.Exists) + return await cacheFile.ReadAllTextAsync(cancellationToken).ConfigureAwait(false); + + throw; + } + } + + private async Task> FetchDocsPathsAsync(CancellationToken cancellationToken) + { + // Octokit calls don't take a CancellationToken; the tree response is small and + // the surrounding cache logic still honors cancellation. + TreeResponse tree; + try + { + tree = await gitHubClient + .Git.Tree.GetRecursive( + DocumentationConstants.Owner, + DocumentationConstants.Repo, + DocumentationConstants.Branch + ) + .ConfigureAwait(false); + } + catch (NotFoundException e) + { + throw new DocumentationNotAvailableException("Documentation repository or branch not found.", e); + } + + cancellationToken.ThrowIfCancellationRequested(); + + var prefix = DocumentationConstants.DocsRoot + "/"; + + var paths = tree + .Tree.Where(item => + item.Type == TreeType.Blob && item.Path.StartsWith(prefix, StringComparison.Ordinal) + ) + .Select(item => item.Path[prefix.Length..]) + .Where(IsDocPage) + .ToList(); + + if (paths.Count == 0) + throw new DocumentationNotAvailableException( + "Documentation folder is empty or not available yet." + ); + + return paths; + } + + /// + /// Whether a docs-relative path should be shown as a navigable page. + /// Excludes images, .gitkeep placeholders, and non-markdown files. + /// + private static bool IsDocPage(string docsRelativePath) + { + if (docsRelativePath.StartsWith("images/", StringComparison.OrdinalIgnoreCase)) + return false; + + var lastSlash = docsRelativePath.LastIndexOf('/'); + var fileName = lastSlash < 0 ? docsRelativePath : docsRelativePath[(lastSlash + 1)..]; + if (fileName.Equals(".gitkeep", StringComparison.OrdinalIgnoreCase)) + return false; + + return fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Groups discovered docs paths into ordered sections, with the root README first. + /// + internal static IReadOnlyList BuildSections(IReadOnlyList docsRelativePaths) + { + var rootPages = new List(); + var sections = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var sectionOrder = new List(); + + foreach (var path in docsRelativePaths) + { + var separatorIndex = path.IndexOf('/'); + if (separatorIndex < 0) + { + // Root-level page + rootPages.Add( + new DocumentationPage { Path = path, Title = DocumentationPathResolver.Humanize(path) } + ); + continue; + } + + var folder = path[..separatorIndex]; + var fileName = path[(path.LastIndexOf('/') + 1)..]; + + if (!sections.TryGetValue(folder, out var list)) + { + list = []; + sections[folder] = list; + sectionOrder.Add(folder); + } + + list.Add( + new DocumentationPage { Path = path, Title = DocumentationPathResolver.Humanize(fileName) } + ); + } + + var result = new List(); + + // Root README first, then any other root-level pages, all under an unnamed root section. + if (rootPages.Count > 0) + { + var orderedRoot = rootPages + .OrderByDescending(p => p.Path.Equals("README.md", StringComparison.OrdinalIgnoreCase)) + .ThenBy(p => p.Title, StringComparer.OrdinalIgnoreCase) + .ToList(); + + result.Add( + new DocumentationSection + { + Title = string.Empty, + FolderName = string.Empty, + Pages = orderedRoot, + } + ); + } + + foreach (var folder in OrderSections(sectionOrder)) + { + var pages = OrderSectionPages(sections[folder]); + + result.Add( + new DocumentationSection + { + Title = DocumentationPathResolver.Humanize(folder), + FolderName = folder, + Pages = pages, + } + ); + } + + return result; + } + + /// + /// Orders section folders by , + /// with any folder not in that list appended afterwards alphabetically. + /// + private static IEnumerable OrderSections(IEnumerable folders) + { + return folders.OrderBy(GetPreferredSectionIndex).ThenBy(f => f, StringComparer.OrdinalIgnoreCase); + } + + private static int GetPreferredSectionIndex(string folder) + { + var order = DocumentationConstants.PreferredSectionOrder; + for (var i = 0; i < order.Length; i++) + { + if (string.Equals(order[i], folder, StringComparison.OrdinalIgnoreCase)) + return i; + } + + // Unknown folders sort after all known ones (then alphabetically via ThenBy). + return order.Length; + } + + /// + /// Orders pages within a section: overview.md first (matched on file name), then the + /// remaining pages alphabetically by title. + /// + private static List OrderSectionPages(IEnumerable pages) + { + return pages + .OrderByDescending(p => IsOverviewPage(p.Path)) + .ThenBy(p => p.Title, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static bool IsOverviewPage(string docsRelativePath) + { + var lastSlash = docsRelativePath.LastIndexOf('/'); + var fileName = lastSlash < 0 ? docsRelativePath : docsRelativePath[(lastSlash + 1)..]; + return fileName.Equals("overview.md", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsFresh(FilePath cacheFile) => + cacheFile.Exists && DateTime.UtcNow - File.GetLastWriteTimeUtc(cacheFile) < CacheTtl; + + private static async Task?> ReadCachedPathsAsync(FilePath cacheFile, CancellationToken ct) + { + try + { + if (!cacheFile.Exists) + return null; + + var text = await cacheFile.ReadAllTextAsync(ct).ConfigureAwait(false); + return text.Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Trim()) + .Where(line => line.Length > 0) + .ToList(); + } + catch (Exception e) when (e is not OperationCanceledException) + { + // Unreadable/corrupt cache — treat as a miss so callers fall back to the network. + return null; + } + } + + private static async Task WriteCachedPathsAsync( + FilePath cacheFile, + List paths, + CancellationToken ct + ) + { + CacheDir.Create(); + await cacheFile.WriteAllTextAsync(string.Join('\n', paths), ct).ConfigureAwait(false); + } + + /// + /// Maps a docs-relative path to a safe flat cache file name. + /// + private static string GetCacheFileName(string docsRelativePath) + { + var builder = new StringBuilder(docsRelativePath.Length); + foreach (var c in docsRelativePath) + { + builder.Append(c is '/' or '\\' ? '_' : c); + } + + return "page_" + builder; + } +} diff --git a/StabilityMatrix.Core/Services/IDocumentationService.cs b/StabilityMatrix.Core/Services/IDocumentationService.cs new file mode 100644 index 000000000..b1574c495 --- /dev/null +++ b/StabilityMatrix.Core/Services/IDocumentationService.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using StabilityMatrix.Core.Models.Documentation; + +namespace StabilityMatrix.Core.Services; + +/// +/// Fetches and caches the in-app documentation content from the docs repository. +/// +public interface IDocumentationService +{ + /// + /// Gets the documentation navigation tree, grouped into sections. + /// The root README.md (if present) is returned as the first entry of the + /// root section so callers can treat it as the landing page. + /// + /// + /// Thrown when the docs folder does not exist in the source repository. + /// + Task> GetSectionsAsync( + bool forceRefresh = false, + CancellationToken cancellationToken = default + ); + + /// + /// Gets the raw markdown for a page path relative to the docs root + /// (e.g. getting-started/overview.md). + /// + Task GetPageMarkdownAsync( + string docsRelativePath, + bool forceRefresh = false, + CancellationToken cancellationToken = default + ); +} diff --git a/StabilityMatrix.Tests/Core/DocumentationPathResolverTests.cs b/StabilityMatrix.Tests/Core/DocumentationPathResolverTests.cs new file mode 100644 index 000000000..b8f091e56 --- /dev/null +++ b/StabilityMatrix.Tests/Core/DocumentationPathResolverTests.cs @@ -0,0 +1,240 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using StabilityMatrix.Core.Models.Documentation; + +namespace StabilityMatrix.Tests.Core; + +[TestClass] +public class DocumentationPathResolverTests +{ + [DataTestMethod] + [DataRow("overview.md", "Overview")] + [DataRow("environment-variables.md", "Environment Variables")] + [DataRow("first-launch", "First Launch")] + [DataRow("getting-started", "Getting Started")] + [DataRow("data_directory", "Data Directory")] + [DataRow("README.md", "Home")] + [DataRow("README", "Home")] + public void Humanize_ProducesTitleCase(string input, string expected) + { + Assert.AreEqual(expected, DocumentationPathResolver.Humanize(input)); + } + + [TestMethod] + public void Humanize_PreservesAcronyms() + { + Assert.AreEqual("GPU Backends", DocumentationPathResolver.Humanize("GPU-backends")); + } + + [TestMethod] + public void ResolveLink_SiblingMarkdown_ResolvesRelativeToCurrentFolder() + { + var result = DocumentationPathResolver.ResolveLink( + "advanced/environment-variables.md", + "overview.md" + ); + + Assert.AreEqual(DocumentationPathResolver.LinkKind.InternalPage, result.Kind); + Assert.AreEqual("advanced/overview.md", result.Target); + } + + [TestMethod] + public void ResolveLink_ParentMarkdown_ResolvesTraversal() + { + var result = DocumentationPathResolver.ResolveLink( + "advanced/environment-variables.md", + "../README.md" + ); + + Assert.AreEqual(DocumentationPathResolver.LinkKind.InternalPage, result.Kind); + Assert.AreEqual("README.md", result.Target); + } + + [TestMethod] + public void ResolveLink_MarkdownFromRoot_ResolvesIntoSubfolder() + { + var result = DocumentationPathResolver.ResolveLink("README.md", "getting-started/overview.md"); + + Assert.AreEqual(DocumentationPathResolver.LinkKind.InternalPage, result.Kind); + Assert.AreEqual("getting-started/overview.md", result.Target); + } + + [TestMethod] + public void ResolveLink_MarkdownWithFragment_StripsFragment() + { + var result = DocumentationPathResolver.ResolveLink( + "advanced/environment-variables.md", + "overview.md#setting-variables" + ); + + Assert.AreEqual(DocumentationPathResolver.LinkKind.InternalPage, result.Kind); + Assert.AreEqual("advanced/overview.md", result.Target); + } + + [TestMethod] + public void ResolveLink_External_IsClassifiedExternal() + { + var result = DocumentationPathResolver.ResolveLink( + "advanced/environment-variables.md", + "https://docs.pytorch.org/docs/stable/torch_environment_variables.html" + ); + + Assert.AreEqual(DocumentationPathResolver.LinkKind.External, result.Kind); + Assert.AreEqual( + "https://docs.pytorch.org/docs/stable/torch_environment_variables.html", + result.Target + ); + } + + [TestMethod] + public void ResolveLink_Anchor_IsClassifiedAnchor() + { + var result = DocumentationPathResolver.ResolveLink( + "advanced/environment-variables.md", + "#common-variables" + ); + + Assert.AreEqual(DocumentationPathResolver.LinkKind.Anchor, result.Kind); + // Target is the bare slug (no leading '#'). + Assert.AreEqual("common-variables", result.Target); + } + + [TestMethod] + public void ResolveLink_AnchorWithMixedCaseAndPunctuation_SlugifiesTarget() + { + var result = DocumentationPathResolver.ResolveLink("advanced/overview.md", "#Apple Silicon (MPS)"); + + Assert.AreEqual(DocumentationPathResolver.LinkKind.Anchor, result.Kind); + Assert.AreEqual("apple-silicon-mps", result.Target); + } + + [TestMethod] + public void ResolveLink_MarkdownWithFragment_CarriesSlugFragment() + { + var result = DocumentationPathResolver.ResolveLink( + "advanced/environment-variables.md", + "overview.md#Setting Variables" + ); + + Assert.AreEqual(DocumentationPathResolver.LinkKind.InternalPage, result.Kind); + Assert.AreEqual("advanced/overview.md", result.Target); + Assert.AreEqual("setting-variables", result.Fragment); + } + + [TestMethod] + public void ResolveLink_MarkdownWithoutFragment_HasNullFragment() + { + var result = DocumentationPathResolver.ResolveLink("README.md", "getting-started/overview.md"); + + Assert.AreEqual(DocumentationPathResolver.LinkKind.InternalPage, result.Kind); + Assert.IsNull(result.Fragment); + } + + [DataTestMethod] + [DataRow("AMD (ROCm)", "amd-rocm")] + [DataRow("Apple Silicon (MPS)", "apple-silicon-mps")] + [DataRow("Common Variables", "common-variables")] + [DataRow("Already-Slugged", "already-slugged")] + [DataRow(" Trailing / Slashes! ", "trailing-slashes")] + [DataRow("Multiple Spaces", "multiple-spaces")] + public void Slugify_ProducesGitHubStyleSlug(string input, string expected) + { + Assert.AreEqual(expected, DocumentationPathResolver.Slugify(input)); + } + + [TestMethod] + public void RewriteCodeSpanLinks_FoldsCodeSpanLinkText() + { + const string markdown = "[`Home`](../README.md) > [`Section Overview`](overview.md)"; + + var result = DocumentationPathResolver.RewriteCodeSpanLinks(markdown); + + Assert.AreEqual("[Home](../README.md) > [Section Overview](overview.md)", result); + } + + [TestMethod] + public void RewriteCodeSpanLinks_MultipleCodeSpansInOneLink_StripsAllBackticks() + { + const string markdown = "[`a` and `b`](target.md)"; + + var result = DocumentationPathResolver.RewriteCodeSpanLinks(markdown); + + Assert.AreEqual("[a and b](target.md)", result); + } + + [TestMethod] + public void RewriteCodeSpanLinks_InsideFencedCodeBlock_IsUntouched() + { + const string markdown = "before\n\n```md\n[`Home`](../README.md)\n```\n\n[`Home`](../README.md)\n"; + + var result = DocumentationPathResolver.RewriteCodeSpanLinks(markdown); + + // The occurrence inside the fence is untouched; the one outside is rewritten. + StringAssert.Contains(result, "```md\n[`Home`](../README.md)\n```"); + StringAssert.Contains(result, "\n\n[Home](../README.md)\n"); + } + + [TestMethod] + public void RewriteCodeSpanLinks_PlainLinksAndText_AreUntouched() + { + const string markdown = "[Home](../README.md) with `inline code` and ![`alt`](img.png) image\n"; + + var result = DocumentationPathResolver.RewriteCodeSpanLinks(markdown); + + Assert.AreEqual(markdown, result); + } + + [TestMethod] + public void ResolveImageUrl_RelativeParentPath_ResolvesToRawUrl() + { + var result = DocumentationPathResolver.ResolveImageUrl( + "advanced/environment-variables.md", + "../images/advanced/envar-window.png" + ); + + Assert.AreEqual( + "https://raw.githubusercontent.com/LykosAI/StabilityMatrix/main/docs/images/advanced/envar-window.png", + result + ); + } + + [TestMethod] + public void ResolveImageUrl_AbsoluteUrl_IsUnchanged() + { + const string absolute = "https://example.com/foo.png"; + var result = DocumentationPathResolver.ResolveImageUrl("advanced/overview.md", absolute); + + Assert.AreEqual(absolute, result); + } + + [TestMethod] + public void RewriteImageUrls_RewritesOnlyRelativeImages() + { + const string markdown = + "Text\n\n![editor](../images/advanced/envar-window.png)\n\n![remote](https://example.com/x.png)\n"; + + var result = DocumentationPathResolver.RewriteImageUrls( + "advanced/environment-variables.md", + markdown + ); + + StringAssert.Contains( + result, + "![editor](https://raw.githubusercontent.com/LykosAI/StabilityMatrix/main/docs/images/advanced/envar-window.png)" + ); + // Absolute image left untouched + StringAssert.Contains(result, "![remote](https://example.com/x.png)"); + } + + [TestMethod] + public void RewriteImageUrls_PreservesImageTitle() + { + const string markdown = "![alt](img/pic.png \"A title\")"; + + var result = DocumentationPathResolver.RewriteImageUrls("getting-started/overview.md", markdown); + + StringAssert.Contains( + result, + "https://raw.githubusercontent.com/LykosAI/StabilityMatrix/main/docs/getting-started/img/pic.png \"A title\"" + ); + } +} diff --git a/StabilityMatrix.Tests/Core/DocumentationServiceBuildSectionsTests.cs b/StabilityMatrix.Tests/Core/DocumentationServiceBuildSectionsTests.cs new file mode 100644 index 000000000..7bd6631c9 --- /dev/null +++ b/StabilityMatrix.Tests/Core/DocumentationServiceBuildSectionsTests.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Tests.Core; + +[TestClass] +public class DocumentationServiceBuildSectionsTests +{ + private static readonly List SamplePaths = + [ + "README.md", + "changelog.md", + "advanced/environment-variables.md", + "advanced/overview.md", + "getting-started/installation.md", + "getting-started/overview.md", + "inference/overview.md", + "troubleshooting/faq.md", + "zzz-extra/page.md", + ]; + + [TestMethod] + public void BuildSections_OrdersSectionsByPreferredOrder_UnknownLast() + { + var sections = DocumentationService.BuildSections(SamplePaths); + + // Root (empty-title) section is first, followed by folders in preferred order, + // then unknown folders alphabetically. + var folderNames = sections.Select(s => s.FolderName).ToList(); + + CollectionAssert.AreEqual( + new[] + { + string.Empty, // root + "getting-started", + "inference", + "advanced", + "troubleshooting", + "zzz-extra", // unknown -> last + }, + folderNames + ); + } + + [TestMethod] + public void BuildSections_OverviewIsFirstWithinSection() + { + var sections = DocumentationService.BuildSections(SamplePaths); + + var advanced = sections.Single(s => s.FolderName == "advanced"); + Assert.AreEqual("advanced/overview.md", advanced.Pages[0].Path); + Assert.AreEqual("advanced/environment-variables.md", advanced.Pages[1].Path); + + var gettingStarted = sections.Single(s => s.FolderName == "getting-started"); + Assert.AreEqual("getting-started/overview.md", gettingStarted.Pages[0].Path); + Assert.AreEqual("getting-started/installation.md", gettingStarted.Pages[1].Path); + } + + [TestMethod] + public void BuildSections_RootReadmeIsFirst() + { + var sections = DocumentationService.BuildSections(SamplePaths); + + var root = sections.First(); + Assert.AreEqual(string.Empty, root.FolderName); + Assert.AreEqual("README.md", root.Pages[0].Path); + // Remaining root pages are alphabetical by title. + Assert.AreEqual("changelog.md", root.Pages[1].Path); + } + + [TestMethod] + public void BuildSections_UnknownFoldersSortedAlphabeticallyAfterKnown() + { + var paths = new List + { + "zebra/overview.md", + "alpha/overview.md", + "getting-started/overview.md", + }; + + var sections = DocumentationService.BuildSections(paths); + + CollectionAssert.AreEqual( + new[] { "getting-started", "alpha", "zebra" }, + sections.Select(s => s.FolderName).ToArray() + ); + } +}