From 2f4a388a4cc40f133409ebf451d6294a5da63e39 Mon Sep 17 00:00:00 2001 From: Louis King Date: Wed, 6 May 2026 19:02:18 +0100 Subject: [PATCH] feat: improve markdown prose styling, map popup overlay, and collapsible filters - Add .prose > :first-child margin-top:0 to eliminate double-spacing before headings - Add nested list CSS (circle/square bullets, lower-alpha/roman numbering) - Replace map popup

/space-y-1 layout with CSS grid for aligned label-value pairs - Convert map filter card to collapsible

with state persistence - Add nested list HTML output tests and markdown features docs --- docs/content.md | 18 +++ .../20260506-1830-markdown-parsing/plan.md | 145 ++++++++++++++++++ .../20260506-1830-markdown-parsing/tasks.md | 17 ++ src/meshcore_hub/web/static/css/app.css | 11 ++ .../web/static/js/spa/pages/map.js | 64 ++++---- tests/test_web/test_pages.py | 54 +++++++ 6 files changed, 280 insertions(+), 29 deletions(-) create mode 100644 docs/plans/20260506-1830-markdown-parsing/plan.md create mode 100644 docs/plans/20260506-1830-markdown-parsing/tasks.md diff --git a/docs/content.md b/docs/content.md index b7afe21..17b093b 100644 --- a/docs/content.md +++ b/docs/content.md @@ -77,6 +77,24 @@ Markdown content here (include your own heading)... The markdown content is rendered as-is, so include your own `# Heading` if desired. +### Supported Markdown Features + +Pages are rendered with [Python-Markdown](https://python-markdown.github.io/) with the following extensions enabled: + +| Feature | Syntax | Notes | +|---------|--------|-------| +| Headings | `# H1` through `### H3` | Rendered with `.prose` styling | +| Bold / Italic | `**bold**`, `*italic*` | Standard Markdown | +| Links | `[text](url)` | Relative paths supported | +| Unordered lists | `- item` or `* item` | Nested lists supported (3 levels) | +| Ordered lists | `1. item` | Nested lists supported (3 levels) | +| Tables | Pipe-delimited (`\| Header \|`) | Auto-generated ``/`` | +| Fenced code blocks | ` ``` ` with optional language | Syntax highlighting via `codehilite` extension | +| Inline code | `` `code` `` | Styled with monospace font | +| Blockquotes | `> quote` | Left border styling | +| Images | `![alt](/media/image.png)` | Use absolute paths to `/media/` | +| Table of contents | `[TOC]` marker | Auto-generated from headings | + ## Docker Configuration With Docker, mount the content directory as a read-only volume. This is already configured in `docker-compose.yml`: diff --git a/docs/plans/20260506-1830-markdown-parsing/plan.md b/docs/plans/20260506-1830-markdown-parsing/plan.md new file mode 100644 index 0000000..431e0c6 --- /dev/null +++ b/docs/plans/20260506-1830-markdown-parsing/plan.md @@ -0,0 +1,145 @@ +# Plan: Markdown Nested Lists & Map Collapsible Filters + +Date: 2026-05-06 + +## Part A: Fix Markdown Prose Styling + +### Problem + +Two CSS issues in custom markdown pages: + +1. **Excessive top spacing** — The first element inside `.prose` (typically an `

