pridaná generovaná navigácia z H1–H3 s unikátnymi ID,

pridaný responzívny panel a ukladanie stavu podľa URL,
pridané verzovanie CSS a JS pomocou MD5 hashov,
aktualizovaná dokumentácia
This commit is contained in:
2026-10-26 19:08:34 +01:00
parent 7b39f26c99
commit 715a0e354b
5 changed files with 325 additions and 15 deletions
+35 -13
View File
@@ -3,25 +3,35 @@
## Purpose ## Purpose
This project is a small Markdown-to-HTML utility used behind NGINX. 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`. 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 ## Key Files
- `src/md2html.php`: entrypoint for conversion and HTML output. - `src/md2html.php`: entrypoint for conversion and HTML output.
- `src/markdown.css`: visual styling for rendered Markdown (`.markdown-body`). - `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`). - `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 ## Environment Assumptions
- Project root: `D:\www\md2html` - Project root: `D:\www\md2html`
- Markdown files are resolved from `D:\www\...` - 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=<MD5 of file contents>`.
- NGINX forwards Markdown requests to `$document_root/md2html/src/md2html.php`; see README.md.
## Change Priorities ## Change Priorities
1. Keep output readable on desktop and mobile. 1. Keep output readable on desktop and mobile.
2. Preserve Markdown semantics generated by Parsedown. 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. 4. Keep the utility simple and fast.
## Styling Rules (`src/markdown.css`) ## Styling Rules (`src/markdown.css`)
- Style only inside `.markdown-body` unless there is a strong reason to change global page behavior. - 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. - Must cover: headings, links, lists, blockquotes, inline code, code blocks, tables, images, hr.
- Keep strong contrast and accessible font sizes. - Keep strong contrast and accessible font sizes.
- Ensure long code lines and wide tables remain usable on small screens (`overflow-x`). - 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`) ## PHP Rules (`src/md2html.php`)
- Keep dependency loading via the project root's `vendor/autoload.php`, resolved relative to the entrypoint directory. - Keep dependency loading via the project root's `vendor/autoload.php`, resolved relative to the entrypoint directory.
- Keep response minimal: `<!doctype html>`, UTF-8 meta, stylesheet link, wrapped content. - Keep response minimal: `<!doctype html>`, 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. - Do not remove `.md` extension guard.
- Any path-handling changes must remain compatible with Windows paths. - Any path-handling changes must remain compatible with Windows paths.
## Recommended Validation After Edits ## Table of Contents Rules (`src/toc.js`)
1. Open at least one Markdown file with: - Build navigation from rendered H1–H3 in `.markdown-body`, preserving order and indicating levels with indentation. Do not add a Markdown parser.
- headings (`#`, `##`, `###`) - Keep existing unique IDs, generate collision-free IDs for other headings, and retain native anchor navigation.
- lists - Use DOM creation and `textContent` for heading labels; do not insert untrusted content through `innerHTML`.
- fenced code block - Default to closed. Store panel state under `md2html:toc:` + pathname + query string, excluding the fragment.
- table - Handle unavailable localStorage without disabling navigation.
- blockquote - Maintain ARIA state, keyboard access, Escape dismissal, mobile close controls, and readable output without JavaScript.
- image
2. Verify layout on desktop and mobile width. ## Validation After Edits
3. Verify links and code blocks remain readable. - 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 ## Non-Goals
- Building a full CMS. - Building a full CMS.
+29
View File
@@ -6,6 +6,7 @@ Zdrojove subory su v adresari `src/`:
- `src/md2html.php`: vstupny bod konverzie Markdown do HTML. - `src/md2html.php`: vstupny bod konverzie Markdown do HTML.
- `src/markdown.css`: styly vystupneho 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/`. Zavislosti sa instaluju cez `composer install` v koreni projektu do `vendor/`.
Konfiguracia NGINX musi smerovat Markdown poziadavky na `src/md2html.php`. 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. Subor `fastcgi_params` musi byt dostupny v konfiguracii NGINX.
Po uprave over konfiguraciu prikazom `nginx -t` a nacitaj ju prikazom `nginx -s reload`. 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.
+156 -1
View File
@@ -9,6 +9,7 @@
--quote-bg: #f4f8fa; --quote-bg: #f4f8fa;
--code-bg: #1f2328; --code-bg: #1f2328;
--code-text: #f4f6f8; --code-text: #f4f6f8;
--toc-width: 19rem;
} }
*, *,
@@ -201,10 +202,164 @@ body {
} }
.markdown-body :target { .markdown-body :target {
scroll-margin-top: 1rem; scroll-margin-top: 4.5rem;
animation: mark-target 1.2s ease; 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 { @keyframes mark-target {
0% { 0% {
background: #fff3c9; background: #fff3c9;
+11 -1
View File
@@ -14,7 +14,17 @@ $parser = new Parsedown();
echo '<!doctype html>'; echo '<!doctype html>';
echo '<meta charset="utf-8">'; echo '<meta charset="utf-8">';
echo '<link rel="stylesheet" href="//localhost/md2html/src/markdown.css">'; echo '<meta name="viewport" content="width=device-width, initial-scale=1">';
echo '<link rel="stylesheet" href="//localhost/md2html/src/markdown.css?h=' . md5_file(__DIR__ . '/markdown.css') . '">';
echo '<script src="//localhost/md2html/src/toc.js?h=' . md5_file(__DIR__ . '/toc.js') . '" defer></script>';
echo '<button class="md2html-toc-toggle" type="button" aria-expanded="false" aria-label="Zobraziť obsah dokumentu" hidden><span aria-hidden="true">☰</span></button>';
echo '<div class="md2html-toc-backdrop" aria-hidden="true" hidden></div>';
echo '<nav class="md2html-toc" aria-label="Obsah dokumentu" hidden>';
echo '<div class="md2html-toc-header"><strong>Obsah dokumentu</strong><button class="md2html-toc-close" type="button" aria-label="Zatvoriť obsah dokumentu">×</button></div>';
echo '<ol class="md2html-toc-list"></ol>';
echo '</nav>';
echo '<div class="md2html-document">';
echo '<div class="markdown-body">'; echo '<div class="markdown-body">';
echo $parser->text($md); echo $parser->text($md);
echo '</div>'; echo '</div>';
echo '</div>';
+94
View File
@@ -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. */ }
}
})();