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:
@@ -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=<MD5 of file contents>`.
|
||||
- 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: `<!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.
|
||||
- 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+156
-1
@@ -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;
|
||||
|
||||
+11
-1
@@ -14,7 +14,17 @@ $parser = new Parsedown();
|
||||
|
||||
echo '<!doctype html>';
|
||||
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 $parser->text($md);
|
||||
echo '</div>';
|
||||
echo '</div>';
|
||||
|
||||
+94
@@ -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. */ }
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user