From a5df0c7b4276b49dadb158488f2b3b4c2b03ab57 Mon Sep 17 00:00:00 2001 From: Jan Ruhlaender Date: Tue, 10 Nov 2026 18:53:57 +0100 Subject: [PATCH 1/6] feat(dashboard): fold groups and limit the tiles they show Group labels become buttons that fold a group to its label and tile count. The front matter options collapsed and limit set which groups start folded and how many tiles a group shows before a Show more button. The filter still searches every tile, and the reader's choices stay in the browser per page. --- Docs/Features/Dashboard-Example.md | 3 + Docs/Features/Dashboard.md | 10 ++ .../Markdown.Renderer.Test/DashboardTest.cs | 64 +++++++++++ Source/Markdown.Renderer/Dashboard.cs | 101 ++++++++++++++-- .../Markdown.Renderer/Res/Css/dashboard.css | 108 ++++++++++++++++++ Source/Markdown.Renderer/Res/Js/dashboard.js | 75 +++++++++++- 6 files changed, 347 insertions(+), 14 deletions(-) diff --git a/Docs/Features/Dashboard-Example.md b/Docs/Features/Dashboard-Example.md index 70e32d23..06fa539a 100644 --- a/Docs/Features/Dashboard-Example.md +++ b/Docs/Features/Dashboard-Example.md @@ -1,5 +1,8 @@ --- layout: dashboard +dashboard: + limit: 2 + collapsed: [Icons] navigation: visible: false --- diff --git a/Docs/Features/Dashboard.md b/Docs/Features/Dashboard.md index 8b796cdb..a4e77b57 100644 --- a/Docs/Features/Dashboard.md +++ b/Docs/Features/Dashboard.md @@ -100,6 +100,8 @@ dashboard: clock: true status: false open: same-tab + limit: 6 + collapsed: [AI, Social] --- ``` @@ -109,6 +111,14 @@ dashboard: | `clock` | `true`, `false` | `true` | Shows the time and date next to the filter field in the reader's language. | | `status` | `true`, `false` | `false` | Adds a dot to each tile that turns green when the browser reaches the site and red when it does not. | | `open` | `same-tab`, `new-tab` | `same-tab` | Where links to other sites open. Links within the site always open in the same tab. | +| `limit` | A number | none | Each group shows its first tiles up to this number and a **Show more** button for the rest. | +| `collapsed` | `true`, a heading, or a list of headings | none | Groups that start folded. Their names match the group headings, ignoring case; `true` folds every group. | + +## Fold groups + +A dashboard can hold everything you need without showing it all at once. Select a group label to fold the group to its label; a folded label shows the number of its tiles. With `limit`, a group shows only its first tiles, and **Show more** reveals the rest; when only one tile would be left over, the group shows it instead. The filter always searches every tile, including those in folded groups and past the limit, and while it has text, every group shows its matches. Selecting a group label while filtering clears the filter and opens that group. + +What you fold or open stays in your browser for this page, so `collapsed` and `limit` set only the start. A good start page keeps the links you use daily in open groups at the top, and the rest in folded groups below. ## Behavior diff --git a/Source/Markdown.Renderer.Test/DashboardTest.cs b/Source/Markdown.Renderer.Test/DashboardTest.cs index 7324a9bf..3f421591 100644 --- a/Source/Markdown.Renderer.Test/DashboardTest.cs +++ b/Source/Markdown.Renderer.Test/DashboardTest.cs @@ -35,6 +35,70 @@ public void ReadsTheOptionsOnlyForADashboardLayout() Dashboard.ReadFrontMatter("---\nlayout: dashboard\ndashboard:\n search: javascript:alert({query})\n---")!.SearchUrl); } + [Fact] + public void ReadsTheLimitAndTheCollapsedGroups() + { + var options = Dashboard.ReadFrontMatter(""" + --- + layout: dashboard + dashboard: + limit: 6 + collapsed: [AI, " Social "] + --- + """)!; + Assert.Equal(6, options.Limit); + Assert.Equal(["AI", "Social"], options.Collapsed); + Assert.True(options.StartsCollapsed("ai")); + Assert.False(options.StartsCollapsed("Developer")); + + var all = Dashboard.ReadFrontMatter("---\nlayout: dashboard\ndashboard:\n collapsed: true\n limit: 0\n---")!; + Assert.Null(all.Limit); + Assert.True(all.StartsCollapsed("Anything")); + + Assert.Equal(["Social"], Dashboard.ReadFrontMatter("---\nlayout: dashboard\ndashboard:\n collapsed: Social\n---")!.Collapsed); + var none = Dashboard.ReadFrontMatter("---\nlayout: dashboard\ndashboard:\n collapsed: false\n limit: many\n---")!; + Assert.Null(none.Collapsed); + Assert.Null(none.Limit); + } + + [Fact] + public async Task FoldsGroupsAndLimitsTheirTiles() + { + var html = await RenderPage(""" + ## Office + + - [Mail](https://mail.example.org) + - [Drive](https://drive.example.org) + + ### Archive + + - [Old Wiki](https://wiki.example.org) + - [Photos](https://photos.example.org) + + ## Tools + + - [Editor](https://editor.example.org) + - [Shell](https://shell.example.org) + - [Notes](https://notes.example.org) + + ## [AI](https://ai.example.org) + + - [Claude](https://claude.ai) + """, Options with { Limit = 2, Collapsed = ["ai"] }); + + Assert.Contains("
", html); + Assert.Contains("
", html); + Assert.Contains("

