Skill: markdown-web-publishing
- typ: interní publikace agentem vytvořeného skillu
- vytvořil:
agent - created_at:
2026-05-12T12:18:02.805096+00:00 - patch_count:
44 - dostupné profily:
default - zdroj:
~/.hermes/skills/devops/markdown-web-publishing/SKILL.md
Markdown Web Publishing
When to Use
Use this skill when the user wants Markdown (.md) files to appear as styled web pages rather than downloads/plain text, or wants to publish/update the Hermes WWW markdown site.
Typical triggers: - "publish a markdown" - "why does .md download from Synology/Web Station?" - "make my markdown site look like yours" - "update/regenerate the Hermes WWW index" - "run the markdown renderer behind reverse proxy" - "flip this Hermes WWW page between internal and public" - "find the other result / fix the broken Hermes WWW link without changing exposure"
This is the umbrella skill for Hermes WWW publishing. Narrow publication-control guidance belongs here as a subsection/reference, not as separate micro-skills.
Core Model
A static web server alone will not render Markdown as styled HTML. It will usually download .md files or show raw text.
The working pattern is:
browser → reverse proxy → Flask renderer → markdown files under web root
The renderer:
1. serves index.html as the home page,
2. intercepts .md requests,
3. converts Markdown to HTML,
4. wraps it in the shared CSS/theme template,
5. autolinks bare http:// / https:// text in the rendered frontend so URLs remain clickable even when the markdown source used plain text instead of [label](url) syntax,
6. serves only explicitly allowed public file types. For Zico's public Hermes WWW this is currently restricted to Markdown (.md) and HTML (.html); internal files such as published_files.json, backups, scripts, keys, configs, and arbitrary attachments must return 404 even if the path is guessed.
Hermes WWW Paths
Azure VM production setup currently uses:
/home/vmubuntu/www/ # public content root
/home/vmubuntu/www/published_files.json # manifest/source of truth for index cards
/home/vmubuntu/www-venv/app.py # Flask markdown renderer
/home/vmubuntu/www-venv/update_manifest.py# recursive scanner -> JSON manifest
/home/vmubuntu/www-venv/generate_index.py # manifest -> index.html
Zico's Synology setup uses a case-sensitive root:
/volume1/docker/HermesWWW/www
/volume1/docker/HermesWWW/www-venv
See references/zico-synology-hermeswww.md for Synology-specific notes, references/manifest-driven-index.md for the JSON-manifest publishing workflow, references/zico-azure-caddy-domain.md for the Azure custom-domain/no-port HTTPS setup with Caddy + Let's Encrypt, and references/rich-html-research-reports.md for self-contained HTML reports with inline SVG charts, data-source notes, and public-route verification fallbacks.
Publishing a New Markdown/HTML File
Hermes WWW is now manifest-driven. The source of truth is published_files.json, not cards embedded in index.html. See references/manifest-driven-index.md for the exact schema and migration notes.
For rich research deliverables, a standalone .html page is often better than Markdown: use inline CSS, inline SVG charts, semantic headings, tables, and source links. Generate data-heavy HTML from a script outside the public root, then write only the final .html file under /home/vmubuntu/www/. See references/rich-html-research-reports.md for the pattern and verification checklist.
Listing Unpublished Browsable Pages
When Zico asks for unpublished articles/pages, list only human-browsable Markdown/HTML entries — ignore technical files, assets, generated root index.html, source files, images, SVGs, etc.
Use the script at ~/.hermes/scripts/list_unpublished_www_pages.py (also saved under this skill as scripts/list_unpublished_www_pages.py):
/home/vmubuntu/.hermes/scripts/list_unpublished_www_pages.py
# JSON output:
/home/vmubuntu/.hermes/scripts/list_unpublished_www_pages.py --json
The script filters publish: false + kind in {MD, HTML} and excludes the system index.html. It prints Discord-friendly bullet lists with <https://...> autolinks — NOT Markdown tables, which are unreadable in Discord.
IMPORTANT — Discord output format for any list of pages/URLs:
- Never output wide Markdown tables (| col | col |) for results sent to Zico via Discord — they render as unreadable plain text.
- Use numbered bullet lists with <https://...> autolinks on their own line.
- Example correct format:
1. **Název stránky**
- typ: `HTML`
- soubor: `path/to/file.html`
- odkaz: <https://hermes.suchacek.com/path/to/>
Hiding a Page Without Deleting
To hide a published page from the index without deleting the file:
- Open
/home/vmubuntu/www/published_files.json - Find the entry by
filepathand set"publish": false - Run
generate_index.py(no need to runupdate_manifest.py— that would reset the flag) - Verify the homepage no longer contains the href
import json, pathlib
p = pathlib.Path('/home/vmubuntu/www/published_files.json')
data = json.loads(p.read_text())
for item in data['files']:
if item.get('filepath') == 'target/path/index.html':
item['publish'] = False
p.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n')
Then regenerate index only:
cd /home/vmubuntu/www-venv && ./bin/python generate_index.py
Do NOT run update_manifest.py after manually setting publish: false — it will reset all flags from the filesystem scan and overwrite manual changes.
- Create the Markdown/HTML file under the web root.
Example:
mkdir -p /home/vmubuntu/www/ai
cat > /home/vmubuntu/www/ai/example.md <<'EOF'
# Example
Markdown content...
EOF
- Update the manifest from the filesystem.
Azure VM:
cd /home/vmubuntu/www-venv
./bin/python update_manifest.py
Synology:
cd /volume1/docker/HermesWWW/www-venv
./venv/bin/python update_manifest.py --root /volume1/docker/HermesWWW/www
- Edit
published_files.jsonmetadata for the new file if the auto-generated values are not good enough:
{
"filepath": "ai/example.md",
"href": "/ai/example.md",
"kind": "MD",
"publish": true,
"internal": false,
"category": "🤖 AI / agenti / Hermes",
"title": "Example title",
"description": "Short description."
}
internal controls which generated index lists the page:
- publish: true + internal: false → visible on the main homepage /
- publish: true + internal: true → hidden from the main homepage and listed on /internal/
- direct page URLs keep working in both cases
Visibility-control workflow for existing Hermes WWW artifacts
When the task is not "publish new content" but rather find, relink, unpublish, or change visibility of an existing page, treat this as a scope-sensitive workflow:
- Locate first, change nothing. Inspect the matching
published_files.jsonentry and report currenthref,publish, andinternalstate. - Do not treat discovery as approval. If the user asked for "the other result" or a missing page, identifying the file does not authorize flipping internal/public visibility.
- Only after explicit approval may you change
internal,publish, orhrefto move a page between/...and/internal/.... - After any approved visibility change, regenerate indexes and verify canonical behavior:
- intended route returns
200 - obsolete competing route returns
404when a single canonical route is desired - If visibility was changed by mistake, rollback means restoring the original manifest fields, regenerating indexes, and verifying both routes explicitly.
For the absorbed narrow case history and exact rollback pattern, see references/hermes-www-visibility-controls.md.
- Generate
index.htmlfrom the manifest.
Azure VM:
cd /home/vmubuntu/www-venv
./bin/python generate_index.py
Synology:
cd /volume1/docker/HermesWWW/www-venv
HERMES_WWW_ROOT=/volume1/docker/HermesWWW/www ./venv/bin/python generate_index.py
- Verify the public URL before returning it as live. For Zico, always return a Discord-friendly clickable public URL visibly on its own line (full
https://...text, not only hidden behind markdown link text). If the file/page is still being built by a Kanban worker, call it a target/cílová URL instead of implying it is ready.
Pitfall: do not manually add new cards to index.html; they will be overwritten. Put durable listing metadata in published_files.json.
Manifest metadata pitfall: update_manifest.py discovers files and technical metadata, but newly added HTML/Markdown pages often get generic category/title/description values. If you already ran generate_index.py and the homepage card looks low-quality, patch only the matching published_files.json entry (category, title, description) and rerun generate_index.py. You do not need to recreate the page or rerun update_manifest.py just to improve card metadata.
For new public technical guides/articles, prefer this publication pattern:
- choose a stable topical folder/slug first (for example /azure/..., /dataverse/..., /hermes/...) so the final URL is clean and reusable,
- keep the filename URL-safe/ASCII for durability,
- then patch the manifest metadata to a human-readable Czech title and a concise description before final verification,
- verify not only the page URL but also that the generated homepage card shows the intended title/category/description.
Internal/public listing rule: the manifest also supports internal: true|false.
- publish: true + internal: false → page is listed on the main homepage /
- publish: true + internal: true → page is hidden from the main homepage and listed on /internal/
- internal pages should use href under /internal/... so the visible/public-facing URL matches the internal namespace
- if an internal page is still reachable on the old root path, prefer redirecting the root path to /internal/... so users land on the canonical internal URL
- generated index outputs themselves stay non-card entries: /index.html and /internal/index.html
Permission rule for Zico: do not flip an existing page between internal/public visibility unless he explicitly confirms that change. If he asks you to "find" a page/result and you cannot find it, stop and ask which artifact he means before changing internal, publish, or href. Treat visibility changes as scope-changing actions, not as harmless fixes.
Rollback pattern if you switched visibility by mistake: restore the manifest entry (href and internal), rerun generate_index.py, then verify both routes explicitly — old unintended public URL should return 404, intended internal URL should return 200.
Backup rule for public roots: do not create backup files inside /home/vmubuntu/www or another served web root unless they are intentionally public. The manifest scanner can publish or list them, and the Flask renderer may serve them directly by URL. If you need a rollback copy, place it outside the web root (for example /home/vmubuntu/backups/hermes-www/ or /tmp) or immediately delete it and regenerate the manifest/index. After cleanup, verify both the intended page and any accidental backup URL (curl -k -I ...backup...) so backup artifacts return 404 and the main page still returns 200.
Renderer Setup Checklist
In app.py, set the content root:
WWW_ROOT = Path("/path/to/www")
For reverse proxy deployments, prefer local HTTP bind:
if __name__ == "__main__":
app.run(host="127.0.0.1", port=8090)
Use reverse proxy for TLS rather than embedding cert paths in app.py, unless the host is intentionally running the Flask dev server directly with a self-signed cert.
Custom Domain / No-Port HTTPS with Caddy
When a public Hermes WWW site should be accessed without a port (for example https://hermes.suchacek.com instead of https://IP:8443), put a real reverse proxy on port 443 in front of the Flask renderer. Caddy is the preferred low-ops option because it obtains and renews Let's Encrypt certificates automatically.
Minimal pattern:
browser → Caddy :443 with Let's Encrypt → Flask renderer on localhost or existing private port
For a Flask renderer that is already serving HTTPS with a self-signed cert on 127.0.0.1:8443, Caddy can proxy with upstream verification disabled:
{
email admin@example.com
}
hermes.example.com {
encode zstd gzip
reverse_proxy https://127.0.0.1:8443 {
transport http {
tls_insecure_skip_verify
}
}
}
Deploy:
sudo apt-get install -y caddy
sudo cp /tmp/Caddyfile /etc/caddy/Caddyfile
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl restart caddy
Verify the exact no-port URL and certificate before reporting success:
curl -I -sS https://hermes.example.com/
echo | openssl s_client -connect hermes.example.com:443 -servername hermes.example.com 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates -ext subjectAltName
If Let's Encrypt HTTP-01 validation fails with Timeout during connect on port 80 but port 443 is reachable, Caddy may still succeed via TLS-ALPN-01. Port 80 is still needed if the user wants plain http:// to redirect to HTTPS; open it in the cloud firewall/NSG only if that behavior is required.
Long-term cleaner setup: bind Flask to 127.0.0.1:8090 without TLS and let Caddy terminate TLS. Avoid exposing the Flask development server directly when a production reverse proxy is in use.
Synology No-Container Pattern
If the user does not want containers on Synology:
- Install Python 3.11 package.
- Create a venv using Python 3.11 explicitly; do not make it the DSM system default.
- Run the Flask app through DSM Task Scheduler at boot.
- Expose it through DSM Reverse Proxy to
127.0.0.1:8090. - Do not use Web Station Portal to run this Flask app; Web Station is fine for static/PHP/CGI patterns but commonly returns
Internal Server Errorfor this standalone Flaskapp.run(...)style.
Common Failures
New file disappears from the home page after regeneration
Root cause: listing metadata was added directly to index.html instead of published_files.json.
Fix: run update_manifest.py, edit the file's manifest entry (publish, category, title, description), then run generate_index.py.
.md downloads instead of rendering
Root cause: request is going to static Web Station/static server, not the Flask renderer.
Fix: route browser traffic through reverse proxy to the renderer port.
Manual python app.py works, but LAN URL fails
If bound to 127.0.0.1, it is only reachable locally. For a direct LAN test, temporarily bind 0.0.0.0, or test locally with:
curl http://127.0.0.1:8090
For final reverse proxy setup, 127.0.0.1 is preferred.
Web Station Portal returns Internal Server Error
Root cause: Web Station is not the right execution model for a standalone Flask dev server app.
Fix: Task Scheduler starts Flask; Reverse Proxy forwards to it.
ModuleNotFoundError: No module named 'zoneinfo'
Root cause: script is running under Python < 3.9.
Fix: run via Python 3.11 venv:
cd /path/to/www-venv
python3.11 -m venv venv
source venv/bin/activate
pip install flask markdown pygments
./venv/bin/python generate_index.py
Avoid changing Synology's system default Python.
Bare URLs are visible in markdown source but not clickable in the frontend
Root cause: the markdown source used plain https://... text and the renderer did not autolink bare URLs after Markdown conversion.
Fix: update the Flask renderer to post-process rendered HTML and wrap bare http/https text in anchors while preserving existing <a> links. For Zico's current Hermes WWW, /home/vmubuntu/www-venv/app.py should autolink bare URLs in normal prose and code blocks (leave existing anchors, scripts, and styles untouched).
Verification:
curl -k -sS https://127.0.0.1:8443/path/page.md | grep -n 'href="https://'
If the user says "links aren't clickable," check the rendered HTML first before rewriting all markdown files. Often the durable fix belongs in the renderer, not in every source document.
File and manifest exist, but public URL hangs or returns no bytes
Treat this as a service-readiness issue, not as missing content. Do not recreate the page first.
- Confirm the file exists under the web root and has a
publish: truemanifest entry. - Check the user service and listener:
systemctl --user is-active hermes-www.service
ss -ltnp | grep -E '(:443|:8443|:8090)'
- Restart only the renderer if the HTTPS/public request hangs while the file is present:
systemctl --user restart hermes-www.service
- Verify both renderer and public Caddy route:
printf 'GET /path/page.html HTTP/1.1\r\nHost: hermes.suchacek.com\r\nConnection: close\r\n\r\n' \
| timeout 10 openssl s_client -quiet -connect 127.0.0.1:8443 -servername hermes.suchacek.com
curl --resolve hermes.suchacek.com:443:127.0.0.1 -I --max-time 10 https://hermes.suchacek.com/path/page.html
curl -I --max-time 10 https://hermes.suchacek.com/path/page.html
Only publish/regenerate content after these checks prove the page is genuinely absent or unpublished.
Verification
Before reporting a Hermes WWW URL as ready, verify the exact public route, not just local files. A planned slug that has not been created yet will return Werkzeug/Flask 404 NOT FOUND, which must be treated as not published.
When the user asks to check whether a page "still exists" or says to publish only if missing, do the existence checks first and do not regenerate/recreate/publish unless the checks fail. Check, in order: the expected file under /home/vmubuntu/www, the matching published_files.json entry with publish: true, the card/title in generated index.html, and the exact public URL status.
When publishing a bundle of related artifacts (for example: one HTML report plus raw .md templates/examples), verify each public URL individually instead of assuming the parent page is sufficient.
On manifest-driven HermesWWW sites, also verify the metadata in published_files.json for the new entries: title, category, description, and publish: true. If files were created programmatically and the generated metadata is too generic, patch the manifest entries and regenerate index.html again. This matters especially for research reports, template packs, and multi-file deliverables where filename-derived titles are often not good enough for homepage cards.
- Local renderer responds:
curl -I http://127.0.0.1:8090/
update_manifest.pycompletes and writespublished_files.json.python -m json.tool /path/to/www/published_files.jsonvalidates the manifest.generate_index.pycompletes and prints the number of indexed items from the manifest.- Security allowlist works: internal files are not publicly fetchable (for Zico,
/published_files.jsonmust return 404), while.mdand.htmlroutes still return 200. If Caddy also proxies another app on the same domain (for example the Hermes dashboard under/kanban/), verify the proxy rules do not accidentally bypass the WWW renderer allowlist or expose manifest/config paths. - For clickable-link complaints, verify rendered anchors on the exact page (
curl ... | grep 'href="https://') and, when needed, do a whole-site markdown audit by rendering every*.mdfile through the currentapp.pyto spot remaining non-clickable URL patterns or malformed placeholders before blaming individual articles. - Markdown page renders via Flask URL/reverse proxy and does not download.
- HTML directory pages resolve at the expected slash URL. For Zico's current Azure setup, prefer the clean Caddy domain and verify without
-k:
curl -I -sS https://hermes.suchacek.com/<slug>/
If using the old self-signed fallback route, curl -k -I https://132.164.99.216:8443/<slug>/ is acceptable for diagnostics only.
- New card appears in category and date/modified tabs.
- Public manifest URL is reachable if intended, e.g.
/published_files.json.
User-Specific Behavior for Zico
- Respond in Czech when Zico writes Czech.
- Keep instructions concise and practical.
- In Discord/gateway replies, return URLs as direct Discord autolinks:
<https://...>. Do not use label-only Markdown links or code formatting for final deliverable URLs; Zico specifically complained those were not clickable in his client. - For Synology, prefer no-container guidance unless he explicitly asks for Docker/container.
- For Hermes WWW publication tasks, maintain
published_files.jsonas the durable inventory and generateindex.htmlfrom it; do not treatindex.htmlas the source of truth.