Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Docs/Features/Dashboard-Example.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
---
layout: dashboard
dashboard:
limit: 2
collapsed: [Icons]
frequent: 4
navigation:
visible: false
---
Expand Down
26 changes: 22 additions & 4 deletions Docs/Features/Dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -98,17 +98,35 @@ layout: dashboard
dashboard:
search: duckduckgo
clock: true
status: false
status: [Operations]
compact: false
open: same-tab
limit: 6
collapsed: [AI, Social]
frequent: 6
---
```

| Option | Values | Default | Description |
| --- | --- | --- | --- |
| `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

Expand Down
189 changes: 186 additions & 3 deletions Source/Markdown.Renderer.Test/DashboardTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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("<section class=\"dashboard-group\" role=\"rowgroup\">", html);
Assert.Contains("<section class=\"dashboard-group is-collapsed\" role=\"rowgroup\">", html);
Assert.Contains("<h2 id=\"office\"><button class=\"dashboard-group-toggle\" type=\"button\" aria-expanded=\"true\" disabled><span class=\"dashboard-group-title\">Office</span><span class=\"dashboard-group-count\" data-count=\"5\" aria-hidden=\"true\"></span>", html);
// One tile past the limit shows instead of a button for it.
Assert.Contains("data-count=\"3\"", html);
// A link in the heading keeps only its text inside the button. Until the fold script runs, every group is open.
Assert.Contains("aria-expanded=\"true\" disabled><span class=\"dashboard-group-title\">AI</span><span class=\"dashboard-group-count\" data-count=\"1\"", html);
// A task-list link counts as a tile; an item with only an image is a note.
Assert.Equal(3, CountOf(html, "<li class=\"dashboard-tile is-overflow\" role=\"row\">"));
Assert.Equal(2, CountOf(html, "<li class=\"dashboard-note is-overflow\""));
Assert.Equal(1, CountOf(html, "class=\"dashboard-more\""));
Assert.Contains("aria-expanded=\"false\" data-more=\"3\">Show 3 more</button>", html);
// A note past the limit folds with the tiles around it.
Assert.Contains("<li class=\"dashboard-note is-overflow\" role=\"row\"><div role=\"gridcell\">Moved to the archive drive</div></li>", html);
// A footnote reference follows the button, so it still links to its footnote.
Assert.Matches("<span class=\"dashboard-group-title\">Tools</span>.*?</button><a [^>]*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("<div class=\"is-overflow\" role=\"row\"><div role=\"gridcell\">\n<h3", html.ReplaceLineEndings("\n"));
Assert.Contains("<div class=\"is-overflow\" role=\"row\"><div role=\"gridcell\">\n<p>Older services.</p>", html.ReplaceLineEndings("\n"));
Assert.Contains("<div class=\"is-overflow\" role=\"row\"><div role=\"gridcell\"><ul>", html);
Assert.DoesNotContain("<div class=\"is-overflow\" role=\"row\"><div role=\"gridcell\">\n<h2", html.ReplaceLineEndings("\n"));
}

[Fact]
public void ReadsTheFrequentlyUsedLinks()
{
DashboardOptions Read(string value) => Dashboard.ReadFrontMatter($"---\nlayout: dashboard\ndashboard:\n frequent: {value}\n---")!;
Assert.Equal(4, Read("4").Frequent);
Assert.Equal(6, Read("true").Frequent);
Assert.Null(Read("false").Frequent);
Assert.Null(Read("0").Frequent);
Assert.Null(Read("often").Frequent);
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, "<span class=\"dashboard-status\""));
Assert.Matches("proxmox\\.example\\.org\"[^<]*>.*?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("<div class=\"dashboard is-compact\">", compact);
// Only the script can change the view, so it shows the switch.
Assert.Contains("<button class=\"dashboard-view\" id=\"dashboard-view\" type=\"button\" aria-pressed=\"true\" title=\"Compact view\" hidden>", compact);

var detailed = await RenderPage(markdown, new DashboardOptions(null, false, false, false));
Assert.Contains("<div class=\"dashboard\">", detailed);
Assert.Contains("aria-pressed=\"false\" title=\"Compact view\" hidden>", detailed);
Assert.DoesNotContain("id=\"dashboard-view\"", await RenderPage("Just text.", new DashboardOptions(null, false, false, false)));
}

[Fact]
public async Task LeavesAHiddenRowForTheFrequentlyUsedLinks()
{
const string markdown = "## Office\n\n- [Mail](https://mail.example.org)\n";
var html = await RenderPage(markdown, new DashboardOptions(null, false, false, false, Frequent: 4));
// The row comes before the groups and stays hidden until the script fills it.
Assert.Matches(
"<div id=\"dashboard-results\"[^>]*>\\s*<div class=\"dashboard-row dashboard-frequent-row\" role=\"presentation\" hidden>\\s*" +
"<section class=\"dashboard-group dashboard-frequent\" role=\"rowgroup\" data-frequent=\"4\">",
html);
Assert.Contains("<ul class=\"dashboard-tiles\" role=\"presentation\"></ul>", html);
Assert.Contains("aria-label=\"Clear frequently used links\"", html);

Assert.DoesNotContain("<section class=\"dashboard-group dashboard-frequent\"", await RenderPage(markdown, new DashboardOptions(null, false, false, false)));
}

[Fact]
public async Task RendersGroupsOfLinkTiles()
{
Expand Down Expand Up @@ -66,7 +249,6 @@ Everything in one place.
Assert.Equal(2, CountOf(html, "<div class=\"dashboard-row\" role=\"presentation\">"));
Assert.Equal(2, CountOf(html, "<section class=\"dashboard-group\" role=\"rowgroup\">"));

Assert.Contains("<ul class=\"dashboard-tiles has-descriptions\" role=\"presentation\">", html);
Assert.Contains("<a class=\"dashboard-link\" href=\"https://mail.google.com\" target=\"_blank\" rel=\"noopener noreferrer\"><span class=\"dashboard-icon\"><span class=\"icon\">", html);
Assert.Contains("<span class=\"dashboard-name\">Mail</span><span class=\"dashboard-description\">Inbox &amp; calendar</span>", html);
Assert.Contains("<a class=\"dashboard-link\" href=\"Guide/Handbook.html\">", html);
Expand Down Expand Up @@ -148,8 +330,9 @@ public async Task ShowsTheHostOnlyWhereItAddsToTheName()

Assert.Contains("<span class=\"dashboard-name\">Netflix</span></span>", html);
Assert.Contains("<span class=\"dashboard-name\">Grafana</span><span class=\"dashboard-description\">grafana.example.org</span>", html);
// Described tiles keep two lines, so the host still fills the second one.
Assert.Contains("<span class=\"dashboard-name\">GitHub</span><span class=\"dashboard-description\">github.com</span>", html);
// Every tile follows the same rule, whether or not its list has descriptions.
Assert.Contains("<span class=\"dashboard-name\">GitHub</span></span>", html);
Assert.DoesNotContain("has-descriptions", html);
Assert.Contains("href=\"https://www.netflix.com\"", html);
}

Expand Down
Loading
Loading