`) gets both the DaisyUI `card-body` padding AND its own `margin-top: 1.5rem`, creating a large gap before content starts. The `.prose h1` rule at `app.css:166` applies `margin-top: 1.5rem` unconditionally. + +2. **No nested list differentiation** — All list levels use the same bullet style (`disc`) because the `.prose` CSS in `app.css:199-215` only styles `ul`, `ol`, and `li` at a single level. Python-Markdown correctly generates nested `
    `/`
      ` HTML — the issue is CSS-only. + +### Changes + +#### 1. Remove top margin on first prose child (`app.css`) + +Add after the `.prose` block (after line 256, the closing brace): + +```css +.prose > :first-child { + margin-top: 0; +} +``` + +This eliminates the double-spacing (card-body padding + h1 margin-top) for the first element. Subsequent headings still get their normal `margin-top` for visual separation between sections. + +**Specificity note:** `.prose > :first-child` and `.prose h1` have identical specificity (0-1-1). This rule works only because it appears later in the source. Avoid adding new heading styles after this rule — the top-margin override would silently break. + +#### 2. Add nested list CSS to `app.css` + +Add after the `.prose li` block (line 215): + +```css +.prose ul ul { list-style-type: circle; } +.prose ul ul ul { list-style-type: square; } +.prose ol ol { list-style-type: lower-alpha; } +.prose ol ol ol { list-style-type: lower-roman; } +.prose ul ul, .prose ul ol, +.prose ol ul, .prose ol ol { margin-top: 0.25rem; margin-bottom: 0; } +``` + +This provides: +- Level 1: disc / decimal +- Level 2: circle / lower-alpha +- Level 3+: square / lower-roman + +#### 3. Update `docs/content.md` + +Add a "Supported Markdown Features" section documenting: +- Basic formatting (headings, bold, italic, links) +- Lists (ordered, unordered, nested) +- Tables (pipe-delimited) +- Code blocks (fenced with triple backticks, optional language) +- Table of contents (`[TOC]` marker) +- Images (require absolute paths to `/media/`) + +### Files + +| File | Change | +|------|--------| +| `src/meshcore_hub/web/static/css/app.css` | Add `:first-child` rule + nested list CSS rules | +| `docs/content.md` | Add markdown features section | +| `tests/test_web/test_pages.py` | Add test for nested list HTML output | + +--- + +## Part B: Map Page Collapsible Filters + +### Problem + +The map page (`src/meshcore_hub/web/static/js/spa/pages/map.js:195-237`) uses a hardcoded non-collapsible filter card: + +```html +
      +``` + +Other list pages (nodes, messages, advertisements) use `renderFilterCard()` from `components.js:724-766` with `collapsible: true`, which renders as: + +```html +
      +``` + +The map page is inconsistent with the rest of the SPA. + +### Approach + +Wrap the map's existing filter controls in the same collapsible `
      ` DaisyUI pattern, but keep the map's custom client-side filter logic. Do **not** refactor to use `renderFilterCard()` — the map has unique behaviors (client-side filtering, show-labels toggle, member filter triggers API re-fetch) that don't fit the shared component's server-side form submission model. + +### Changes + +#### 1. Replace filter card HTML in `map.js` + +Replace lines 195-237 (the hardcoded `
      `) with: + +```html +
      + + ${t('common.filters')} + +
      +
      + +
      +
      +
      +``` + +#### 2. Add collapsible state persistence + +Before the `litRender()` call, add the same pattern used by other pages: + +```javascript +const existingDetails = container.querySelector('details.collapse'); +const isFilterOpen = existingDetails ? existingDetails.open : false; +``` + +**Behavior note:** On first visit (no prior DOM state), the filter starts **closed**. This is consistent with all other list pages (nodes, messages, advertisements), but is a change from the current map behavior where the filter card is always visible. Users who want to see filters will need to click to expand — same as every other page in the SPA. + +**Re-render note:** State persistence works because `container.querySelector` inspects the pre-existing DOM before `litRender` replaces content. If the map ever re-renders on every pan/zoom (not just filter changes), the filter would collapse on each re-render. The current map only re-renders on filter changes, so this is not an issue. + +#### 3. Preserve all existing filter logic + +- `applyFilters()` / `applyFiltersCore()` — unchanged +- `@change=${applyFilters}` event handlers — unchanged +- `clearFiltersHandler()` — unchanged +- Show-labels checkbox — unchanged +- Member filter re-fetch logic — unchanged + +### Files + +| File | Change | +|------|--------| +| `src/meshcore_hub/web/static/js/spa/pages/map.js` | Replace filter card HTML with collapsible `
      `, add state persistence | + +--- + +## Testing + +- `pytest tests/test_web/test_pages.py` — verify Python-Markdown generates nested `
        `/`
          ` HTML for indented markdown (NOT CSS rendering) +- `pre-commit run --all-files` — quality checks +- Manual: create a test `.md` page with nested lists, verify visual rendering with different bullet/indentation levels +- Manual: load map page, verify filter section collapses/expands correctly, survives re-render after filter changes diff --git a/docs/plans/20260506-1830-markdown-parsing/tasks.md b/docs/plans/20260506-1830-markdown-parsing/tasks.md new file mode 100644 index 0000000..11a5dd5 --- /dev/null +++ b/docs/plans/20260506-1830-markdown-parsing/tasks.md @@ -0,0 +1,17 @@ +# Tasks: Markdown Nested Lists & Map Collapsible Filters + +## Part A: Fix Markdown Prose Styling + +- [ ] Add `.prose > :first-child { margin-top: 0; }` to `app.css` after the `.prose` block (after line 256) +- [ ] Add nested list CSS rules to `app.css` after the `.prose li` block (line 215) +- [ ] Update `docs/content.md` with "Supported Markdown Features" section +- [ ] Add nested list HTML output test to `tests/test_web/test_pages.py` +- [ ] Run `pytest tests/test_web/test_pages.py` +- [ ] Run `pre-commit run --all-files` + +## Part B: Map Page Collapsible Filters + +- [ ] Add `isFilterOpen` state persistence logic before `litRender()` in `map.js` +- [ ] Replace hardcoded `
          ` filter card (lines 195-237) with collapsible `
          ` in `map.js` +- [ ] Verify all existing filter logic preserved (`applyFilters`, `clearFiltersHandler`, show-labels, member filter) +- [ ] Manual: load map page, verify filter collapses/expands, survives re-render after filter changes diff --git a/src/meshcore_hub/web/static/css/app.css b/src/meshcore_hub/web/static/css/app.css index 57cbe71..e42499d 100644 --- a/src/meshcore_hub/web/static/css/app.css +++ b/src/meshcore_hub/web/static/css/app.css @@ -214,6 +214,13 @@ margin-bottom: 0.25rem; } +.prose ul ul { list-style-type: circle; } +.prose ul ul ul { list-style-type: square; } +.prose ol ol { list-style-type: lower-alpha; } +.prose ol ol ol { list-style-type: lower-roman; } +.prose ul ul, .prose ul ol, +.prose ol ul, .prose ol ol { margin-top: 0.25rem; margin-bottom: 0; } + .prose a { color: var(--color-primary); text-decoration: underline; @@ -255,6 +262,10 @@ word-break: break-word; } +.prose > :first-child { + margin-top: 0; +} + .prose table { width: 100%; margin-bottom: 1rem; diff --git a/src/meshcore_hub/web/static/js/spa/pages/map.js b/src/meshcore_hub/web/static/js/spa/pages/map.js index 84a56c4..718df11 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/map.js +++ b/src/meshcore_hub/web/static/js/spa/pages/map.js @@ -67,19 +67,6 @@ function createNodeIcon(node, oidcEnabled) { // Leaflet popup requires plain HTML strings, so keep escapeHtml here function createPopupContent(node, oidcEnabled) { - let ownerHtml = ''; - if (node.owner) { - const ownerDisplay = node.owner.callsign - ? escapeHtml(node.owner.name) + ' (' + escapeHtml(node.owner.callsign) + ')' - : escapeHtml(node.owner.name); - ownerHtml = '

          ' + ((window.t && window.t('map.owner')) || 'Owner:') + ' ' + ownerDisplay + '

          '; - } - - let roleHtml = ''; - if (node.role) { - roleHtml = '

          ' + ((window.t && window.t('map.role')) || 'Role:') + ' ' + escapeHtml(node.role) + '

          '; - } - const typeDisplay = getTypeDisplay(node); const nodeTypeEmoji = typeEmoji(node.adv_type); @@ -91,27 +78,39 @@ function createPopupContent(node, oidcEnabled) { infraIndicatorHtml = ' '; } - const lastSeenLabel = (window.t && window.t('common.last_seen_label')) || 'Last seen:'; - const lastSeenHtml = node.last_seen - ? '

          ' + lastSeenLabel + ' ' + node.last_seen.substring(0, 19).replace('T', ' ') + '

          ' - : ''; - const typeLabel = (window.t && window.t('common.type')) || 'Type:'; const keyLabel = (window.t && window.t('common.key')) || 'Key:'; const locationLabel = (window.t && window.t('common.location')) || 'Location:'; + const lastSeenLabel = (window.t && window.t('common.last_seen_label')) || 'Last seen:'; const unknownLabel = (window.t && window.t('node_types.unknown')) || 'Unknown'; const viewDetailsLabel = (window.t && window.t('common.view_details')) || 'View Details'; + let rows = ''; + rows += '
          ' + typeLabel + '
          ' + escapeHtml(typeDisplay) + '
          '; + + if (node.role) { + const roleLabel = (window.t && window.t('map.role')) || 'Role:'; + rows += '
          ' + roleLabel + '
          ' + escapeHtml(node.role) + '
          '; + } + + if (node.owner) { + const ownerLabel = (window.t && window.t('map.owner')) || 'Owner:'; + const ownerDisplay = node.owner.callsign + ? escapeHtml(node.owner.name) + ' (' + escapeHtml(node.owner.callsign) + ')' + : escapeHtml(node.owner.name); + rows += '
          ' + ownerLabel + '
          ' + ownerDisplay + '
          '; + } + + rows += '
          ' + keyLabel + '
          ' + escapeHtml(node.public_key.substring(0, 16)) + '...
          '; + rows += '
          ' + locationLabel + '
          ' + node.lat.toFixed(4) + ', ' + node.lon.toFixed(4) + '
          '; + + if (node.last_seen) { + rows += '
          ' + lastSeenLabel + '
          ' + node.last_seen.substring(0, 19).replace('T', ' ') + '
          '; + } + return '
          ' + '

          ' + nodeTypeEmoji + ' ' + escapeHtml(node.name || unknownLabel) + infraIndicatorHtml + '

          ' + - '
          ' + - '

          ' + typeLabel + ' ' + escapeHtml(typeDisplay) + '

          ' + - roleHtml + - ownerHtml + - '

          ' + keyLabel + ' ' + escapeHtml(node.public_key.substring(0, 16)) + '...

          ' + - '

          ' + locationLabel + ' ' + node.lat.toFixed(4) + ', ' + node.lon.toFixed(4) + '

          ' + - lastSeenHtml + - '
          ' + + '
          ' + rows + '
          ' + '' + viewDetailsLabel + '' + '
          '; } @@ -182,6 +181,9 @@ export async function render(container, params, router) { applyFilters(); } + const existingDetails = container.querySelector('details.collapse'); + const isFilterOpen = existingDetails ? existingDetails.open : false; + litRender(html`

          ${t('entities.map')}

          @@ -192,8 +194,12 @@ export async function render(container, params, router) {
          -
          -
          +
          + + ${t('common.filters')} + +
          @@ -234,7 +240,7 @@ export async function render(container, params, router) {
          -
          +
          diff --git a/tests/test_web/test_pages.py b/tests/test_web/test_pages.py index f0b6fd7..7ab265a 100644 --- a/tests/test_web/test_pages.py +++ b/tests/test_web/test_pages.py @@ -313,6 +313,60 @@ def hello(): assert "
          " in pages[0].content_html
                       assert "def hello():" in pages[0].content_html
           
          +    def test_markdown_nested_unordered_list(self) -> None:
          +        """Test that nested unordered lists produce nested 
            elements.""" + with tempfile.TemporaryDirectory() as tmpdir: + (Path(tmpdir) / "nested.md").write_text("""--- +title: Nested +--- + +- Item 1 + - Sub item A + - Sub item B + - Deep item +- Item 2 +""") + + loader = PageLoader(tmpdir) + loader.load_pages() + + pages = loader.get_menu_pages() + assert len(pages) == 1 + html = pages[0].content_html + assert "
              " in html + assert "
            • Item 1" in html + assert "
            • Sub item A" in html + assert "
            • Deep item" in html + outer_ul = html.index("
                ") + inner_ul = html.index("
                  ", outer_ul + 1) + assert inner_ul > outer_ul + + def test_markdown_nested_ordered_list(self) -> None: + """Test that nested ordered lists produce nested
                    elements.""" + with tempfile.TemporaryDirectory() as tmpdir: + (Path(tmpdir) / "nested-ol.md").write_text("""--- +title: Nested OL +--- + +1. First + 1. Sub first + 2. Sub second +2. Second +""") + + loader = PageLoader(tmpdir) + loader.load_pages() + + pages = loader.get_menu_pages() + assert len(pages) == 1 + html = pages[0].content_html + assert "
                      " in html + assert "
                    1. First" in html + assert "
                    2. Sub first" in html + outer_ol = html.index("
                        ") + inner_ol = html.index("
                          ", outer_ol + 1) + assert inner_ol > outer_ol + class TestPagesRoute: """Tests for the custom pages routes (SPA).