diff --git a/Docs/Features/Dashboard-Example.md b/Docs/Features/Dashboard-Example.md index 70e32d2..9bf1a70 100644 --- a/Docs/Features/Dashboard-Example.md +++ b/Docs/Features/Dashboard-Example.md @@ -1,5 +1,9 @@ --- layout: dashboard +dashboard: + limit: 2 + collapsed: [Icons] + frequent: 4 navigation: visible: false --- diff --git a/Docs/Features/Dashboard.md b/Docs/Features/Dashboard.md index 8b796cd..0ffda1f 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,8 +98,12 @@ layout: dashboard dashboard: search: duckduckgo clock: true - status: false + status: [Operations] + compact: false open: same-tab + limit: 6 + collapsed: [AI, Social] + frequent: 6 --- ``` @@ -107,8 +111,22 @@ 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. | +| `frequent` | A number, `true`, `false` | `false` | Adds a row of the links this reader opens most above the groups. A number sets how many it shows; `true` shows 6. | + +## 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. Without JavaScript, every group shows all of its tiles. + +## Frequently used + +With `frequent`, the dashboard learns which links you use. Every link you open from a tile or with Enter in the filter counts in your browser, and nothing is sent anywhere. A link you opened at least twice appears in a row above the groups, the most used first; recent uses count more than old ones, so the row follows your habits. The row changes on your next visit, never while you are on the page. The filter searches only the original tiles, so the row steps aside while it has text. **Clear** forgets every counted use for this page. Without JavaScript or browser storage, the row does not appear. ## Behavior diff --git a/Source/Markdown.Renderer.Test/DashboardTest.cs b/Source/Markdown.Renderer.Test/DashboardTest.cs index 7324a9b..b9a59f9 100644 --- a/Source/Markdown.Renderer.Test/DashboardTest.cs +++ b/Source/Markdown.Renderer.Test/DashboardTest.cs @@ -35,6 +35,189 @@ 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) + - [ ](https://task.example.org) + - ![Logo](logo.png) + - Moved to the archive drive + + ## Tools[^1] + + - [Editor](https://editor.example.org) + - [Shell](https://shell.example.org) + - [Notes](https://notes.example.org) + + ## [AI](https://ai.example.org) + + - [Claude](https://claude.ai) + + [^1]: Installed locally. + """, Options with { Limit = 2, Collapsed = ["ai"] }); + + Assert.Contains("
", html); + Assert.Contains("
", html); + Assert.Contains("

", 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] + 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("

    ") + .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($""); + } // The filter script fills these containers, so they exist even on a dashboard without groups. html.AppendLine("
      "); + group = null; + } + + for (var index = 0; index < blocks.Count; index++) + { + switch (blocks[index]) { case ThematicBreakBlock: - if (groupOpen) html.AppendLine(""); - groupOpen = false; + CloseGroup(); html.AppendLine(""); html.AppendLine("
      "); break; case HeadingBlock { Level: 2 } heading: - if (groupOpen) html.AppendLine(""); - groupOpen = true; - html.AppendLine("
      "); + CloseGroup(); + var title = PlainText(heading.Inline ?? Enumerable.Empty()).Trim(); + group = new GroupState(CountGroupTiles(blocks, index + 1), options.Limit, options.ChecksStatus(title)); + html.AppendLine($"
      "); html.AppendLine("
      "); - html.Append(RenderBlock(heading, pipeline)); + html.Append(RenderGroupHeading(RenderBlock(heading, pipeline), group.Total)); html.AppendLine("
      "); break; case ListBlock list: - if (groupOpen) html.Append(RenderTiles(list, pipeline, options, usedIds, ref tileIndex)); + if (group is not null) 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; - default: - if (!groupOpen) html.AppendLine("
      "); - 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; } } - if (groupOpen) html.AppendLine("
      "); + CloseGroup(); html.AppendLine("
      "); return html.ToString(); } + /// Counts the tiles of the group whose blocks start at . + 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(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, int tiles) + { + var start = headingHtml.IndexOf('>') + 1; + var end = headingHtml.LastIndexOf("", StringComparison.Ordinal); + if (start <= 0 || end < start) return headingHtml; + 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..]; + } + + /// + /// 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. + /// 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, bool status) + { + public int Total { get; } = total; + public int Shown { get; } = limit is { } value && total - value > 1 ? value : total; + public bool Status { get; } = status; + public int Written { get; set; } + public bool IsFull => Written >= Shown && Shown < Total; + } + private static HashSet ReadHeadingIds(IEnumerable headings, MarkdownPipeline pipeline) { var rendered = Render(renderer => @@ -205,34 +342,40 @@ private static string RenderToolbar(DashboardOptions options, bool hasGroups) html.AppendLine("

      "); html.AppendLine("

      "); if (options.Clock) html.AppendLine(""); + // dashboard-folds.js shows the switch, because only a script can change the view. + if (hasGroups) + html.AppendLine($""); html.Append(""); 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)) - return $"
      {RenderBlock(list, pipeline)}
      "; + return $"
      {RenderBlock(list, pipeline)}
      "; - var hasDescriptions = tiles.Any(tile => tile is not null && tile.Description.Length > 0); var html = new StringBuilder(); - html.AppendLine($"