", html); + Assert.DoesNotContain('\u0001', html); + } + [Fact] public async Task RendersGroupsOfLinkTiles() { diff --git a/Source/Markdown.Renderer/Dashboard.cs b/Source/Markdown.Renderer/Dashboard.cs index bc95fd90..59d36981 100644 --- a/Source/Markdown.Renderer/Dashboard.cs +++ b/Source/Markdown.Renderer/Dashboard.cs @@ -15,7 +15,19 @@ namespace Markdown.Renderer; /// /// The options of a page with layout: dashboard in its front matter. /// -public sealed record DashboardOptions(string? SearchUrl, bool Clock, bool Status, bool NewTab); +/// The number of tiles a group shows before a button reveals the rest, or null for all tiles. +/// The groups that start collapsed: the text of their headings, or * for every group. +public sealed record DashboardOptions( + string? SearchUrl, + bool Clock, + bool Status, + bool NewTab, + int? Limit = null, + IReadOnlyList? Collapsed = null) +{ + internal bool StartsCollapsed(string heading) => + Collapsed is not null && Collapsed.Any(name => name == "*" || string.Equals(name, heading, StringComparison.OrdinalIgnoreCase)); +} /// /// Renders a page with layout: dashboard as a start page: every second-level heading becomes a group and every @@ -88,7 +100,26 @@ public static class Dashboard ReadSearchUrl(options is null ? null : ReadScalar(options, "search")), ReadBoolean(options, "clock", true), ReadBoolean(options, "status", false), - string.Equals(options is null ? null : ReadScalar(options, "open")?.Trim(), "new-tab", StringComparison.OrdinalIgnoreCase)); + string.Equals(options is null ? null : ReadScalar(options, "open")?.Trim(), "new-tab", StringComparison.OrdinalIgnoreCase), + int.TryParse(options is null ? null : ReadScalar(options, "limit")?.Trim(), out var limit) && limit > 0 ? limit : null, + ReadCollapsed(options)); + } + + /// + /// Reads collapsed: true for every group, one heading, or a list of headings. + /// + private static IReadOnlyList? ReadCollapsed(YamlMappingNode? options) + { + if (options is null || !options.Children.TryGetValue(new YamlScalarNode("collapsed"), out var node)) return null; + var names = node switch + { + YamlScalarNode { Value: { } value } when bool.TryParse(value.Trim(), out var all) => all ? ["*"] : [], + YamlScalarNode { Value: { } value } => [value], + YamlSequenceNode sequence => sequence.Children.OfType().Select(item => item.Value ?? string.Empty).ToList(), + _ => new List() + }; + var trimmed = names.Select(name => name.Trim()).Where(name => name.Length > 0).ToList(); + return trimmed.Count == 0 ? null : trimmed; } public static string Render(MarkdownDocument document, MarkdownPipeline pipeline, DashboardOptions options) @@ -126,32 +157,51 @@ private static string RenderGroups(IEnumerable blocks, MarkdownPipeline p { var html = new StringBuilder(); html.AppendLine("
"); + GroupState? group = null; var groupOpen = false; var tileIndex = 0; + + void CloseGroup() + { + if (group is null) return; + // The heading names the number of tiles, which the group only knows at its end. + html.Replace(GroupState.CountToken, group.Tiles.ToString(System.Globalization.CultureInfo.InvariantCulture), group.Start, html.Length - group.Start); + var more = group.Tiles - (options.Limit ?? group.Tiles); + // A button for a single tile would take the tile's place, so the tile shows instead. + if (more == 1) + html.Replace("
  • 1) + html.AppendLine($"
    "); + html.AppendLine("
  • "); + group = null; + } + foreach (var block in blocks) { switch (block) { case ThematicBreakBlock: - if (groupOpen) html.AppendLine("
    "); + CloseGroup(); groupOpen = false; html.AppendLine(""); html.AppendLine("
    "); break; case HeadingBlock { Level: 2 } heading: - if (groupOpen) html.AppendLine(""); + CloseGroup(); groupOpen = true; - html.AppendLine("
    "); + var collapsed = options.StartsCollapsed(PlainText(heading.Inline ?? Enumerable.Empty()).Trim()); + group = new GroupState(html.Length); + html.AppendLine($"
    "); html.AppendLine("
    "); - html.Append(RenderBlock(heading, pipeline)); + html.Append(RenderGroupHeading(RenderBlock(heading, pipeline), collapsed)); html.AppendLine("
    "); break; case ListBlock list: - if (groupOpen) html.Append(RenderTiles(list, pipeline, options, usedIds, ref tileIndex)); + if (groupOpen) html.Append(RenderTiles(list, pipeline, options, usedIds, ref tileIndex, group)); else { html.AppendLine("
    "); - html.Append(RenderTiles(list, pipeline, options, usedIds, ref tileIndex)); + html.Append(RenderTiles(list, pipeline, options, usedIds, ref tileIndex, null)); html.AppendLine("
    "); } break; @@ -165,11 +215,38 @@ private static string RenderGroups(IEnumerable blocks, MarkdownPipeline p } } - if (groupOpen) html.AppendLine("
    "); + CloseGroup(); html.AppendLine("
    "); return html.ToString(); } + /// + /// Turns the text of a group heading into a button that folds the group. The heading keeps its id, so links to it + /// still work; links inside the heading keep only their text, because a button cannot contain a link. + /// + private static string RenderGroupHeading(string headingHtml, bool collapsed) + { + var start = headingHtml.IndexOf('>') + 1; + var end = headingHtml.LastIndexOf("", StringComparison.Ordinal); + if (start <= 0 || end < start) return headingHtml; + var content = AnchorTagRegex.Replace(headingHtml[start..end], string.Empty).Trim(); + return headingHtml[..start] + + $"" + + headingHtml[end..]; + } + + /// The group being written: where it starts in the output and how many tiles it has so far. + private sealed class GroupState(int start) + { + public const string CountToken = "\u0001tiles\u0001"; + public int Start { get; } = start; + public int Tiles { get; set; } + } + private static HashSet ReadHeadingIds(IEnumerable headings, MarkdownPipeline pipeline) { var rendered = Render(renderer => @@ -209,7 +286,7 @@ private static string RenderToolbar(DashboardOptions options, bool hasGroups) return html.ToString(); } - private static string RenderTiles(ListBlock list, MarkdownPipeline pipeline, DashboardOptions options, HashSet usedIds, ref int tileIndex) + private static string RenderTiles(ListBlock list, MarkdownPipeline pipeline, DashboardOptions options, HashSet usedIds, ref int tileIndex, GroupState? group) { var tiles = list.OfType().Select(item => ReadTile(item, pipeline)).ToList(); if (tiles.Count == 0 || tiles.All(tile => tile is null)) @@ -240,7 +317,9 @@ private static string RenderTiles(ListBlock list, MarkdownPipeline pipeline, Das do id = $"dashboard-tile-{tileIndex++}"; while (!usedIds.Add(id)); - html.Append("
  • "); + // Tiles past the limit stay in the page for the filter and appear when the reader asks for them. + var overflow = group is not null && ++group.Tiles > (options.Limit ?? int.MaxValue); + html.Append($"
  • "); html.Append($"
    "); html.Append($" .dashboard-group-toggle) { + padding: 0 0 0.35rem; +} + +.dashboard-group-toggle { + display: flex; + gap: 0.5rem; + align-items: center; + width: 100%; + margin: -0.25rem 0 0; + padding: 0.25rem 0.6rem; + border: 0; + border-radius: 6px; + background: none; + color: inherit; + font: inherit; + letter-spacing: inherit; + text-align: left; + text-transform: inherit; + cursor: pointer; +} + +.dashboard-group-toggle:hover { + color: var(--text-primary); +} + +.dashboard-group-title { + min-width: 0; + overflow-wrap: anywhere; +} + +.dashboard-group-count { + display: none; + padding: 0 0.4rem; + border-radius: 999px; + background: var(--dashboard-chip); + font-variant-numeric: tabular-nums; + letter-spacing: 0; +} + +.dashboard-group-count::before { + content: attr(data-count); +} + +.dashboard-group-chevron { + flex: none; + width: 0.9rem; + height: 0.9rem; + margin-left: auto; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.75; + transition: transform 160ms ease, opacity 160ms ease; +} + +@media (hover: hover) { + .dashboard-group-chevron { + opacity: 0; + } + + .dashboard-group-toggle:is(:hover, :focus-visible) .dashboard-group-chevron, + .dashboard-group.is-collapsed .dashboard-group-chevron { + opacity: 1; + } +} + +.dashboard:not(.is-filtering) .dashboard-group.is-collapsed .dashboard-group-count { + display: inline-block; +} + +.dashboard:not(.is-filtering) .dashboard-group.is-collapsed .dashboard-group-chevron { + transform: rotate(-90deg); +} + +/* Without a filter, a folded group shows only its label and a limited group only its first tiles. While the filter + has text, every group shows its matches. */ +.dashboard:not(.is-filtering) .dashboard-group.is-collapsed > :not(:first-child), +.dashboard:not(.is-filtering) .dashboard-group:not(.is-expanded) .dashboard-tile.is-overflow, +.dashboard.is-filtering .dashboard-more-row { + display: none; +} + +.dashboard-more { + margin: 0.25rem 0 0; + padding: 0.35rem 0.6rem; + border: 0; + border-radius: 6px; + background: none; + color: var(--dashboard-muted); + font: inherit; + font-size: 0.8125rem; + cursor: pointer; +} + +.dashboard-more:hover { + color: var(--text-primary); + background: var(--dashboard-hover); +} + +:root:not([data-input="pointer"]) :is(.dashboard-group-toggle, .dashboard-more):focus-visible { + outline: 2px solid var(--link); + outline-offset: 2px; +} + /* Headings below the group label, from the third level down, divide a group: smaller than a tile title, in full text color and sentence case so they read below the group label, with a dashed rule that stays quieter than the label's solid line. */ .markdown-content .dashboard-group :is(h3, h4, h5, h6) { diff --git a/Source/Markdown.Renderer/Res/Js/dashboard.js b/Source/Markdown.Renderer/Res/Js/dashboard.js index 455830f7..e1ce0742 100644 --- a/Source/Markdown.Renderer/Res/Js/dashboard.js +++ b/Source/Markdown.Renderer/Res/Js/dashboard.js @@ -69,6 +69,52 @@ }; }); + // Groups fold with their heading, and a group with a limit shows the rest of its tiles on request. The reader's choice + // stays in this browser for this page; the page works the same without storage. + const foldKey = `mdu-dashboard-folds:${window.location.pathname}`; + const readFolds = () => { + try { + return JSON.parse(localStorage.getItem(foldKey) || "{}") || {}; + } catch { + return {}; + } + }; + const folds = readFolds(); + const groupKey = (group) => group.querySelector("h2")?.id || String(groups.indexOf(group)); + const isFolded = (tile) => { + const group = tile.closest(".dashboard-group"); + return !!group && (group.classList.contains("is-collapsed") || + (tile.classList.contains("is-overflow") && !group.classList.contains("is-expanded"))); + }; + const setCollapsed = (group, collapsed) => { + group.classList.toggle("is-collapsed", collapsed); + group.querySelector(".dashboard-group-toggle")?.setAttribute("aria-expanded", String(!collapsed)); + }; + const setExpanded = (group, expanded) => { + group.classList.toggle("is-expanded", expanded); + const more = group.querySelector(".dashboard-more"); + if (!more) return; + more.setAttribute("aria-expanded", String(expanded)); + more.textContent = expanded ? "Show fewer" : `Show ${more.dataset.more} more`; + }; + const saveFolds = (group) => { + folds[groupKey(group)] = { + collapsed: group.classList.contains("is-collapsed"), + expanded: group.classList.contains("is-expanded") + }; + try { + localStorage.setItem(foldKey, JSON.stringify(folds)); + } catch { + // Without storage, the choice lasts until the page reloads. + } + }; + for (const group of groups) { + const saved = folds[groupKey(group)]; + if (!saved) continue; + setCollapsed(group, !!saved.collapsed); + setExpanded(group, !!saved.expanded); + } + // The page an index entry belongs to; section entries carry an anchor. const pageOf = (entry) => entry.url.split("#")[0]; let pageEntries; @@ -319,16 +365,20 @@ const terms = tokenize(query); let first = null; + // While the filter is empty, folded groups and tiles past the limit stay out of the results; the filter searches them. + dashboard.classList.toggle("is-filtering", terms.length > 0); for (const entry of tileEntries) { const matches = terms.every(term => entry.text.includes(term)); - entry.tile.hidden = !matches; + entry.tile.hidden = !matches || (terms.length === 0 && isFolded(entry.tile)); if (matches && terms.length > 0) { const startsWith = terms.length > 0 && entry.name.startsWith(terms[0]); if (!first || (startsWith && !first.startsWith)) first = { ...entry, startsWith }; } } for (const section of sections) { - const hidden = terms.length > 0 && !section.parts.some(part => part.querySelector(".dashboard-tile:not([hidden]), .dashboard-note")); + const folds = section.heads[0].closest(".dashboard-group")?.matches(".is-collapsed, :has(.is-overflow)"); + const hidden = (terms.length > 0 || folds) && + !section.parts.some(part => part.querySelector(".dashboard-tile:not([hidden]), .dashboard-note")); for (const head of section.heads) head.hidden = hidden; } // A subheading that ends up first below its group label keeps the tight spacing of one that starts the group. @@ -497,6 +547,22 @@ input.focus(); }; + dashboard.addEventListener("click", event => { + const button = event.target instanceof Element ? event.target.closest(".dashboard-group-toggle, .dashboard-more") : null; + const group = button?.closest(".dashboard-group"); + if (!group) return; + // While the filter shows matches from every group, a heading clears the filter and opens its group. + if (button.matches(".dashboard-group-toggle") && input.value.trim()) { + input.value = ""; + setCollapsed(group, false); + } else if (button.matches(".dashboard-group-toggle")) setCollapsed(group, !group.classList.contains("is-collapsed")); + else setExpanded(group, !group.classList.contains("is-expanded")); + saveFolds(group); + update(); + // Showing fewer tiles must not leave the button far below the viewport. + if (button.isConnected) button.scrollIntoView({ block: "nearest" }); + }); + clearButton.addEventListener("click", clear); input.addEventListener("focus", () => loadIndex(), { once: true }); input.addEventListener("input", () => { @@ -536,7 +602,10 @@ } }); - renderHints(); + // Applies folded groups and limits, so the results and the arrow keys skip the tiles they hide. + update(); + // Nothing has changed for a screen reader yet. + status.textContent = ""; // On a dashboard, the slash key focuses the dashboard filter instead of opening the site search. window.addEventListener("keydown", event => { From d1c6cbe3163e256a2684659e6930d0313f2a10a7 Mon Sep 17 00:00:00 2001 From: Jan Ruhlaender Date: Tue, 10 Nov 2026 19:04:57 +0100 Subject: [PATCH 2/6] fix(dashboard): apply folds before the first paint and only with a script An inline script after the groups enables folding and applies the reader's saved choices before the page is painted; without JavaScript every tile stays visible. Group tiles are counted up front, notes past the limit fold with their tiles, footnote references in group headings keep their links, folded labels announce expanded while the filter shows their matches, typing on a group button continues in the filter, and the buttons use the standard focus offset. --- Docs/Features/Dashboard.md | 2 +- .../Markdown.Renderer.Test/DashboardTest.cs | 12 ++- Source/Markdown.Renderer/Dashboard.cs | 75 +++++++++++-------- .../Markdown.Renderer.csproj | 2 + .../Markdown.Renderer/Res/Css/dashboard.css | 24 ++++-- .../Res/Js/dashboard-folds.js | 24 ++++++ Source/Markdown.Renderer/Res/Js/dashboard.js | 29 ++++--- Source/Markdown.Renderer/Resources.cs | 1 + 8 files changed, 112 insertions(+), 57 deletions(-) create mode 100644 Source/Markdown.Renderer/Res/Js/dashboard-folds.js diff --git a/Docs/Features/Dashboard.md b/Docs/Features/Dashboard.md index a4e77b57..1d810c5c 100644 --- a/Docs/Features/Dashboard.md +++ b/Docs/Features/Dashboard.md @@ -118,7 +118,7 @@ dashboard: A dashboard can hold everything you need without showing it all at once. Select a group label to fold the group to its label; a folded label shows the number of its tiles. With `limit`, a group shows only its first tiles, and **Show more** reveals the rest; when only one tile would be left over, the group shows it instead. The filter always searches every tile, including those in folded groups and past the limit, and while it has text, every group shows its matches. Selecting a group label while filtering clears the filter and opens that group. -What you fold or open stays in your browser for this page, so `collapsed` and `limit` set only the start. A good start page keeps the links you use daily in open groups at the top, and the rest in folded groups below. +What you fold or open stays in your browser for this page, so `collapsed` and `limit` set only the start. A good start page keeps the links you use daily in open groups at the top, and the rest in folded groups below. Without JavaScript, every group shows all of its tiles. ## Behavior diff --git a/Source/Markdown.Renderer.Test/DashboardTest.cs b/Source/Markdown.Renderer.Test/DashboardTest.cs index 3f421591..9e631afc 100644 --- a/Source/Markdown.Renderer.Test/DashboardTest.cs +++ b/Source/Markdown.Renderer.Test/DashboardTest.cs @@ -74,8 +74,9 @@ public async Task FoldsGroupsAndLimitsTheirTiles() - [Old Wiki](https://wiki.example.org) - [Photos](https://photos.example.org) + - Moved to the archive drive - ## Tools + ## Tools[^1] - [Editor](https://editor.example.org) - [Shell](https://shell.example.org) @@ -84,6 +85,8 @@ public async Task FoldsGroupsAndLimitsTheirTiles() ## [AI](https://ai.example.org) - [Claude](https://claude.ai) + + [^1]: Installed locally. """, Options with { Limit = 2, Collapsed = ["ai"] }); Assert.Contains("
    ", html); @@ -96,7 +99,12 @@ public async Task FoldsGroupsAndLimitsTheirTiles() Assert.Equal(2, CountOf(html, "
  • ")); Assert.Equal(1, CountOf(html, "class=\"dashboard-more\"")); Assert.Contains("aria-expanded=\"false\" data-more=\"2\">Show 2 more", html); - Assert.DoesNotContain('\u0001', html); + // A note past the limit folds with the tiles around it. + Assert.Contains("
  • Moved to the archive drive
  • ", html); + // A footnote reference follows the button, so it still links to its footnote. + Assert.Matches("Tools.*?
    ]*class=\"footnote-ref\"", html); + // Folding starts in the script that can undo it. + Assert.Equal(1, CountOf(html, "dashboard.classList.add(\"is-folding\")")); } [Fact] diff --git a/Source/Markdown.Renderer/Dashboard.cs b/Source/Markdown.Renderer/Dashboard.cs index 59d36981..a19b9fb9 100644 --- a/Source/Markdown.Renderer/Dashboard.cs +++ b/Source/Markdown.Renderer/Dashboard.cs @@ -77,6 +77,7 @@ public static class Dashboard private static readonly Regex NonSlugCharactersRegex = new("[^a-z0-9]+", RegexOptions.Compiled); private static readonly Regex LeadingSeparatorRegex = new(@"\A\s*(?:[—–:|·-]\s*)?", RegexOptions.Compiled); private static readonly Regex AnchorTagRegex = new("]*>", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex FootnoteReferenceRegex = new("]*\\bclass=\"footnote-ref\"[^>]*>.*?", RegexOptions.Compiled | RegexOptions.Singleline); private static readonly Regex HeadingIdRegex = new("]*\\bid=\"(?[^\"]+)\"", RegexOptions.Compiled); private static readonly char[] WordSeparators = [' ', '@', '-', '_', '.', '/', '&', '+']; // The arrow that fades in on hover and focus shows whether a tile leaves the site. @@ -139,7 +140,12 @@ public static string Render(MarkdownDocument document, MarkdownPipeline pipeline foreach (var block in introduction) html.Append(RenderBlock(block, pipeline)); html.AppendLine(RenderToolbar(options, firstGroup >= 0)); html.AppendLine($"
    "); - if (firstGroup >= 0) html.Append(RenderGroups(blocks.Skip(firstGroup), pipeline, options, usedIds)); + if (firstGroup >= 0) + { + html.Append(RenderGroups(blocks.Skip(firstGroup).ToList(), pipeline, options, usedIds)); + // Inline, so folded groups and saved choices apply before the first paint, and only where a script can undo them. + html.AppendLine($""); + } // The filter script fills these containers, so they exist even on a dashboard without groups. html.AppendLine("
    "); group = null; } - foreach (var block in blocks) + for (var index = 0; index < blocks.Count; index++) { - switch (block) + switch (blocks[index]) { case ThematicBreakBlock: CloseGroup(); - groupOpen = false; html.AppendLine("
    "); html.AppendLine("
    "); break; case HeadingBlock { Level: 2 } heading: CloseGroup(); - groupOpen = true; var collapsed = options.StartsCollapsed(PlainText(heading.Inline ?? Enumerable.Empty()).Trim()); - group = new GroupState(html.Length); + group = new GroupState(CountGroupTiles(blocks, index + 1, pipeline), options.Limit); html.AppendLine($"
    "); html.AppendLine("
    "); - html.Append(RenderGroupHeading(RenderBlock(heading, pipeline), collapsed)); + html.Append(RenderGroupHeading(RenderBlock(heading, pipeline), collapsed, group.Total)); html.AppendLine("
    "); break; case ListBlock list: - if (groupOpen) html.Append(RenderTiles(list, pipeline, options, usedIds, ref tileIndex, group)); + if (group is not null) html.Append(RenderTiles(list, pipeline, options, usedIds, ref tileIndex, group)); else { html.AppendLine("
    "); @@ -205,12 +203,12 @@ void CloseGroup() html.AppendLine("
    "); } break; - default: - if (!groupOpen) html.AppendLine("
    "); + case var block: + if (group is null) html.AppendLine("
    "); html.AppendLine("
    "); html.Append(RenderBlock(block, pipeline)); html.AppendLine("
    "); - if (!groupOpen) html.AppendLine("
    "); + if (group is null) html.AppendLine("
    "); break; } } @@ -220,31 +218,46 @@ void CloseGroup() return html.ToString(); } + /// Counts the tiles of the group whose blocks start at . + private static int CountGroupTiles(IReadOnlyList blocks, int start, MarkdownPipeline pipeline) => + blocks.Skip(start) + .TakeWhile(block => block is not (ThematicBreakBlock or HeadingBlock { Level: 2 })) + .OfType() + .Sum(list => list.OfType().Count(item => ReadTile(item, pipeline) is not null)); + /// /// Turns the text of a group heading into a button that folds the group. The heading keeps its id, so links to it - /// still work; links inside the heading keep only their text, because a button cannot contain a link. + /// still work. A button cannot contain a link, so links in the heading keep only their text, and footnote references + /// follow the button. /// - private static string RenderGroupHeading(string headingHtml, bool collapsed) + private static string RenderGroupHeading(string headingHtml, bool collapsed, int tiles) { var start = headingHtml.IndexOf('>') + 1; var end = headingHtml.LastIndexOf("", StringComparison.Ordinal); if (start <= 0 || end < start) return headingHtml; - var content = AnchorTagRegex.Replace(headingHtml[start..end], string.Empty).Trim(); + var content = headingHtml[start..end]; + var footnotes = string.Concat(FootnoteReferenceRegex.Matches(content).Select(match => match.Value)); + content = AnchorTagRegex.Replace(FootnoteReferenceRegex.Replace(content, string.Empty), string.Empty).Trim(); return headingHtml[..start] + $"" + + footnotes + headingHtml[end..]; } - /// The group being written: where it starts in the output and how many tiles it has so far. - private sealed class GroupState(int start) + /// + /// The group being written: how many tiles it has, how many show before Show more, and how many are written so far. + /// A button for a single tile would take the tile's place, so a group with one tile past the limit shows them all. + /// + private sealed class GroupState(int total, int? limit) { - public const string CountToken = "\u0001tiles\u0001"; - public int Start { get; } = start; - public int Tiles { get; set; } + public int Total { get; } = total; + public int Shown { get; } = limit is { } value && total - value > 1 ? value : total; + public int Written { get; set; } + public bool IsFull => Written >= Shown && Shown < Total; } private static HashSet ReadHeadingIds(IEnumerable headings, MarkdownPipeline pipeline) @@ -300,7 +313,7 @@ private static string RenderTiles(ListBlock list, MarkdownPipeline pipeline, Das var tile = tiles[index]; if (tile is null) { - html.AppendLine($"
  • {RenderChildren((ListItemBlock)list[index], pipeline)}
  • "); + html.AppendLine($"
  • {RenderChildren((ListItemBlock)list[index], pipeline)}
  • "); continue; } @@ -318,7 +331,7 @@ private static string RenderTiles(ListBlock list, MarkdownPipeline pipeline, Das while (!usedIds.Add(id)); // Tiles past the limit stay in the page for the filter and appear when the reader asks for them. - var overflow = group is not null && ++group.Tiles > (options.Limit ?? int.MaxValue); + var overflow = group is not null && ++group.Written > group.Shown; html.Append($"
  • "); html.Append($"
    "); html.Append($" + + diff --git a/Source/Markdown.Renderer/Res/Css/dashboard.css b/Source/Markdown.Renderer/Res/Css/dashboard.css index 418ed3e1..b1a2a63e 100644 --- a/Source/Markdown.Renderer/Res/Css/dashboard.css +++ b/Source/Markdown.Renderer/Res/Css/dashboard.css @@ -286,6 +286,16 @@ color: var(--text-primary); } +/* Without a script, the label cannot fold its group, so it looks and behaves like a plain label. */ +.dashboard:not(.is-folding) .dashboard-group-toggle { + cursor: default; + pointer-events: none; +} + +.dashboard:not(.is-folding) .dashboard-group-chevron { + display: none; +} + .dashboard-group-title { min-width: 0; overflow-wrap: anywhere; @@ -328,19 +338,19 @@ } } -.dashboard:not(.is-filtering) .dashboard-group.is-collapsed .dashboard-group-count { +.dashboard.is-folding:not(.is-filtering) .dashboard-group.is-collapsed .dashboard-group-count { display: inline-block; } -.dashboard:not(.is-filtering) .dashboard-group.is-collapsed .dashboard-group-chevron { +.dashboard.is-folding:not(.is-filtering) .dashboard-group.is-collapsed .dashboard-group-chevron { transform: rotate(-90deg); } /* Without a filter, a folded group shows only its label and a limited group only its first tiles. While the filter - has text, every group shows its matches. */ -.dashboard:not(.is-filtering) .dashboard-group.is-collapsed > :not(:first-child), -.dashboard:not(.is-filtering) .dashboard-group:not(.is-expanded) .dashboard-tile.is-overflow, -.dashboard.is-filtering .dashboard-more-row { + has text, every group shows its matches. Without a script, nothing folds, because nothing could open it again. */ +.dashboard.is-folding:not(.is-filtering) .dashboard-group.is-collapsed > :not(:first-child), +.dashboard.is-folding:not(.is-filtering) .dashboard-group:not(.is-expanded) .is-overflow, +.dashboard:is(.is-filtering, :not(.is-folding)) .dashboard-more-row { display: none; } @@ -363,7 +373,7 @@ :root:not([data-input="pointer"]) :is(.dashboard-group-toggle, .dashboard-more):focus-visible { outline: 2px solid var(--link); - outline-offset: 2px; + outline-offset: 3px; } /* Headings below the group label, from the third level down, divide a group: smaller than a tile title, in full text diff --git a/Source/Markdown.Renderer/Res/Js/dashboard-folds.js b/Source/Markdown.Renderer/Res/Js/dashboard-folds.js new file mode 100644 index 00000000..614c23b0 --- /dev/null +++ b/Source/Markdown.Renderer/Res/Js/dashboard-folds.js @@ -0,0 +1,24 @@ +(() => { + // Runs inline right after the groups, before the page is painted: folding starts only with a script that can undo it, + // and the reader's saved choices apply without a visible jump. dashboard.js reads and writes the same entry. + const dashboard = document.currentScript?.closest(".dashboard"); + if (!dashboard) return; + dashboard.classList.add("is-folding"); + let folds = {}; + try { + folds = JSON.parse(localStorage.getItem(`mdu-dashboard-folds:${window.location.pathname}`) || "{}") || {}; + } catch { + return; + } + [...dashboard.querySelectorAll(".dashboard-group")].forEach((group, index) => { + const saved = folds[group.querySelector("h2")?.id || String(index)]; + if (!saved) return; + group.classList.toggle("is-collapsed", !!saved.collapsed); + group.classList.toggle("is-expanded", !!saved.expanded); + group.querySelector(".dashboard-group-toggle")?.setAttribute("aria-expanded", String(!saved.collapsed)); + const more = group.querySelector(".dashboard-more"); + if (!more) return; + more.setAttribute("aria-expanded", String(!!saved.expanded)); + if (saved.expanded) more.textContent = "Show fewer"; + }); +})(); diff --git a/Source/Markdown.Renderer/Res/Js/dashboard.js b/Source/Markdown.Renderer/Res/Js/dashboard.js index e1ce0742..a1a93ae6 100644 --- a/Source/Markdown.Renderer/Res/Js/dashboard.js +++ b/Source/Markdown.Renderer/Res/Js/dashboard.js @@ -70,7 +70,8 @@ }); // Groups fold with their heading, and a group with a limit shows the rest of its tiles on request. The reader's choice - // stays in this browser for this page; the page works the same without storage. + // stays in this browser for this page; dashboard-folds.js applies it while the page loads. The page works the same + // without storage. const foldKey = `mdu-dashboard-folds:${window.location.pathname}`; const readFolds = () => { try { @@ -108,12 +109,6 @@ // Without storage, the choice lasts until the page reloads. } }; - for (const group of groups) { - const saved = folds[groupKey(group)]; - if (!saved) continue; - setCollapsed(group, !!saved.collapsed); - setExpanded(group, !!saved.expanded); - } // The page an index entry belongs to; section entries carry an anchor. const pageOf = (entry) => entry.url.split("#")[0]; @@ -367,6 +362,10 @@ // While the filter is empty, folded groups and tiles past the limit stay out of the results; the filter searches them. dashboard.classList.toggle("is-filtering", terms.length > 0); + // A filter shows the matches of folded groups too, so their labels announce them as expanded meanwhile. + for (const group of groups) + group.querySelector(".dashboard-group-toggle")?.setAttribute("aria-expanded", + String(terms.length > 0 || !group.classList.contains("is-collapsed"))); for (const entry of tileEntries) { const matches = terms.every(term => entry.text.includes(term)); entry.tile.hidden = !matches || (terms.length === 0 && isFolded(entry.tile)); @@ -376,8 +375,8 @@ } } for (const section of sections) { - const folds = section.heads[0].closest(".dashboard-group")?.matches(".is-collapsed, :has(.is-overflow)"); - const hidden = (terms.length > 0 || folds) && + const groupFolds = section.heads[0].closest(".dashboard-group")?.matches(".is-collapsed, :has(.is-overflow)"); + const hidden = (terms.length > 0 || groupFolds) && !section.parts.some(part => part.querySelector(".dashboard-tile:not([hidden]), .dashboard-note")); for (const head of section.heads) head.hidden = hidden; } @@ -422,10 +421,6 @@ : `No links or pages match “${query}”.`; }; - const linkOf = (event) => event.target instanceof Element - ? event.target.closest("a.dashboard-link, a.dashboard-page") - : null; - // Whichever input was used last leads: moving the mouse hides the keyboard outline, and a key press hides the hover // again. A resting pointer is ignored, so tiles that scroll or filter in under it do not light up. const root = document.documentElement; @@ -527,10 +522,12 @@ from.blur(); }); - // On a tile, Home and End jump to the first and last result, and typing continues in the filter. + // On a tile or a group button, Home and End jump to the first and last result, and typing continues in the filter. dashboard.addEventListener("keydown", event => { - const link = linkOf(event); - if (!link || event.altKey || event.metaKey || event.ctrlKey) return; + const control = event.target instanceof Element + ? event.target.closest("a.dashboard-link, a.dashboard-page, .dashboard-group-toggle, .dashboard-more") + : null; + if (!control || event.altKey || event.metaKey || event.ctrlKey) return; const items = results(); if (event.key === "Home" || event.key === "End") { event.preventDefault(); diff --git a/Source/Markdown.Renderer/Resources.cs b/Source/Markdown.Renderer/Resources.cs index 7c915797..eace6fe8 100755 --- a/Source/Markdown.Renderer/Resources.cs +++ b/Source/Markdown.Renderer/Resources.cs @@ -16,6 +16,7 @@ public static class Resources public static string HeroPlaygroundScriptContent() => R.GetStringByName("Markdown.Renderer.Res.Js.hero-playground.js"); public static string DashboardCssContent() => R.GetStringByName("Markdown.Renderer.Res.Css.dashboard.css"); public static string DashboardScriptContent() => R.GetStringByName("Markdown.Renderer.Res.Js.dashboard.js"); + public static string DashboardFoldsScriptContent() => R.GetStringByName("Markdown.Renderer.Res.Js.dashboard-folds.js"); public static string IsometricDiagramScriptContent() => R.GetStringByName("Markdown.Renderer.Res.Js.isometric-diagram.js"); public static string ImageLightboxScriptContent() => R.GetStringByName("Markdown.Renderer.Res.Js.image-lightbox.js"); } From d8cc96b80458710eb51af124d47420d254ed6345 Mon Sep 17 00:00:00 2001 From: Jan Ruhlaender Date: Tue, 10 Nov 2026 19:18:01 +0100 Subject: [PATCH 3/6] fix(dashboard): open folded groups for links and share one fold state per page A fragment that targets a folded group or anything inside it opens the group in full, on load and when the fragment changes. A folder's start page keeps one fold state for its folder and index.html addresses. Group labels stay disabled until the fold script runs, and tiles are counted without rendering them. --- .../Markdown.Renderer.Test/DashboardTest.cs | 14 +++-- Source/Markdown.Renderer/Dashboard.cs | 38 +++++++++---- .../Markdown.Renderer/Res/Css/dashboard.css | 6 +- .../Res/Js/dashboard-folds.js | 56 +++++++++++++++---- Source/Markdown.Renderer/Res/Js/dashboard.js | 12 +++- 5 files changed, 93 insertions(+), 33 deletions(-) diff --git a/Source/Markdown.Renderer.Test/DashboardTest.cs b/Source/Markdown.Renderer.Test/DashboardTest.cs index 9e631afc..5c61b6c8 100644 --- a/Source/Markdown.Renderer.Test/DashboardTest.cs +++ b/Source/Markdown.Renderer.Test/DashboardTest.cs @@ -74,6 +74,8 @@ public async Task FoldsGroupsAndLimitsTheirTiles() - [Old Wiki](https://wiki.example.org) - [Photos](https://photos.example.org) + - [ ](https://task.example.org) + - ![Logo](logo.png) - Moved to the archive drive ## Tools[^1] @@ -91,14 +93,16 @@ public async Task FoldsGroupsAndLimitsTheirTiles() Assert.Contains("
    ", html); Assert.Contains("
    ", html); - Assert.Contains("

    ", html); + Assert.Contains("aria-expanded=\"false\" data-more=\"3\">Show 3 more", html); // A note past the limit folds with the tiles around it. Assert.Contains("
  • Moved to the archive drive
  • ", html); // A footnote reference follows the button, so it still links to its footnote. diff --git a/Source/Markdown.Renderer/Dashboard.cs b/Source/Markdown.Renderer/Dashboard.cs index a19b9fb9..6d016d6f 100644 --- a/Source/Markdown.Renderer/Dashboard.cs +++ b/Source/Markdown.Renderer/Dashboard.cs @@ -188,10 +188,10 @@ void CloseGroup() case HeadingBlock { Level: 2 } heading: CloseGroup(); var collapsed = options.StartsCollapsed(PlainText(heading.Inline ?? Enumerable.Empty()).Trim()); - group = new GroupState(CountGroupTiles(blocks, index + 1, pipeline), options.Limit); + group = new GroupState(CountGroupTiles(blocks, index + 1), options.Limit); html.AppendLine($"
    "); html.AppendLine("
    "); - html.Append(RenderGroupHeading(RenderBlock(heading, pipeline), collapsed, group.Total)); + html.Append(RenderGroupHeading(RenderBlock(heading, pipeline), group.Total)); html.AppendLine("
    "); break; case ListBlock list: @@ -219,18 +219,18 @@ void CloseGroup() } /// Counts the tiles of the group whose blocks start at . - private static int CountGroupTiles(IReadOnlyList blocks, int start, MarkdownPipeline pipeline) => + private static int CountGroupTiles(IReadOnlyList blocks, int start) => blocks.Skip(start) .TakeWhile(block => block is not (ThematicBreakBlock or HeadingBlock { Level: 2 })) .OfType() - .Sum(list => list.OfType().Count(item => ReadTile(item, pipeline) is not null)); + .Sum(list => list.OfType().Count(IsTile)); /// /// Turns the text of a group heading into a button that folds the group. The heading keeps its id, so links to it /// still work. A button cannot contain a link, so links in the heading keep only their text, and footnote references /// follow the button. /// - private static string RenderGroupHeading(string headingHtml, bool collapsed, int tiles) + private static string RenderGroupHeading(string headingHtml, int tiles) { var start = headingHtml.IndexOf('>') + 1; var end = headingHtml.LastIndexOf("

    ", StringComparison.Ordinal); @@ -239,7 +239,7 @@ private static string RenderGroupHeading(string headingHtml, bool collapsed, int var footnotes = string.Concat(FootnoteReferenceRegex.Matches(content).Select(match => match.Value)); content = AnchorTagRegex.Replace(FootnoteReferenceRegex.Replace(content, string.Empty), string.Empty).Trim(); return headingHtml[..start] - + $"
  • ") + .AppendLine("
      ") + .AppendLine("") + .AppendLine(""); html.Append(RenderGroups(blocks.Skip(firstGroup).ToList(), pipeline, options, usedIds)); // Inline, so folded groups and saved choices apply before the first paint, and only where a script can undo them. html.AppendLine($""); diff --git a/Source/Markdown.Renderer/Res/Css/dashboard.css b/Source/Markdown.Renderer/Res/Css/dashboard.css index e65c8ca0..e026a25c 100644 --- a/Source/Markdown.Renderer/Res/Css/dashboard.css +++ b/Source/Markdown.Renderer/Res/Css/dashboard.css @@ -371,7 +371,33 @@ background: var(--dashboard-hover); } -:root:not([data-input="pointer"]) :is(.dashboard-group-toggle, .dashboard-more):focus-visible { +/* The frequently used links come first and span the width; their label carries a quiet button that forgets them. */ +.markdown-content .dashboard-frequent h2 { + display: flex; + gap: 0.5rem; + align-items: baseline; +} + +.dashboard-frequent-clear { + margin: -0.25rem 0 0 auto; + padding: 0.25rem 0.5rem; + border: 0; + border-radius: 6px; + background: none; + color: var(--dashboard-muted); + font: inherit; + font-weight: 500; + letter-spacing: normal; + text-transform: none; + cursor: pointer; +} + +.dashboard-frequent-clear:hover { + color: var(--text-primary); + background: var(--dashboard-hover); +} + +:root:not([data-input="pointer"]) :is(.dashboard-group-toggle, .dashboard-more, .dashboard-frequent-clear):focus-visible { outline: 2px solid var(--link); outline-offset: 3px; } diff --git a/Source/Markdown.Renderer/Res/Js/dashboard-folds.js b/Source/Markdown.Renderer/Res/Js/dashboard-folds.js index d96d9760..e5fba3b0 100644 --- a/Source/Markdown.Renderer/Res/Js/dashboard-folds.js +++ b/Source/Markdown.Renderer/Res/Js/dashboard-folds.js @@ -5,8 +5,9 @@ if (!dashboard) return; dashboard.classList.add("is-folding"); // A folder's start page has two addresses, such as /wiki/ and /wiki/index.html; both share one entry. - dashboard.dataset.foldKey = `mdu-dashboard-folds:${window.location.pathname.replace(/(?:index|README)\.html$/i, "")}`; - const groups = [...dashboard.querySelectorAll(".dashboard-group")]; + const page = window.location.pathname.replace(/(?:index|README)\.html$/i, ""); + dashboard.dataset.foldKey = `mdu-dashboard-folds:${page}`; + const groups = [...dashboard.querySelectorAll(".dashboard-group:not(.dashboard-frequent)")]; // The page leaves the labels disabled and every group open, which is true without a script; folding starts here. for (const group of groups) { const toggle = group.querySelector(".dashboard-group-toggle"); @@ -36,6 +37,44 @@ if (saved) apply(group, !!saved.collapsed, !!saved.expanded); }); + // The row of frequently used links shows copies of the tiles this reader opened at least twice, those opened often and + // lately first; a use counts half after 30 days. dashboard.js counts the uses in the same entry. + const frequent = dashboard.querySelector(".dashboard-frequent"); + if (frequent) { + dashboard.dataset.frequentKey = `mdu-dashboard-frequent:${page}`; + let uses = {}; + try { + uses = JSON.parse(localStorage.getItem(dashboard.dataset.frequentKey) || "{}") || {}; + } catch { + // Without storage, the row stays hidden. + } + const links = new Map(); + for (const group of groups) + for (const link of group.querySelectorAll(".dashboard-tile a.dashboard-link")) + if (!links.has(link.href)) links.set(link.href, link); + const now = Date.now(); + const score = use => use.n * 0.5 ** ((now - use.t) / (30 * 864e5)); + const picks = Object.entries(uses) + .filter(([href, use]) => use?.n >= 2 && links.has(href)) + .sort(([, a], [, b]) => score(b) - score(a)) + .slice(0, Number(frequent.dataset.frequent) || 6); + const list = frequent.querySelector(".dashboard-tiles"); + picks.forEach(([href], index) => { + const link = links.get(href); + const tile = link.closest(".dashboard-tile").cloneNode(true); + tile.classList.remove("is-overflow"); + tile.hidden = false; + tile.querySelector(".dashboard-tile-more")?.remove(); + const cell = tile.querySelector('[role="gridcell"]'); + let suffix = index; + while (document.getElementById(`dashboard-frequent-tile-${suffix}`)) suffix++; + cell.id = `dashboard-frequent-tile-${suffix}`; + if (link.closest(".dashboard-tiles.has-descriptions")) list.classList.add("has-descriptions"); + list.append(tile); + }); + frequent.parentElement.hidden = picks.length === 0; + } + // A link to a group or to anything inside it opens the group in full, without changing the saved choice, so the // browser can scroll to the target. dashboard.js shows the tiles again when the fragment changes later. const reveal = () => { diff --git a/Source/Markdown.Renderer/Res/Js/dashboard.js b/Source/Markdown.Renderer/Res/Js/dashboard.js index 1ffd502d..99aefa6b 100644 --- a/Source/Markdown.Renderer/Res/Js/dashboard.js +++ b/Source/Markdown.Renderer/Res/Js/dashboard.js @@ -13,9 +13,12 @@ const clock = document.getElementById("dashboard-clock"); const searchUrl = dashboard.dataset.dashboardSearch; const indexPath = document.body.dataset.searchIndex; - const tiles = [...dashboard.querySelectorAll(".dashboard-tile")]; - const groups = [...dashboard.querySelectorAll(".dashboard-group")]; - const rows = [...dashboard.querySelectorAll(".dashboard-row")]; + // The row of frequently used links holds copies of tiles; the filter and the folds work on the originals. + const frequentRow = dashboard.querySelector(".dashboard-frequent-row"); + const frequentList = frequentRow?.querySelector(".dashboard-tiles"); + const tiles = [...dashboard.querySelectorAll(".dashboard-tile")].filter(tile => !frequentRow?.contains(tile)); + const groups = [...dashboard.querySelectorAll(".dashboard-group:not(.dashboard-frequent)")]; + const rows = [...dashboard.querySelectorAll(".dashboard-row:not(.dashboard-frequent-row)")]; // A subheading (third level or lower) starts a section of its group that runs to the next one; its heading and // description rows hide with the section's tiles. const sections = groups.flatMap(group => { @@ -392,6 +395,8 @@ } for (const group of groups) group.hidden = !group.querySelector(".dashboard-tile:not([hidden]), .dashboard-note") && terms.length > 0; for (const row of rows) row.hidden = !row.querySelector(".dashboard-group:not([hidden])") && terms.length > 0; + // The filter finds the original tiles, so the copies step aside while it has text. + if (frequentRow) frequentRow.hidden = terms.length > 0 || !frequentList.children.length; renderPages(rankPages(terms), terms); const pages = [...pagesList.querySelectorAll("a.dashboard-page")]; @@ -402,7 +407,7 @@ resultsGrid.hidden = ![...resultsGrid.querySelectorAll('[role="rowgroup"]')].some(isVisibleInResults); input.setAttribute("aria-expanded", String(!resultsGrid.hidden)); - const count = items.length; + const count = items.filter(item => !frequentRow?.contains(item)).length; const waitingForIndex = terms.length > 0 && !!indexPath && !pageEntries; const message = terms.length === 0 ? `${count} ${count === 1 ? "link" : "links"}.` @@ -544,8 +549,42 @@ input.focus(); }; + // Every opened tile counts for the row of frequently used links: a click, a middle click, or Enter in the filter. The + // row changes on the next visit, so tiles do not move under the pointer. + const frequentKey = dashboard.dataset.frequentKey; + const countUse = (link) => { + if (!frequentKey || !link?.matches("a.dashboard-link")) return; + try { + const uses = JSON.parse(localStorage.getItem(frequentKey) || "{}") || {}; + const now = Date.now(); + uses[link.href] = { n: (uses[link.href]?.n || 0) + 1, t: now }; + // Only the most used links are worth keeping. + const score = use => use.n * 0.5 ** ((now - use.t) / (30 * 864e5)); + const kept = Object.entries(uses).sort(([, a], [, b]) => score(b) - score(a)).slice(0, 50); + localStorage.setItem(frequentKey, JSON.stringify(Object.fromEntries(kept))); + } catch { + // Without storage, nothing is counted. + } + }; + dashboard.addEventListener("click", event => countUse(event.target instanceof Element ? event.target.closest("a") : null)); + dashboard.addEventListener("auxclick", event => { + if (event.button === 1) countUse(event.target instanceof Element ? event.target.closest("a") : null); + }); + + frequentRow?.querySelector(".dashboard-frequent-clear")?.addEventListener("click", () => { + try { + localStorage.removeItem(frequentKey); + } catch { + // Nothing was stored. + } + frequentList.replaceChildren(); + update(); + input.focus({ preventScroll: true }); + status.textContent = "Frequently used links cleared."; + }); + dashboard.addEventListener("click", event => { - const button = event.target instanceof Element ? event.target.closest(".dashboard-group-toggle, .dashboard-more") : null; + const button =event.target instanceof Element ? event.target.closest(".dashboard-group-toggle, .dashboard-more") : null; const group = button?.closest(".dashboard-group"); if (!group) return; // While the filter shows matches from every group, a heading clears the filter and opens its group. @@ -596,6 +635,7 @@ event.preventDefault(); const newTab = event.metaKey || event.ctrlKey; if (best && !(event.altKey && searchUrl)) { + countUse(best); if (newTab || opensNewTab(best)) window.open(best.href, "_blank", "noopener"); else window.location.href = best.href; } else if (searchUrl) { @@ -640,21 +680,24 @@ } if (dashboard.dataset.dashboardStatus === "true") { + // One request per address, so a tile and its copy among the frequently used links share the answer. + const requests = new Map(); + const reach = (href) => { + if (!requests.has(href)) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 6000); + requests.set(href, fetch(href, { mode: "no-cors", cache: "no-store", signal: controller.signal }) + .then(() => true, () => false) + .finally(() => clearTimeout(timeout))); + } + return requests.get(href); + }; const check = async (link, status) => { const url = new URL(link.href); if (window.location.protocol === "https:" && url.protocol === "http:") return; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 6000); - try { - await fetch(url, { mode: "no-cors", cache: "no-store", signal: controller.signal }); - status.classList.add("is-up"); - status.setAttribute("aria-label", "Reachable"); - } catch { - status.classList.add("is-down"); - status.setAttribute("aria-label", "Not reachable"); - } finally { - clearTimeout(timeout); - } + const up = await reach(url.href); + status.classList.add(up ? "is-up" : "is-down"); + status.setAttribute("aria-label", up ? "Reachable" : "Not reachable"); }; for (const status of dashboard.querySelectorAll(".dashboard-status")) check(status.closest("a"), status); From 5f2dd91f5fadfeb56423bbebdfba97b901adfcb5 Mon Sep 17 00:00:00 2001 From: Jan Ruhlaender Date: Tue, 10 Nov 2026 23:15:16 +0100 Subject: [PATCH 5/6] feat(dashboard): align tiles, check status per group, and add a compact view Every tile now has two lines, its description or else its host, so tiles line up across groups. status accepts group headings, and a group label counts its unreachable sites, also while folded. A switch in the toolbar changes to a compact view with the names alone; compact sets the start view and the reader's choice is kept per page. --- Docs/Features/Dashboard.md | 10 +- .../Markdown.Renderer.Test/DashboardTest.cs | 59 +++++++++- Source/Markdown.Renderer/Dashboard.cs | 63 ++++++---- .../Markdown.Renderer/Res/Css/dashboard.css | 109 ++++++++++++++---- .../Res/Js/dashboard-folds.js | 15 ++- Source/Markdown.Renderer/Res/Js/dashboard.js | 29 ++++- 6 files changed, 234 insertions(+), 51 deletions(-) diff --git a/Docs/Features/Dashboard.md b/Docs/Features/Dashboard.md index 83d42774..0ffda1f6 100644 --- a/Docs/Features/Dashboard.md +++ b/Docs/Features/Dashboard.md @@ -46,11 +46,11 @@ layout: dashboard | `## Heading` | A group of tiles | | `### Heading` or a lower level inside a group | A subheading that divides the group; a paragraph below it is a short description | | `- [Name](URL)` | A tile that opens the link | -| Text after the link | Subtitle of the tile. A leading `—`, `–`, `-`, or `:` is removed. Without it, the tile shows the host name. In a group of compact rows, a host that is only the name and a top-level domain, such as `github.com` for GitHub, is left out; a host with a subdomain other than `www.` always stays. | +| Text after the link | Subtitle of the tile. A leading `—`, `–`, `-`, or `:` is removed. Without it, the tile shows the host name. A host that is only the name and a top-level domain, such as `github.com` for GitHub, is left out; a host with a subdomain other than `www.` always stays. | | `---` | Starts a new row of groups | | A list item without a link | A note between the tiles | -Groups with subtitles use large tiles; groups without them use compact rows. A compact row always shows the whole name first and the host only in the space that is left. Groups in a row share the width and wrap on narrow screens. Links to `.md` files point to the rendered pages, and all links open in the same tab unless you set `open: new-tab`. A [`tags` code block](Tags.md#list-tagged-pages-on-any-page) in a group fills it with the pages that carry a tag. +Every tile has two lines, the name and the subtitle, so tiles line up across all groups; a tile without a subtitle keeps the same height. The switch at the end of the toolbar changes to the compact view, which shows only the names and fits more tiles per row. The reader's choice stays in the browser for this page. Groups in a row share the width and wrap on narrow screens. Links to `.md` files point to the rendered pages, and all links open in the same tab unless you set `open: new-tab`. A [`tags` code block](Tags.md#list-tagged-pages-on-any-page) in a group fills it with the pages that carry a tag. ## Icons @@ -98,7 +98,8 @@ layout: dashboard dashboard: search: duckduckgo clock: true - status: false + status: [Operations] + compact: false open: same-tab limit: 6 collapsed: [AI, Social] @@ -110,7 +111,8 @@ dashboard: | --- | --- | --- | --- | | `search` | `duckduckgo`, `google`, `bing`, `startpage`, `ecosia`, `none`, or a URL with `{query}` | `duckduckgo` | Web search for queries without a matching link. `none` turns it off. | | `clock` | `true`, `false` | `true` | Shows the time and date next to the filter field in the reader's language. | -| `status` | `true`, `false` | `false` | Adds a dot to each tile that turns green when the browser reaches the site and red when it does not. | +| `status` | `true`, `false`, a heading, or a list of headings | `false` | Adds a dot to each tile that turns green when the browser reaches the site and red when it does not. With headings, only the tiles of those groups get a dot. A group label counts its unreachable sites, such as **1 offline**, so a folded group still shows a problem. | +| `compact` | `true`, `false` | `false` | Starts in the compact view with the names alone. The reader can switch the view; their choice wins. | | `open` | `same-tab`, `new-tab` | `same-tab` | Where links to other sites open. Links within the site always open in the same tab. | | `limit` | A number | none | Each group shows its first tiles up to this number and a **Show more** button for the rest. | | `collapsed` | `true`, a heading, or a list of headings | none | Groups that start folded. Their names match the group headings, ignoring case; `true` folds every group. | diff --git a/Source/Markdown.Renderer.Test/DashboardTest.cs b/Source/Markdown.Renderer.Test/DashboardTest.cs index a1f201f8..703a869b 100644 --- a/Source/Markdown.Renderer.Test/DashboardTest.cs +++ b/Source/Markdown.Renderer.Test/DashboardTest.cs @@ -123,6 +123,59 @@ public void ReadsTheFrequentlyUsedLinks() Assert.Null(Dashboard.ReadFrontMatter("---\nlayout: dashboard\n---")!.Frequent); } + [Fact] + public void ReadsTheStatusGroupsAndTheCompactView() + { + DashboardOptions Read(string options) => Dashboard.ReadFrontMatter($"---\nlayout: dashboard\ndashboard:\n{options}\n---")!; + var groups = Read(" status: [Betrieb, \" Server \"]\n compact: true"); + Assert.True(groups.Status); + Assert.Equal(["Betrieb", "Server"], groups.StatusGroups); + Assert.True(groups.ChecksStatus("betrieb")); + Assert.False(groups.ChecksStatus("Social")); + Assert.False(groups.ChecksStatus(null)); + Assert.True(groups.Compact); + + var all = Read(" status: true"); + Assert.Null(all.StatusGroups); + Assert.True(all.ChecksStatus(null)); + Assert.False(all.Compact); + Assert.Equal(["Betrieb"], Read(" status: Betrieb").StatusGroups); + Assert.False(Read(" status: false").ChecksStatus("Betrieb")); + } + + [Fact] + public async Task ShowsTheStatusOnlyInTheNamedGroups() + { + var html = await RenderPage(""" + ## Betrieb + + - [Proxmox](https://proxmox.example.org) + + ## Social + + - [Mastodon](https://mastodon.example.org) + """, new DashboardOptions(null, false, true, false, StatusGroups: ["betrieb"])); + + Assert.Equal(1, CountOf(html, ".*?class=\"dashboard-status\"", html); + Assert.Contains("data-dashboard-status=\"true\"", html); + } + + [Fact] + public async Task StartsInTheCompactViewAndOffersTheSwitch() + { + const string markdown = "## Office\n\n- [Mail](https://mail.example.org)\n"; + var compact = await RenderPage(markdown, new DashboardOptions(null, false, false, false, Compact: true)); + Assert.Contains("
      ", compact); + // Only the script can change the view, so it shows the switch. + Assert.Contains(""); html.Append("
      "); return html.ToString(); } @@ -329,9 +351,9 @@ private static string RenderTiles(ListBlock list, MarkdownPipeline pipeline, Das if (tiles.Count == 0 || tiles.All(tile => tile is null)) return $"
      {RenderBlock(list, pipeline)}
      "; - var hasDescriptions = tiles.Any(tile => tile is not null && tile.Description.Length > 0); var html = new StringBuilder(); - html.AppendLine($"
        "); + html.AppendLine("
          "); + var status = group?.Status ?? options.ChecksStatus(null); for (var index = 0; index < tiles.Count; index++) { var tile = tiles[index]; @@ -343,10 +365,11 @@ private static string RenderTiles(ListBlock list, MarkdownPipeline pipeline, Das var external = Uri.TryCreate(tile.Url, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https"; var host = external ? uri!.Host : string.Empty; - // In a compact list, a host that only repeats the name (GitHub, github.com) would take the name's space. + // Every tile has a second line: its description, or else its host. A host that only repeats the name (GitHub, + // github.com) leaves the line empty, and the tile keeps the height of the others. var subtitle = tile.Description.Length > 0 ? tile.Description - : !hasDescriptions && HostRepeatsName(tile.PlainName, host) + : HostRepeatsName(tile.PlainName, host) ? string.Empty : HtmlEncoder.Default.Encode(NormalizeHost(host)); @@ -369,7 +392,7 @@ private static string RenderTiles(ListBlock list, MarkdownPipeline pipeline, Das html.Append(""); var newTab = external && options.NewTab; if (newTab) html.Append(" (opens in a new tab)"); - if (options.Status && external) html.Append(""); + if (status && external) html.Append(""); html.Append(external ? ExternalArrow : InternalArrow); html.Append(""); // Further paragraphs or nested lists of the item stay below the tile, outside its link. diff --git a/Source/Markdown.Renderer/Res/Css/dashboard.css b/Source/Markdown.Renderer/Res/Css/dashboard.css index e026a25c..e561ba9b 100644 --- a/Source/Markdown.Renderer/Res/Css/dashboard.css +++ b/Source/Markdown.Renderer/Res/Css/dashboard.css @@ -397,7 +397,81 @@ background: var(--dashboard-hover); } -:root:not([data-input="pointer"]) :is(.dashboard-group-toggle, .dashboard-more, .dashboard-frequent-clear):focus-visible { +/* A group with unreachable sites says so in its label, also while it is folded. */ +.dashboard-group-down { + flex: none; + padding: 0 0.4rem; + border-radius: 999px; + background: color-mix(in srgb, #e5342a 14%, transparent); + color: #c42b22; + letter-spacing: 0; + text-transform: none; +} + +:root[data-theme="dark"] .dashboard-group-down { + color: #ff8a80; +} + +@media (prefers-color-scheme: dark) { + :root[data-theme="auto"] .dashboard-group-down { + color: #ff8a80; + } +} + +/* The compact view keeps only the names, so narrower groups and tiles fit more per row. */ +.dashboard.is-compact .dashboard-row { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr)); +} + +.markdown-content .dashboard.is-compact ul.dashboard-tiles { + grid-template-columns: repeat(auto-fill, minmax(min(100%, 11rem), 1fr)); +} + +/* The view switch sits at the end of the toolbar as a quiet icon button. */ +.dashboard-view { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + margin-left: auto; + padding: 0; + border: 0; + border-radius: 6px; + background: none; + color: var(--dashboard-muted); + cursor: pointer; +} + +.dashboard-view[hidden] { + display: none; +} + +.dashboard-clock + .dashboard-view { + margin-left: -1.25rem; +} + +.dashboard-view:hover { + color: var(--text-primary); + background: var(--dashboard-hover); +} + +.dashboard-view[aria-pressed="true"] { + color: var(--text-primary); + background: var(--dashboard-chip); +} + +.dashboard-view svg { + width: 1rem; + height: 1rem; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-width: 1.6; +} + +:root:not([data-input="pointer"]) :is(.dashboard-group-toggle, .dashboard-more, .dashboard-frequent-clear, .dashboard-view):focus-visible { outline: 2px solid var(--link); outline-offset: 3px; } @@ -507,7 +581,8 @@ text-decoration: none; } -.dashboard-tiles.has-descriptions a.dashboard-link { +/* Every tile has two lines, so tiles line up across groups; the compact view keeps only the names. */ +.markdown-content .dashboard:not(.is-compact) a.dashboard-link { --dashboard-chip-size: 2.375rem; --dashboard-chip-half-size: 1.1875rem; min-height: 3.5rem; @@ -622,21 +697,6 @@ padding-right: 1.1rem; } -/* A compact row keeps the whole name and gives the host only the space left. A host that does not get at least 5rem - wraps to a second line, which the one-line height hides, instead of squeezing the name. */ -.dashboard-tiles:not(.has-descriptions) .dashboard-text { - flex-direction: row; - flex-wrap: wrap; - align-items: baseline; - gap: 0 0.75rem; - max-height: 1lh; - overflow: hidden; -} - -.dashboard-tiles:not(.has-descriptions) .dashboard-name { - max-width: 100%; -} - .dashboard-name { overflow: hidden; font-weight: 500; @@ -644,7 +704,7 @@ white-space: nowrap; } -.dashboard-tiles.has-descriptions .dashboard-name { +.dashboard:not(.is-compact) .dashboard-name { font-size: 0.9375rem; } @@ -656,10 +716,8 @@ white-space: nowrap; } -.dashboard-tiles:not(.has-descriptions) .dashboard-description { - flex: 1 1 5rem; - min-width: 0; - text-align: right; +.dashboard.is-compact .dashboard-description { + display: none; } /* The arrow fades in at the row's end: ↗ leaves the site, → stays in it. */ @@ -926,6 +984,13 @@ a.dashboard-page:focus-visible .dashboard-page-footer svg { margin-left: 0; } + /* The view switch stays on the clock's line instead of a line of its own. */ + .dashboard-view, + .dashboard-clock + .dashboard-view { + order: -1; + margin-left: auto; + } + .dashboard-search-hint, .dashboard-search-key { display: none; diff --git a/Source/Markdown.Renderer/Res/Js/dashboard-folds.js b/Source/Markdown.Renderer/Res/Js/dashboard-folds.js index e5fba3b0..613db940 100644 --- a/Source/Markdown.Renderer/Res/Js/dashboard-folds.js +++ b/Source/Markdown.Renderer/Res/Js/dashboard-folds.js @@ -8,6 +8,20 @@ const page = window.location.pathname.replace(/(?:index|README)\.html$/i, ""); dashboard.dataset.foldKey = `mdu-dashboard-folds:${page}`; const groups = [...dashboard.querySelectorAll(".dashboard-group:not(.dashboard-frequent)")]; + + // The view switch shows only with a script; the reader's choice of view overrides the page's start view. + dashboard.dataset.viewKey = `mdu-dashboard-view:${page}`; + const view = document.getElementById("dashboard-view"); + if (view && dashboard.contains(view)) { + try { + const saved = localStorage.getItem(dashboard.dataset.viewKey); + if (saved) dashboard.classList.toggle("is-compact", saved === "compact"); + } catch { + // Without storage, the page's start view applies. + } + view.setAttribute("aria-pressed", String(dashboard.classList.contains("is-compact"))); + view.hidden = false; + } // The page leaves the labels disabled and every group open, which is true without a script; folding starts here. for (const group of groups) { const toggle = group.querySelector(".dashboard-group-toggle"); @@ -69,7 +83,6 @@ let suffix = index; while (document.getElementById(`dashboard-frequent-tile-${suffix}`)) suffix++; cell.id = `dashboard-frequent-tile-${suffix}`; - if (link.closest(".dashboard-tiles.has-descriptions")) list.classList.add("has-descriptions"); list.append(tile); }); frequent.parentElement.hidden = picks.length === 0; diff --git a/Source/Markdown.Renderer/Res/Js/dashboard.js b/Source/Markdown.Renderer/Res/Js/dashboard.js index 99aefa6b..167d1561 100644 --- a/Source/Markdown.Renderer/Res/Js/dashboard.js +++ b/Source/Markdown.Renderer/Res/Js/dashboard.js @@ -583,8 +583,20 @@ status.textContent = "Frequently used links cleared."; }); + // The compact view shows only the names, so more tiles fit; the choice stays in this browser for this page. + document.getElementById("dashboard-view")?.addEventListener("click", event => { + const compact = !dashboard.classList.contains("is-compact"); + dashboard.classList.toggle("is-compact", compact); + event.currentTarget.setAttribute("aria-pressed", String(compact)); + try { + localStorage.setItem(dashboard.dataset.viewKey, compact ? "compact" : "detailed"); + } catch { + // Without storage, the view lasts until the page reloads. + } + }); + dashboard.addEventListener("click", event => { - const button =event.target instanceof Element ? event.target.closest(".dashboard-group-toggle, .dashboard-more") : null; + const button = event.target instanceof Element ? event.target.closest(".dashboard-group-toggle, .dashboard-more") : null; const group = button?.closest(".dashboard-group"); if (!group) return; // While the filter shows matches from every group, a heading clears the filter and opens its group. @@ -698,6 +710,21 @@ const up = await reach(url.href); status.classList.add(up ? "is-up" : "is-down"); status.setAttribute("aria-label", up ? "Reachable" : "Not reachable"); + if (!up) countDown(status.closest(".dashboard-group")); + }; + // A group label counts its unreachable sites, so a folded group still tells that something is down. + const down = new Map(); + const countDown = (group) => { + const toggle = group && !group.matches(".dashboard-frequent") ? group.querySelector(".dashboard-group-toggle") : null; + if (!toggle) return; + down.set(group, (down.get(group) || 0) + 1); + let label = toggle.querySelector(".dashboard-group-down"); + if (!label) { + label = document.createElement("span"); + label.className = "dashboard-group-down"; + toggle.querySelector(".dashboard-group-count")?.after(label); + } + label.textContent = `${down.get(group)} offline`; }; for (const status of dashboard.querySelectorAll(".dashboard-status")) check(status.closest("a"), status); From ad016a6973c47491dba4bcdfacd2a179e709ba54 Mon Sep 17 00:00:00 2001 From: Jan Ruhlaender Date: Wed, 11 Nov 2026 01:46:25 +0100 Subject: [PATCH 6/6] fix(dashboard): fold content past the limit and count offline sites once Subheadings, paragraphs, and lists without links after the last shown tile now fold with the tiles past the limit, and notes there no longer keep their subheading in view. Frequently used links include tiles outside groups, ignore invalid stored entries, and a group counts each unreachable site once. Both dashboard scripts share the fold and scoring helpers. --- .../Markdown.Renderer.Test/DashboardTest.cs | 26 +++++++++ Source/Markdown.Renderer/Dashboard.cs | 21 ++++--- .../Res/Js/dashboard-folds.js | 41 ++++++------- Source/Markdown.Renderer/Res/Js/dashboard.js | 57 ++++++++----------- 4 files changed, 86 insertions(+), 59 deletions(-) diff --git a/Source/Markdown.Renderer.Test/DashboardTest.cs b/Source/Markdown.Renderer.Test/DashboardTest.cs index 703a869b..b9a59f9e 100644 --- a/Source/Markdown.Renderer.Test/DashboardTest.cs +++ b/Source/Markdown.Renderer.Test/DashboardTest.cs @@ -111,6 +111,32 @@ public async Task FoldsGroupsAndLimitsTheirTiles() Assert.Equal(1, CountOf(html, "dashboard.classList.add(\"is-folding\")")); } + [Fact] + public async Task FoldsTheContentPastTheLimit() + { + var html = await RenderPage(""" + ## Office + + - [Mail](https://mail.example.org) + - [Calendar](https://calendar.example.org) + + ### Archive + + Older services. + + * Moved to the archive drive + + - [Photos](https://photos.example.org) + - [Files](https://files.example.org) + """, Options with { Limit = 2 }); + + // The subheading, the paragraph, and the list without links follow the last tile shown, so they fold with the rest. + Assert.Contains("
          \n
          \n

          Older services.

          ", html.ReplaceLineEndings("\n")); + Assert.Contains("
            ", html); + Assert.DoesNotContain("
            \n"); - html.AppendLine("
            "); + html.AppendLine($"
            "); html.Append(RenderBlock(block, pipeline)); html.AppendLine("
            "); if (group is null) html.AppendLine("
            "); @@ -287,6 +287,12 @@ private static string RenderGroupHeading(string headingHtml, int tiles) + headingHtml[end..]; } + /// + /// Content after the last tile a group shows before Show more, such as a subheading, a paragraph, or a list without + /// links, hides with the tiles past the limit. + /// + private static string OverflowClass(GroupState? group) => group is { IsFull: true } ? " class=\"is-overflow\"" : string.Empty; + /// /// The group being written: how many tiles it has, how many show before Show more, whether they show a status, and how /// many are written so far. @@ -349,7 +355,7 @@ private static string RenderTiles(ListBlock list, MarkdownPipeline pipeline, Das { var tiles = list.OfType().Select(item => ReadTile(item, pipeline)).ToList(); if (tiles.Count == 0 || tiles.All(tile => tile is null)) - return $"
            {RenderBlock(list, pipeline)}
            "; + return $"
            {RenderBlock(list, pipeline)}
            "; var html = new StringBuilder(); html.AppendLine("
              "); @@ -435,7 +441,7 @@ private static int FindTileLink(List parts) var plainName = PlainText(nameParts).Trim(); return new DashboardTile( - link.Url, + link.Url!, link.Title, RenderInlines(nameParts, pipeline).Trim(), plainName, @@ -457,13 +463,14 @@ private static string RenderDescription(string leadingText, IEnumerable return LeadingSeparatorRegex.Replace(AnchorTagRegex.Replace(html, string.Empty), string.Empty, 1).Trim(); } - /// - /// Reads - [X](https://x.com), which Markdown parses as a checked task followed by an automatic link. - /// + /// Tells whether the inlines are - [X](https://x.com), a checked task followed by an automatic link. private static bool IsTaskListLink(IReadOnlyList parts) => parts is [TaskList, LiteralInline open, LinkInline { IsAutoLink: true } link, LiteralInline close, ..] && open.Content.ToString() == "(" && close.Content.ToString().StartsWith(')') && !string.IsNullOrWhiteSpace(link.Url); + /// + /// Reads - [X](https://x.com), which Markdown parses as a checked task followed by an automatic link. + /// private static DashboardTile? ReadTaskListLink(IReadOnlyList parts, MarkdownPipeline pipeline) { if (!IsTaskListLink(parts)) return null; @@ -473,7 +480,7 @@ private static bool IsTaskListLink(IReadOnlyList parts) => var name = task.Checked ? "X" : " "; var description = RenderDescription(close.Content.ToString()[1..], parts.Skip(4), pipeline); - return new DashboardTile(link.Url, null, name, name, description, null, string.Empty); + return new DashboardTile(link.Url!, null, name, name, description, null, string.Empty); } private static bool IsIcon(Inline inline) => diff --git a/Source/Markdown.Renderer/Res/Js/dashboard-folds.js b/Source/Markdown.Renderer/Res/Js/dashboard-folds.js index 613db940..9587bd94 100644 --- a/Source/Markdown.Renderer/Res/Js/dashboard-folds.js +++ b/Source/Markdown.Renderer/Res/Js/dashboard-folds.js @@ -30,6 +30,7 @@ toggle.setAttribute("aria-expanded", String(!group.classList.contains("is-collapsed"))); } + // dashboard.js folds and counts with the same helpers, so both scripts keep one rule. const apply = (group, collapsed, expanded) => { group.classList.toggle("is-collapsed", collapsed); group.classList.toggle("is-expanded", expanded); @@ -39,38 +40,40 @@ more.setAttribute("aria-expanded", String(expanded)); more.textContent = expanded ? "Show fewer" : `Show ${more.dataset.more} more`; }; + // A use counts half after 30 days; an entry without a count or a time counts nothing. + const score = (use, now) => Number.isFinite(use?.n) && Number.isFinite(use?.t) ? use.n * 0.5 ** ((now - use.t) / (30 * 864e5)) : 0; + const readStore = (key) => { + try { + const value = JSON.parse(localStorage.getItem(key) || "{}"); + return value && typeof value === "object" ? value : {}; + } catch { + return {}; + } + }; + dashboard.dashboardFolds = { apply, score, readStore }; - let folds = {}; - try { - folds = JSON.parse(localStorage.getItem(dashboard.dataset.foldKey) || "{}") || {}; - } catch { - // Without storage, the groups start as the page defines them. - } + // Without storage, the groups start as the page defines them. + const folds = readStore(dashboard.dataset.foldKey); groups.forEach((group, index) => { const saved = folds[group.querySelector("h2")?.id || String(index)]; if (saved) apply(group, !!saved.collapsed, !!saved.expanded); }); // The row of frequently used links shows copies of the tiles this reader opened at least twice, those opened often and - // lately first; a use counts half after 30 days. dashboard.js counts the uses in the same entry. + // lately first. dashboard.js counts the uses in the same entry. const frequent = dashboard.querySelector(".dashboard-frequent"); if (frequent) { dashboard.dataset.frequentKey = `mdu-dashboard-frequent:${page}`; - let uses = {}; - try { - uses = JSON.parse(localStorage.getItem(dashboard.dataset.frequentKey) || "{}") || {}; - } catch { - // Without storage, the row stays hidden. - } + // Without storage, the row stays hidden. + const uses = readStore(dashboard.dataset.frequentKey); const links = new Map(); - for (const group of groups) - for (const link of group.querySelectorAll(".dashboard-tile a.dashboard-link")) - if (!links.has(link.href)) links.set(link.href, link); + // Tiles outside any group count too. + for (const link of dashboard.querySelectorAll(".dashboard-tile a.dashboard-link")) + if (!frequent.contains(link) && !links.has(link.href)) links.set(link.href, link); const now = Date.now(); - const score = use => use.n * 0.5 ** ((now - use.t) / (30 * 864e5)); const picks = Object.entries(uses) - .filter(([href, use]) => use?.n >= 2 && links.has(href)) - .sort(([, a], [, b]) => score(b) - score(a)) + .filter(([href, use]) => use?.n >= 2 && score(use, now) > 0 && links.has(href)) + .sort(([, a], [, b]) => score(b, now) - score(a, now)) .slice(0, Number(frequent.dataset.frequent) || 6); const list = frequent.querySelector(".dashboard-tiles"); picks.forEach(([href], index) => { diff --git a/Source/Markdown.Renderer/Res/Js/dashboard.js b/Source/Markdown.Renderer/Res/Js/dashboard.js index 167d1561..41e2d05b 100644 --- a/Source/Markdown.Renderer/Res/Js/dashboard.js +++ b/Source/Markdown.Renderer/Res/Js/dashboard.js @@ -76,31 +76,16 @@ // stays in this browser for this page; dashboard-folds.js applies it while the page loads. The page works the same // without storage. const foldKey = dashboard.dataset.foldKey; - const readFolds = () => { - try { - return foldKey ? JSON.parse(localStorage.getItem(foldKey) || "{}") || {} : {}; - } catch { - return {}; - } - }; - const folds = readFolds(); + // dashboard-folds.js shares its helpers; a dashboard without groups has no folds and no row to fill. + const { apply, score, readStore } = dashboard.dashboardFolds || + { apply: () => {}, score: () => 0, readStore: () => ({}) }; + const folds = foldKey ? readStore(foldKey) : {}; const groupKey = (group) => group.querySelector("h2")?.id || String(groups.indexOf(group)); const isFolded = (tile) => { const group = tile.closest(".dashboard-group"); return !!group && (group.classList.contains("is-collapsed") || (tile.classList.contains("is-overflow") && !group.classList.contains("is-expanded"))); }; - const setCollapsed = (group, collapsed) => { - group.classList.toggle("is-collapsed", collapsed); - group.querySelector(".dashboard-group-toggle")?.setAttribute("aria-expanded", String(!collapsed)); - }; - const setExpanded = (group, expanded) => { - group.classList.toggle("is-expanded", expanded); - const more = group.querySelector(".dashboard-more"); - if (!more) return; - more.setAttribute("aria-expanded", String(expanded)); - more.textContent = expanded ? "Show fewer" : `Show ${more.dataset.more} more`; - }; const saveFolds = (group) => { folds[groupKey(group)] = { collapsed: group.classList.contains("is-collapsed"), @@ -379,8 +364,9 @@ } for (const section of sections) { const groupFolds = section.heads[0].closest(".dashboard-group")?.matches(".is-collapsed, :has(.is-overflow)"); - const hidden = (terms.length > 0 || groupFolds) && - !section.parts.some(part => part.querySelector(".dashboard-tile:not([hidden]), .dashboard-note")); + // A note past the limit hides with the tiles there, so it does not keep its subheading in view. + const hidden = (terms.length > 0 || groupFolds) && !section.parts.some(part => + [...part.querySelectorAll(".dashboard-tile, .dashboard-note")].some(item => !item.hidden && (terms.length > 0 || !isFolded(item)))); for (const head of section.heads) head.hidden = hidden; } // A subheading that ends up first below its group label keeps the tight spacing of one that starts the group. @@ -555,12 +541,13 @@ const countUse = (link) => { if (!frequentKey || !link?.matches("a.dashboard-link")) return; try { - const uses = JSON.parse(localStorage.getItem(frequentKey) || "{}") || {}; + const uses = readStore(frequentKey); const now = Date.now(); - uses[link.href] = { n: (uses[link.href]?.n || 0) + 1, t: now }; + const before = uses[link.href]?.n; + uses[link.href] = { n: (Number.isFinite(before) ? before : 0) + 1, t: now }; // Only the most used links are worth keeping. - const score = use => use.n * 0.5 ** ((now - use.t) / (30 * 864e5)); - const kept = Object.entries(uses).sort(([, a], [, b]) => score(b) - score(a)).slice(0, 50); + const kept = Object.entries(uses).filter(([, use]) => score(use, now) > 0) + .sort(([, a], [, b]) => score(b, now) - score(a, now)).slice(0, 50); localStorage.setItem(frequentKey, JSON.stringify(Object.fromEntries(kept))); } catch { // Without storage, nothing is counted. @@ -602,9 +589,10 @@ // While the filter shows matches from every group, a heading clears the filter and opens its group. if (button.matches(".dashboard-group-toggle") && input.value.trim()) { input.value = ""; - setCollapsed(group, false); - } else if (button.matches(".dashboard-group-toggle")) setCollapsed(group, !group.classList.contains("is-collapsed")); - else setExpanded(group, !group.classList.contains("is-expanded")); + apply(group, false, group.classList.contains("is-expanded")); + } else if (button.matches(".dashboard-group-toggle")) + apply(group, !group.classList.contains("is-collapsed"), group.classList.contains("is-expanded")); + else apply(group, group.classList.contains("is-collapsed"), !group.classList.contains("is-expanded")); saveFolds(group); update(); // Showing fewer tiles must not leave the button far below the viewport. @@ -710,21 +698,24 @@ const up = await reach(url.href); status.classList.add(up ? "is-up" : "is-down"); status.setAttribute("aria-label", up ? "Reachable" : "Not reachable"); - if (!up) countDown(status.closest(".dashboard-group")); + if (!up) countDown(status.closest(".dashboard-group"), url.href); }; - // A group label counts its unreachable sites, so a folded group still tells that something is down. + // A group label counts its unreachable sites, so a folded group still tells that something is down. A site listed + // twice in a group counts once. const down = new Map(); - const countDown = (group) => { + const countDown = (group, href) => { const toggle = group && !group.matches(".dashboard-frequent") ? group.querySelector(".dashboard-group-toggle") : null; if (!toggle) return; - down.set(group, (down.get(group) || 0) + 1); + const sites = down.get(group) || new Set(); + if (sites.has(href)) return; + down.set(group, sites.add(href)); let label = toggle.querySelector(".dashboard-group-down"); if (!label) { label = document.createElement("span"); label.className = "dashboard-group-down"; toggle.querySelector(".dashboard-group-count")?.after(label); } - label.textContent = `${down.get(group)} offline`; + label.textContent = `${sites.size} offline`; }; for (const status of dashboard.querySelectorAll(".dashboard-status")) check(status.closest("a"), status);