From 715a0e354bf79a1422f4da2104c82b6e220231b5 Mon Sep 17 00:00:00 2001 From: igor Date: Mon, 26 Oct 2026 19:08:34 +0100 Subject: [PATCH] =?UTF-8?q?pridan=C3=A1=20generovan=C3=A1=20navig=C3=A1cia?= =?UTF-8?q?=20z=20H1=E2=80=93H3=20s=20unik=C3=A1tnymi=20ID,=20pridan=C3=BD?= =?UTF-8?q?=20responz=C3=ADvny=20panel=20a=20ukladanie=20stavu=20pod=C4=BE?= =?UTF-8?q?a=20URL,=20pridan=C3=A9=20verzovanie=20CSS=20a=20JS=20pomocou?= =?UTF-8?q?=20MD5=20hashov,=20aktualizovan=C3=A1=20dokument=C3=A1cia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 48 +++++++++++---- README.md | 29 +++++++++ src/markdown.css | 157 ++++++++++++++++++++++++++++++++++++++++++++++- src/md2html.php | 12 +++- src/toc.js | 94 ++++++++++++++++++++++++++++ 5 files changed, 325 insertions(+), 15 deletions(-) create mode 100644 src/toc.js diff --git a/AGENTS.md b/AGENTS.md index 672bd2c..d39cf4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,25 +3,35 @@ ## Purpose This project is a small Markdown-to-HTML utility used behind NGINX. When a `.md` file is requested, `src/md2html.php` loads the Markdown, converts it with Parsedown, and renders HTML styled by `src/markdown.css`. +`src/toc.js` builds an interactive table of contents from the rendered headings. ## Key Files - `src/md2html.php`: entrypoint for conversion and HTML output. - `src/markdown.css`: visual styling for rendered Markdown (`.markdown-body`). +- `src/toc.js`: vanilla JavaScript for the table of contents and per-document panel state. - `composer.json`: PHP dependency config (`erusev/parsedown`). +- `tests/fixtures/toc.md`: Markdown regression fixture. +- `tests/render.php`: checks PHP output and exports heading data for JS tests. +- `tests/toc.test.cjs`: Node.js tests using DOM doubles, without a browser. +- `tests/run.ps1`: PowerShell runner for PHP and JS integration checks. ## Environment Assumptions - Project root: `D:\www\md2html` - Markdown files are resolved from `D:\www\...` -- CSS is currently linked as `//localhost/md2html/src/markdown.css` +- CSS and JS are linked under `//localhost/md2html/src/` with `?h=`. +- NGINX forwards Markdown requests to `$document_root/md2html/src/md2html.php`; see README.md. ## Change Priorities 1. Keep output readable on desktop and mobile. 2. Preserve Markdown semantics generated by Parsedown. -3. Avoid introducing JS unless explicitly required. +3. Keep JavaScript in `src/toc.js`, use vanilla JavaScript, and avoid unnecessary dependencies. 4. Keep the utility simple and fast. ## Styling Rules (`src/markdown.css`) - Style only inside `.markdown-body` unless there is a strong reason to change global page behavior. +- Navigation is outside `.markdown-body`; scope its styles to `.md2html-toc*` and `.md2html-document` where possible. +- At widths above 760px, the open panel must leave space for the document; at smaller widths, use a dismissible overlay. +- Keep the toggle fixed and available, the panel independently scrollable, and reduced-motion preferences respected. - Must cover: headings, links, lists, blockquotes, inline code, code blocks, tables, images, hr. - Keep strong contrast and accessible font sizes. - Ensure long code lines and wide tables remain usable on small screens (`overflow-x`). @@ -29,20 +39,32 @@ When a `.md` file is requested, `src/md2html.php` loads the Markdown, converts i ## PHP Rules (`src/md2html.php`) - Keep dependency loading via the project root's `vendor/autoload.php`, resolved relative to the entrypoint directory. -- Keep response minimal: ``, UTF-8 meta, stylesheet link, wrapped content. +- Keep response minimal: ``, UTF-8 and viewport meta, stylesheet link, deferred script, navigation controls, and wrapped content. +- Keep CSS and JS URLs versioned using `md5_file()` on paths relative to `__DIR__`. +- Do not assign anonymous functions or closures to PHP variables; use named functions or class methods. +- Output helpers should return rendered content for callers to consume. - Do not remove `.md` extension guard. - Any path-handling changes must remain compatible with Windows paths. -## Recommended Validation After Edits -1. Open at least one Markdown file with: - - headings (`#`, `##`, `###`) - - lists - - fenced code block - - table - - blockquote - - image -2. Verify layout on desktop and mobile width. -3. Verify links and code blocks remain readable. +## Table of Contents Rules (`src/toc.js`) +- Build navigation from rendered H1–H3 in `.markdown-body`, preserving order and indicating levels with indentation. Do not add a Markdown parser. +- Keep existing unique IDs, generate collision-free IDs for other headings, and retain native anchor navigation. +- Use DOM creation and `textContent` for heading labels; do not insert untrusted content through `innerHTML`. +- Default to closed. Store panel state under `md2html:toc:` + pathname + query string, excluding the fragment. +- Handle unavailable localStorage without disabling navigation. +- Maintain ARIA state, keyboard access, Escape dismissal, mobile close controls, and readable output without JavaScript. + +## Validation After Edits +- Never use a browser or browser automation. Leave visual browser verification to Igor; use non-browser checks instead. +- Run relevant checks from the project root: + - `php -l src/md2html.php` + - `php -l tests/render.php` when changing the PHP test helper. + - `node --check src/toc.js` + - `./tests/run.ps1` + - `git diff --check` +- Tests require PHP with DOM, Composer dependencies, and Node.js supporting `node:test` and `--test-isolation=none`. +- Keep regression coverage for heading levels, formatting, code fences, duplicate and existing anchors, Unicode, empty and long contents, URL-specific state, blocked storage, mobile dismissal, asset hashes, and the `.md` guard. +- DOM doubles do not verify actual layout or scrolling. Igor manually checks desktop/mobile layout, long-panel scrolling, anchor navigation, and readability of Markdown elements. ## Non-Goals - Building a full CMS. diff --git a/README.md b/README.md index 087d707..52da40a 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Zdrojove subory su v adresari `src/`: - `src/md2html.php`: vstupny bod konverzie Markdown do HTML. - `src/markdown.css`: styly vystupneho HTML. +- `src/toc.js`: interaktivny obsah dokumentu bez externych JS zavislosti. Zavislosti sa instaluju cez `composer install` v koreni projektu do `vendor/`. Konfiguracia NGINX musi smerovat Markdown poziadavky na `src/md2html.php`. @@ -29,3 +30,31 @@ Konfiguracia predpoklada projekt v adresari `md2html` pod `root` daneho servera Subor `fastcgi_params` musi byt dostupny v konfiguracii NGINX. Po uprave over konfiguraciu prikazom `nginx -t` a nacitaj ju prikazom `nginx -s reload`. + +## Obsah dokumentu + +Tlacidlo v lavom hornom rohu otvara obsah vytvoreny z nadpisov H1 az H3. +Na desktope panel uvolni miesto vedla dokumentu, na mobile sa zobrazi ako overlay. +Zatvorit ho mozno tlacidlom, klavesom Escape alebo na mobile kliknutim mimo panelu. +Panel ma vlastne scrollovanie a odkazy smeruju na unikatne ID nadpisov. +Existujuce jednoznacne ID zostavaju zachovane. + +Stav sa uklada do localStorage pod klucom `md2html:toc:` + pathname + query string, +bez URL fragmentu. Pri nedostupnom ulozisku ovladanie funguje dalej. +Bez JavaScriptu zostava dokument citatelny, navigacne ovladanie je skryte. + +## Testovanie + +Testy vyzaduju PHP s DOM rozsirenim, nainstalovane Composer zavislosti a Node.js +s podporou `node:test` a `--test-isolation=none`. Nepouzivaju prehliadac ani dalsie balicky. +V PowerShelli z korena projektu spusti: + +```powershell +php -l src/md2html.php +node --check src/toc.js +./tests/run.ps1 +``` + +Testy overuju realny vystup Parsedownu a JS spravanie pomocou modelu DOM. +Vizualne rozlozenie, skutocne scrollovanie a ovladanie v prehliadaci treba overit +manualne na desktope aj mobile. diff --git a/src/markdown.css b/src/markdown.css index 1e9fbbc..51d8842 100644 --- a/src/markdown.css +++ b/src/markdown.css @@ -9,6 +9,7 @@ --quote-bg: #f4f8fa; --code-bg: #1f2328; --code-text: #f4f6f8; + --toc-width: 19rem; } *, @@ -201,10 +202,164 @@ body { } .markdown-body :target { - scroll-margin-top: 1rem; + scroll-margin-top: 4.5rem; animation: mark-target 1.2s ease; } +/* Navigation lives outside the rendered Markdown. */ +.md2html-document { + transition: margin-left 200ms ease; +} + +.markdown-body :is(h1, h2, h3) { + scroll-margin-top: 4.5rem; +} + +html:has(.md2html-toc-ready) { + scroll-behavior: smooth; +} + +.md2html-toc-toggle, +.md2html-toc-close { + display: inline-grid; + place-items: center; + width: 2.75rem; + height: 2.75rem; + padding: 0; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--accent); + background: var(--paper-bg); + font: 1.25rem/1 sans-serif; + cursor: pointer; +} + +.md2html-toc-toggle { + position: fixed; + top: 0.75rem; + left: 0.75rem; + z-index: 30; + box-shadow: 0 2px 6px #40312118; +} + +.md2html-toc-toggle[hidden], +.md2html-toc[hidden], +.md2html-toc-backdrop[hidden] { + display: none; +} + +.md2html-toc-toggle[aria-expanded="true"] { + color: var(--paper-bg); + background: var(--accent); +} + +.md2html-toc :is(a, button):focus-visible, +.md2html-toc-toggle:focus-visible { + outline: 3px solid var(--accent); + outline-offset: 2px; +} + +.md2html-toc { + position: fixed; + z-index: 20; + top: 4rem; + bottom: 1rem; + left: 0.75rem; + width: var(--toc-width); + max-width: calc(100% - 4.5rem); + max-height: calc(100vh - 5rem); + max-height: calc(100dvh - 5rem); + overflow-y: auto; + overscroll-behavior: contain; + padding: 1rem; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--paper-bg); + box-shadow: 0 8px 24px #40312120; + font: 0.95rem/1.5 "Segoe UI", sans-serif; + overflow-wrap: anywhere; + visibility: hidden; + transform: translateX(calc(-100% - 1rem)); + transition: transform 200ms ease, visibility 200ms; +} + +.md2html-toc-open .md2html-toc { + visibility: visible; + transform: translateX(0); +} + +.md2html-toc-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.md2html-toc-close { + flex-shrink: 0; +} + +.md2html-toc-list { + list-style: none; + margin: 0; + padding: 0; +} + +.md2html-toc-list a { + display: block; + padding: 0.4rem 0.25rem; + color: var(--accent); + text-decoration: none; + border-radius: 4px; +} + +.md2html-toc-list a:hover { + background: var(--accent-soft); + text-decoration: underline; +} + +.md2html-toc-level-2 { padding-left: 1rem; } +.md2html-toc-level-3 { padding-left: 2rem; } + +.md2html-toc-backdrop { display: none; } + +@media (min-width: 761px) { + .md2html-toc-open .md2html-document { + margin-left: calc(var(--toc-width) + 1.5rem); + } +} + +@media (max-width: 760px) { + .md2html-toc-ready .markdown-body { + margin-top: 4.25rem; + } + + .md2html-toc-backdrop { + display: block; + position: fixed; + inset: 0; + z-index: 10; + background: #24201c55; + visibility: hidden; + opacity: 0; + transition: opacity 200ms ease, visibility 200ms; + } + + .md2html-toc-open .md2html-toc-backdrop { + visibility: visible; + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + html:has(.md2html-toc-ready) { scroll-behavior: auto; } + .md2html-document, + .md2html-toc, + .md2html-toc-backdrop { transition: none; } + .markdown-body :target { animation: none; } +} + @keyframes mark-target { 0% { background: #fff3c9; diff --git a/src/md2html.php b/src/md2html.php index 614abf4..c951e3b 100644 --- a/src/md2html.php +++ b/src/md2html.php @@ -14,7 +14,17 @@ $parser = new Parsedown(); echo ''; echo ''; -echo ''; +echo ''; +echo ''; +echo ''; +echo ''; +echo ''; +echo ''; +echo '
'; echo '
'; echo $parser->text($md); echo '
'; +echo '
'; diff --git a/src/toc.js b/src/toc.js new file mode 100644 index 0000000..eb27e90 --- /dev/null +++ b/src/toc.js @@ -0,0 +1,94 @@ +(() => { + 'use strict'; + + const content = document.querySelector('.markdown-body'); + const panel = document.querySelector('.md2html-toc'); + const toggle = document.querySelector('.md2html-toc-toggle'); + const close = document.querySelector('.md2html-toc-close'); + const backdrop = document.querySelector('.md2html-toc-backdrop'); + const list = document.querySelector('.md2html-toc-list'); + if (!content || !panel || !toggle || !close || !backdrop || !list) return; + + // Reserve all existing anchors, including anchors outside headings. + const usedIds = new Set(Array.from(document.querySelectorAll('[id]'), node => node.id)); + function uniqueId(base) { + let id = base; + let suffix = 2; + while (usedIds.has(id)) id = `${base}-${suffix++}`; + usedIds.add(id); + return id; + } + + panel.id = uniqueId('md2html-toc-panel'); + toggle.setAttribute('aria-controls', panel.id); + const headings = content.querySelectorAll('h1, h2, h3'); + const seenHeadingIds = new Set(); + for (const heading of headings) { + const label = heading.textContent.trim() || 'Bez názvu'; + if (!heading.id || seenHeadingIds.has(heading.id) || document.getElementById(heading.id) !== heading) { + const slug = label.normalize('NFC').toLowerCase() + .replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/^-+|-+$/g, '') || 'nadpis'; + heading.id = uniqueId(slug); + } + seenHeadingIds.add(heading.id); + + const item = document.createElement('li'); + item.className = `md2html-toc-level-${heading.tagName.slice(1)}`; + const link = document.createElement('a'); + link.setAttribute('href', `#${encodeURIComponent(heading.id)}`); + link.textContent = label; + link.addEventListener('click', () => { + if (window.matchMedia('(max-width: 760px)').matches) setOpen(false); + if (!heading.hasAttribute('tabindex')) heading.setAttribute('tabindex', '-1'); + heading.focus({ preventScroll: true }); + // Native anchor navigation updates the fragment and supports Back/Forward. + }); + item.append(link); + list.append(item); + } + if (!headings.length) { + const message = document.createElement('p'); + message.textContent = 'Obsah dokumentu nie je dostupný.'; + list.replaceWith(message); + } + + const storageKey = `md2html:toc:${window.location.pathname}${window.location.search}`; + let open = false; + function setOpen(value, persist = true) { + open = value; + document.body.classList.toggle('md2html-toc-open', open); + toggle.setAttribute('aria-expanded', String(open)); + toggle.setAttribute('aria-label', open ? 'Zatvoriť obsah dokumentu' : 'Zobraziť obsah dokumentu'); + panel.setAttribute('aria-hidden', String(!open)); + panel.inert = !open; + if (!open && panel.contains(document.activeElement)) toggle.focus(); + if (persist) { + try { window.localStorage.setItem(storageKey, String(open)); } catch { /* Storage is optional. */ } + } + } + + let savedOpen = false; + try { savedOpen = window.localStorage.getItem(storageKey) === 'true'; } catch { /* Default to closed. */ } + setOpen(savedOpen, false); + panel.hidden = false; + toggle.hidden = false; + backdrop.hidden = false; + document.body.classList.add('md2html-toc-ready'); + toggle.addEventListener('click', () => setOpen(!open)); + close.addEventListener('click', () => { setOpen(false); toggle.focus(); }); + backdrop.addEventListener('click', () => setOpen(false)); + document.addEventListener('keydown', event => { + if (event.key === 'Escape' && open) { + setOpen(false); + toggle.focus(); + } + }); + + // The browser may have tried to resolve the fragment before generated IDs existed. + if (window.location.hash) { + try { + const target = document.getElementById(decodeURIComponent(window.location.hash.slice(1))); + if (target && content.contains(target)) target.scrollIntoView(); + } catch { /* Ignore malformed URL fragments. */ } + } +})();