Compare commits
14
Commits
7fc33cbe12
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad59009107 | ||
|
|
9248ae62c7 | ||
|
|
d0e4e8ab9e | ||
|
|
b5631986e6 | ||
|
|
e57b0bc784 | ||
|
|
5394f7e9bf | ||
|
|
384281c747 | ||
|
|
5e0f8d87a8 | ||
|
|
7be4656106 | ||
|
|
4bb8134b9c | ||
|
|
bb9bdf817f | ||
|
|
59e22c3113 | ||
|
|
d88a987d94 | ||
|
|
677a8dde4f |
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Logs (skript-generated)
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Changelog — Lukas' Additions
|
||||||
|
|
||||||
|
All notable changes to this fork are documented here. Upstream-tracking via [DanielCheer/obsidian-web-viewer](https://github.com/DanielCheer/obsidian-web-viewer).
|
||||||
|
|
||||||
|
## v0.2.0 — 2026-07-16 — Lukas' first additions
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`vault-custom.js`** — additive JavaScript layer with three features:
|
||||||
|
- **Current-page highlighting in 3D Graph**: node representing the open file becomes gold with glow; connected nodes (via WikiLinks) become light-blue; unrelated nodes dim to 20% opacity
|
||||||
|
- **Click-to-navigate on 3D Graph**: Three.js raycaster triggers `loadFile()` on node click; URL updates with `?file=<path>` query param
|
||||||
|
- **Responsive layout**: at viewport <1024px the 3D graph collapses to a 200px bottom panel; at <768px the tree collapses to a hamburger menu and graph hides entirely
|
||||||
|
- **One-line patch in `vault.html`**: `<script src="vault-custom.js"></script>` before `</body>` (single source of upstream-merge friction)
|
||||||
|
- **7-line patch in `server.py`**: `?file=<path>` query parameter support on `/api/vault/file/` for clean URL navigation
|
||||||
|
- **`docs/ARCHITECTURE.md`**: design rationale (why additive layer, why HermesCustom namespace, what tradeoffs we accepted)
|
||||||
|
- **`docs/CUSTOMIZATIONS.md`**: feature spec with code pointers
|
||||||
|
- **`README.md`**: Lukas-authored intro, Quick Start, customization guide, upstream-merge procedure
|
||||||
|
|
||||||
|
### Preserved from upstream
|
||||||
|
|
||||||
|
- Catppuccin-dark theme
|
||||||
|
- Three.js 3D graph
|
||||||
|
- File tree sidebar with collapsible folders
|
||||||
|
- WikiLink resolution with search fallback
|
||||||
|
- Full-text search
|
||||||
|
- Frontmatter-as-card rendering
|
||||||
|
- Python stdlib only (no Flask/FastAPI dependency)
|
||||||
|
|
||||||
|
### Migration notes
|
||||||
|
|
||||||
|
If you're coming from upstream obv:
|
||||||
|
1. `python3 server.py --vault <your-vault> --host 127.0.0.1 --port 8765` works as before
|
||||||
|
2. The Tailscale-URL gets `?file=` query params on navigation (deep-linkable)
|
||||||
|
3. Customizations only kick in if `vault-custom.js` is reachable from the same origin (it is, served by the same server.py)
|
||||||
|
|
||||||
|
## v0.1.0 — 2026-04 — Upstream baseline
|
||||||
|
|
||||||
|
Forked from [DanielCheer/obsidian-web-viewer @ commit `master`](https://github.com/DanielCheer/obsidian-web-viewer) on 2026-07-16. No modifications yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
Next planned additions (in priority order):
|
||||||
|
|
||||||
|
- **Backlinks panel** — for each loaded file, show incoming WikiLinks (which other files link to this one). Adds a third collapsible panel on the right or merges into graph context menu.
|
||||||
|
- **Keyboard shortcuts**:
|
||||||
|
- `j` / `k` — next / previous file in current folder
|
||||||
|
- `[` / `]` — back / forward in navigation history
|
||||||
|
- `g` — focus 3D graph
|
||||||
|
- `/` — focus search box
|
||||||
|
- **Light theme variant** — Catppuccin-Light alongside Catppuccin-Dark, toggle in header
|
||||||
|
- **Recent files section** — show last 10 visited files in tree sidebar
|
||||||
|
- **Graph filters** — toggle to show only current-folder nodes, only connected nodes, etc.
|
||||||
|
|
||||||
|
Lukas' note: each new addition should remain in `vault-custom.js`. If it can't fit there cleanly, it's a sign that the addition deserves its own script tag (loaded after `vault-custom.js`).
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 DanielCheer
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,3 +1,117 @@
|
|||||||
# hermes-wiki-viewer
|
# hermes-wiki-static
|
||||||
|
|
||||||
Customizable Obsidian-Vault viewer with 3D Graph, current-page highlighting, responsive layout. Fork of DanielCheer/obsidian-web-viewer.
|
Static HTML wiki generator for Lukas Huber's karpathy-style Obsidian-vault.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
A Python tool that watches `/home/admin/my-karpathy-wiki/` for changes and
|
||||||
|
regenerates static HTML files on disk. A simple HTTP server serves them on
|
||||||
|
loopback port 8765; Tailscale exposes it to the tailnet.
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
We previously used `DanielCheer/obsidian-web-viewer` (forked as
|
||||||
|
`hermes-wiki-viewer`). It had three blockers:
|
||||||
|
|
||||||
|
- **No touch support on iOS Safari** — click handlers don't work reliably
|
||||||
|
- **3D-Graph auto-rotates** — prevents node selection via tap
|
||||||
|
- **Fixed 260px Graph column** — wastes horizontal space
|
||||||
|
|
||||||
|
This tool replaces obv with our own renderer:
|
||||||
|
|
||||||
|
- **Touch-first design** — bottom-nav on mobile, hamburger-tree, FAB+modal graph
|
||||||
|
- **Static HTML** — each page is a real URL, deep-linkable, PWA-installable
|
||||||
|
- **No auto-rotation** — graph is static, click-to-rotate, click-to-navigate
|
||||||
|
- **Native-feeling** — service worker, pull-to-refresh, swipe-back
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
my-karpathy-wiki/*.md (input)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[watchdog observer] (auto-regen on .md change)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
generator.py (Python: markdown + frontmatter)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
~/.local/share/hermes-wiki/site/ (static HTML output)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
python3 http.server (loopback:8765)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
tailscale serve (https://openclaw.wholphin-musical.ts.net/)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `generator.py` — daemon with watchdog + HTTP server
|
||||||
|
- `requirements.txt` — markdown, pyyaml, watchdog, python-frontmatter
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install deps (one-time, system Python)
|
||||||
|
pip install --break-system-packages markdown pyyaml watchdog python-frontmatter
|
||||||
|
|
||||||
|
# Start daemon
|
||||||
|
python3 generator.py --start
|
||||||
|
|
||||||
|
# Status / Stop
|
||||||
|
python3 generator.py --status
|
||||||
|
python3 generator.py --stop
|
||||||
|
|
||||||
|
# One-shot regen (no watcher, no HTTP server)
|
||||||
|
python3 generator.py --once
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output structure
|
||||||
|
|
||||||
|
```
|
||||||
|
site/
|
||||||
|
├── index.html (redirect to first note)
|
||||||
|
├── concepts/<slug>.html (one file per .md)
|
||||||
|
├── entities/<slug>.html
|
||||||
|
├── ...
|
||||||
|
└── __/
|
||||||
|
├── style.css
|
||||||
|
├── app.js
|
||||||
|
├── data.js (all metadata inlined for offline)
|
||||||
|
├── tree.json
|
||||||
|
├── graph.json
|
||||||
|
├── tags.json
|
||||||
|
├── backlinks.json
|
||||||
|
└── manifest.json (PWA)
|
||||||
|
```
|
||||||
|
|
||||||
|
## WikiLink syntax
|
||||||
|
|
||||||
|
`[[entity-name]]` or `[[entity-name|display text]]` resolves to a real
|
||||||
|
`<a class="wikilink" href="/path/to/entity.html">` if the target exists,
|
||||||
|
otherwise `<a class="wikilink-missing">` (greyed out).
|
||||||
|
|
||||||
|
## Frontmatter
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: My Note
|
||||||
|
type: concept
|
||||||
|
status: stable
|
||||||
|
updated: 2026-07-15
|
||||||
|
sources:
|
||||||
|
- https://example.com
|
||||||
|
tags:
|
||||||
|
- hermes
|
||||||
|
- architecture
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known limitations (Phase 1)
|
||||||
|
|
||||||
|
- CSS is placeholder — touch design comes in Phase 2
|
||||||
|
- JS is placeholder — search/tree/graph render comes in Phase 2
|
||||||
|
- Bottom-Nav template is there but not styled
|
||||||
|
- 5 YAML files in the wiki have malformed frontmatter (parser warnings)
|
||||||
|
- Graph only shows 27 edges — some WikiLinks not resolving due to those YAML issues
|
||||||
|
|||||||
@@ -0,0 +1,754 @@
|
|||||||
|
/* Hermes Wiki — Touch-First JS for Phase 2
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Tree render with collapsible folders
|
||||||
|
* - Search with live results dropdown (title + tags)
|
||||||
|
* - 3D Graph in modal (Three.js, NO auto-rotation, click-to-navigate)
|
||||||
|
* - Mobile bottom-nav (Files, Search, Graph)
|
||||||
|
* - Hamburger tree + FAB graph on mobile
|
||||||
|
* - Backdrop click closes overlays
|
||||||
|
* - Backlinks rendering
|
||||||
|
* - Hash-based navigation (forward/back via pushState)
|
||||||
|
*/
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const data = window.HERMES_DATA;
|
||||||
|
if (!data) {
|
||||||
|
console.error('[Hermes] HERMES_DATA missing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSlug = window.location.pathname
|
||||||
|
.replace(/^\//, '')
|
||||||
|
.replace(/\.html$/, '');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Tree render
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function renderTree(node, container, basePath) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'tree-root';
|
||||||
|
|
||||||
|
if (node.folders && node.folders.length > 0) {
|
||||||
|
for (const folder of node.folders) {
|
||||||
|
const fEl = document.createElement('div');
|
||||||
|
fEl.className = 'tree-folder';
|
||||||
|
if (folder.name === 'concepts' || folder.name === 'entities') {
|
||||||
|
fEl.classList.add('open'); // auto-open most-used folders
|
||||||
|
}
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'tree-folder-header';
|
||||||
|
header.innerHTML = `<span class="arrow">▶</span><span>${escapeHtml(folder.name)}</span>`;
|
||||||
|
header.onclick = () => fEl.classList.toggle('open');
|
||||||
|
fEl.appendChild(header);
|
||||||
|
const children = document.createElement('div');
|
||||||
|
children.className = 'tree-children';
|
||||||
|
fEl.appendChild(children);
|
||||||
|
renderTree(folder, children, basePath + '/' + folder.name);
|
||||||
|
wrap.appendChild(fEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.files && node.files.length > 0) {
|
||||||
|
for (const file of node.files) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = '/' + file.slug + '.html';
|
||||||
|
a.className = 'tree-file';
|
||||||
|
if (file.slug === currentSlug) a.classList.add('current');
|
||||||
|
a.innerHTML = `<span class="tree-file-icon">📄</span><span>${escapeHtml(file.title)}</span>`;
|
||||||
|
wrap.appendChild(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Search
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function setupSearch() {
|
||||||
|
const input = document.getElementById('search');
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
// Wrap with relative parent for absolute dropdown
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'search-wrap';
|
||||||
|
input.parentNode.insertBefore(wrapper, input);
|
||||||
|
wrapper.appendChild(input);
|
||||||
|
input.style.paddingLeft = '36px';
|
||||||
|
|
||||||
|
const dropdown = document.createElement('div');
|
||||||
|
dropdown.className = 'search-results';
|
||||||
|
wrapper.appendChild(dropdown);
|
||||||
|
|
||||||
|
let activeIdx = -1;
|
||||||
|
|
||||||
|
input.addEventListener('input', (e) => {
|
||||||
|
const q = e.target.value.toLowerCase().trim();
|
||||||
|
if (!q) {
|
||||||
|
dropdown.classList.remove('open');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const results = data.pages.filter(p =>
|
||||||
|
(p.title || '').toLowerCase().includes(q) ||
|
||||||
|
(p.slug || '').toLowerCase().includes(q) ||
|
||||||
|
(p.tags || []).some(t => t.toLowerCase().includes(q))
|
||||||
|
).slice(0, 20);
|
||||||
|
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
if (results.length === 0) {
|
||||||
|
dropdown.innerHTML = '<div class="search-empty">Keine Treffer</div>';
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < results.length; i++) {
|
||||||
|
const r = results[i];
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = '/' + r.slug + '.html';
|
||||||
|
a.className = 'search-result' + (i === activeIdx ? ' active' : '');
|
||||||
|
const tagsHtml = (r.tags || []).slice(0, 3).map(t => `<span class="search-result-tag">${escapeHtml(t)}</span>`).join('');
|
||||||
|
a.innerHTML = `<span class="search-result-title">${escapeHtml(r.title)}</span><span class="search-result-path">${tagsHtml}${escapeHtml(r.slug)}</span>`;
|
||||||
|
a.onmouseenter = () => {
|
||||||
|
activeIdx = i;
|
||||||
|
updateActive();
|
||||||
|
};
|
||||||
|
dropdown.appendChild(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dropdown.classList.add('open');
|
||||||
|
|
||||||
|
function updateActive() {
|
||||||
|
[...dropdown.querySelectorAll('.search-result')].forEach((el, idx) => {
|
||||||
|
el.classList.toggle('active', idx === activeIdx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
const items = [...dropdown.querySelectorAll('.search-result')];
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
activeIdx = Math.min(activeIdx + 1, items.length - 1);
|
||||||
|
[...dropdown.querySelectorAll('.search-result')].forEach((el, idx) =>
|
||||||
|
el.classList.toggle('active', idx === activeIdx)
|
||||||
|
);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
activeIdx = Math.max(activeIdx - 1, 0);
|
||||||
|
[...dropdown.querySelectorAll('.search-result')].forEach((el, idx) =>
|
||||||
|
el.classList.toggle('active', idx === activeIdx)
|
||||||
|
);
|
||||||
|
} else if (e.key === 'Enter' && activeIdx >= 0 && items[activeIdx]) {
|
||||||
|
e.preventDefault();
|
||||||
|
items[activeIdx].click();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
input.value = '';
|
||||||
|
dropdown.classList.remove('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!wrapper.contains(e.target)) {
|
||||||
|
dropdown.classList.remove('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Graph (Three.js) — modal, NO auto-rotation, click-to-navigate
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
let graphState = null;
|
||||||
|
|
||||||
|
function buildGraph(canvas, container) {
|
||||||
|
if (!window.THREE) {
|
||||||
|
console.error('[Hermes] THREE.js not loaded');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctx = {
|
||||||
|
scene: new THREE.Scene(),
|
||||||
|
camera: null,
|
||||||
|
renderer: new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }),
|
||||||
|
nodes: new Map(),
|
||||||
|
edges: [],
|
||||||
|
currentSlug: currentSlug,
|
||||||
|
isDragging: false,
|
||||||
|
dragStart: { x: 0, y: 0 },
|
||||||
|
cameraPos: { x: 0, y: 0, z: 1200 },
|
||||||
|
cameraTarget: { x: 0, y: 0, z: 0 },
|
||||||
|
needsRender: true,
|
||||||
|
hoveredNode: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const w = canvas.clientWidth || canvas.width || 600;
|
||||||
|
const h = canvas.clientHeight || canvas.height || 400;
|
||||||
|
|
||||||
|
ctx.renderer.setSize(w, h, false);
|
||||||
|
ctx.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
ctx.renderer.setClearColor(0x0e0e1a, 1);
|
||||||
|
|
||||||
|
ctx.camera = new THREE.PerspectiveCamera(60, w / h, 1, 10000);
|
||||||
|
ctx.camera.position.set(0, 0, 1200);
|
||||||
|
ctx.camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
|
// Build nodes via deterministic layout (Fruchterman-Reingold style)
|
||||||
|
const graph = data.graph;
|
||||||
|
const slugs = graph.nodes.map(n => n.id);
|
||||||
|
const positions = computeLayout(slugs, graph.edges, 600);
|
||||||
|
|
||||||
|
// Group nodes by folder for visual variety
|
||||||
|
const folderColors = {};
|
||||||
|
let colorIdx = 0;
|
||||||
|
const palette = [0x89b4fa, 0xa6e3a1, 0xf9e2af, 0xf5c2e7, 0x94e2d5, 0xfab387, 0xcba6f7];
|
||||||
|
for (const slug of slugs) {
|
||||||
|
const folder = slug.split('/')[0] || 'root';
|
||||||
|
if (!(folder in folderColors)) {
|
||||||
|
folderColors[folder] = palette[colorIdx++ % palette.length];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current page edges for highlight
|
||||||
|
const currentEdges = new Set();
|
||||||
|
const currentReverse = new Set();
|
||||||
|
if (graph.edges) {
|
||||||
|
for (const e of graph.edges) {
|
||||||
|
if (e.source === currentSlug) {
|
||||||
|
currentEdges.add(e.target);
|
||||||
|
currentReverse.add(e.source);
|
||||||
|
}
|
||||||
|
if (e.target === currentSlug) {
|
||||||
|
currentEdges.add(e.source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build meshes
|
||||||
|
const sphereGeo = new THREE.SphereGeometry(5, 12, 12);
|
||||||
|
for (const node of graph.nodes) {
|
||||||
|
const isCurrent = node.id === currentSlug;
|
||||||
|
const isConnected = currentEdges.has(node.id);
|
||||||
|
const folder = node.id.split('/')[0] || 'root';
|
||||||
|
const baseColor = folderColors[folder];
|
||||||
|
|
||||||
|
let color = baseColor;
|
||||||
|
let opacity = 0.5;
|
||||||
|
let size = 1;
|
||||||
|
if (isCurrent) {
|
||||||
|
color = 0xFFD700;
|
||||||
|
opacity = 1;
|
||||||
|
size = 2.2;
|
||||||
|
} else if (isConnected) {
|
||||||
|
color = 0xFFD700;
|
||||||
|
opacity = 0.95;
|
||||||
|
size = 1.5;
|
||||||
|
} else if (currentSlug) {
|
||||||
|
opacity = 0.25;
|
||||||
|
} else {
|
||||||
|
opacity = 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mat = new THREE.MeshBasicMaterial({
|
||||||
|
color,
|
||||||
|
transparent: true,
|
||||||
|
opacity,
|
||||||
|
depthWrite: isCurrent || isConnected,
|
||||||
|
});
|
||||||
|
const mesh = new THREE.Mesh(sphereGeo, mat);
|
||||||
|
mesh.position.set(positions[node.id].x, positions[node.id].y, positions[node.id].z);
|
||||||
|
mesh.scale.setScalar(size);
|
||||||
|
mesh.userData = { nodeId: node.id, baseScale: size, baseOpacity: opacity, isCurrent, isConnected };
|
||||||
|
ctx.scene.add(mesh);
|
||||||
|
ctx.nodes.set(node.id, mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build edges
|
||||||
|
const edgeMat = new THREE.LineBasicMaterial({
|
||||||
|
color: 0x6c7086,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.35,
|
||||||
|
});
|
||||||
|
const currentEdgeMat = new THREE.LineBasicMaterial({
|
||||||
|
color: 0xFFD700,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.85,
|
||||||
|
});
|
||||||
|
for (const e of graph.edges) {
|
||||||
|
const src = ctx.nodes.get(e.source);
|
||||||
|
const tgt = ctx.nodes.get(e.target);
|
||||||
|
if (!src || !tgt) continue;
|
||||||
|
const lineGeo = new THREE.BufferGeometry().setFromPoints([src.position, tgt.position]);
|
||||||
|
const isCurrentEdge = (e.source === currentSlug || e.target === currentSlug);
|
||||||
|
const line = new THREE.Line(lineGeo, isCurrentEdge ? currentEdgeMat : edgeMat);
|
||||||
|
ctx.scene.add(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render only on demand (NO animation loop!)
|
||||||
|
function render() {
|
||||||
|
ctx.renderer.render(ctx.scene, ctx.camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual rotation: drag to rotate (pointer + touch fallback for older iOS)
|
||||||
|
let isDragging = false;
|
||||||
|
let lastX = 0, lastY = 0;
|
||||||
|
let dragStartTime = 0;
|
||||||
|
let dragStartXY = { x: 0, y: 0 };
|
||||||
|
let moved = false;
|
||||||
|
|
||||||
|
function getXY(e, canvas) {
|
||||||
|
let clientX, clientY;
|
||||||
|
if (e.touches && e.touches.length > 0) {
|
||||||
|
clientX = e.touches[0].clientX;
|
||||||
|
clientY = e.touches[0].clientY;
|
||||||
|
} else if (e.changedTouches && e.changedTouches.length > 0) {
|
||||||
|
clientX = e.changedTouches[0].clientX;
|
||||||
|
clientY = e.changedTouches[0].clientY;
|
||||||
|
} else {
|
||||||
|
clientX = e.clientX;
|
||||||
|
clientY = e.clientY;
|
||||||
|
}
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
const y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
return { x, y, clientX, clientY };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateCamera(dx, dy) {
|
||||||
|
const rotSpeed = 0.005;
|
||||||
|
const offset = new THREE.Vector3().subVectors(ctx.camera.position, ctx.cameraTarget);
|
||||||
|
const spherical = new THREE.Spherical().setFromVector3(offset);
|
||||||
|
spherical.theta -= dx * rotSpeed;
|
||||||
|
spherical.phi -= dy * rotSpeed;
|
||||||
|
spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi));
|
||||||
|
offset.setFromSpherical(spherical);
|
||||||
|
ctx.camera.position.copy(ctx.cameraTarget).add(offset);
|
||||||
|
ctx.camera.lookAt(ctx.cameraTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDown(e) {
|
||||||
|
// Don't preventDefault here — breaks iOS-Safari pointer sequence
|
||||||
|
const p = getXY(e, canvas);
|
||||||
|
isDragging = true;
|
||||||
|
moved = false;
|
||||||
|
dragStartTime = Date.now();
|
||||||
|
dragStartXY = { x: p.clientX, y: p.clientY };
|
||||||
|
lastX = p.clientX;
|
||||||
|
lastY = p.clientY;
|
||||||
|
if (canvas.setPointerCapture && e.pointerId !== undefined) {
|
||||||
|
try { canvas.setPointerCapture(e.pointerId); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMove(e) {
|
||||||
|
if (!isDragging) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const p = getXY(e, canvas);
|
||||||
|
const dx = p.clientX - lastX;
|
||||||
|
const dy = p.clientY - lastY;
|
||||||
|
if (Math.abs(p.clientX - dragStartXY.x) + Math.abs(p.clientY - dragStartXY.y) > 8) {
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
rotateCamera(dx, dy);
|
||||||
|
lastX = p.clientX;
|
||||||
|
lastY = p.clientY;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp(e) {
|
||||||
|
if (!isDragging) return;
|
||||||
|
isDragging = false;
|
||||||
|
const elapsed = Date.now() - dragStartTime;
|
||||||
|
// Tap = quick release without much movement
|
||||||
|
if (!moved && elapsed < 500) {
|
||||||
|
const p = getXY(e.changedTouches ? { changedTouches: e.changedTouches } : e, canvas);
|
||||||
|
const raycaster = new THREE.Raycaster();
|
||||||
|
const mouse = new THREE.Vector2(p.x, p.y);
|
||||||
|
raycaster.setFromCamera(mouse, ctx.camera);
|
||||||
|
const meshes = Array.from(ctx.nodes.values());
|
||||||
|
const intersects = raycaster.intersectObjects(meshes);
|
||||||
|
if (intersects.length > 0) {
|
||||||
|
const nodeId = intersects[0].object.userData.nodeId;
|
||||||
|
if (nodeId) {
|
||||||
|
const modal = document.querySelector('.graph-modal');
|
||||||
|
if (modal) modal.classList.remove('open');
|
||||||
|
window.location.href = '/' + nodeId + '.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pointer events (modern: iOS 13+, all modern browsers)
|
||||||
|
canvas.addEventListener('pointerdown', onDown);
|
||||||
|
canvas.addEventListener('pointermove', onMove);
|
||||||
|
canvas.addEventListener('pointerup', onUp);
|
||||||
|
canvas.addEventListener('pointercancel', onUp);
|
||||||
|
canvas.addEventListener('pointerleave', onUp);
|
||||||
|
|
||||||
|
// Touch events fallback (older iOS Safari 12-)
|
||||||
|
canvas.addEventListener('touchstart', onDown, { passive: true });
|
||||||
|
canvas.addEventListener('touchmove', onMove, { passive: false });
|
||||||
|
canvas.addEventListener('touchend', onUp, { passive: false });
|
||||||
|
canvas.addEventListener('touchcancel', onUp, { passive: false });
|
||||||
|
|
||||||
|
// Mouse events fallback (older desktop browsers)
|
||||||
|
canvas.addEventListener('mousedown', onDown);
|
||||||
|
canvas.addEventListener('mousemove', onMove);
|
||||||
|
canvas.addEventListener('mouseup', onUp);
|
||||||
|
canvas.addEventListener('mouseleave', onUp);
|
||||||
|
|
||||||
|
// Zoom with wheel (desktop) + pinch (mobile)
|
||||||
|
let pinchStartDist = null;
|
||||||
|
function getPinchDist(e) {
|
||||||
|
if (e.touches && e.touches.length >= 2) {
|
||||||
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||||
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||||
|
return Math.sqrt(dx*dx + dy*dy);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheel(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const dir = e.deltaY > 0 ? 1.1 : 0.9;
|
||||||
|
ctx.camera.position.multiplyScalar(dir);
|
||||||
|
ctx.camera.lookAt(ctx.cameraTarget);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
canvas.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
|
||||||
|
function onTouchMoveForPinch(e) {
|
||||||
|
const dist = getPinchDist(e);
|
||||||
|
if (dist !== null) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (pinchStartDist === null) {
|
||||||
|
pinchStartDist = dist;
|
||||||
|
} else {
|
||||||
|
const ratio = pinchStartDist / dist;
|
||||||
|
if (Math.abs(ratio - 1) > 0.01) {
|
||||||
|
ctx.camera.position.multiplyScalar(1 / ratio);
|
||||||
|
ctx.camera.lookAt(ctx.cameraTarget);
|
||||||
|
pinchStartDist = dist;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onTouchEndForPinch(e) {
|
||||||
|
if (e.touches && e.touches.length < 2) {
|
||||||
|
pinchStartDist = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canvas.addEventListener('touchmove', onTouchMoveForPinch, { passive: false });
|
||||||
|
canvas.addEventListener('touchend', onTouchEndForPinch, { passive: false });
|
||||||
|
|
||||||
|
// Resize handling — wait for actual visible dimensions
|
||||||
|
function safeResize() {
|
||||||
|
const w2 = canvas.clientWidth || canvas.parentElement.clientWidth;
|
||||||
|
const h2 = canvas.clientHeight || canvas.parentElement.clientHeight;
|
||||||
|
if (w2 === 0 || h2 === 0) return;
|
||||||
|
ctx.camera.aspect = w2 / h2;
|
||||||
|
ctx.camera.updateProjectionMatrix();
|
||||||
|
ctx.renderer.setSize(w2, h2, false);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResize() {
|
||||||
|
safeResize();
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
|
||||||
|
// Force-initial render with retry until canvas has dimensions
|
||||||
|
let resizeRetries = 0;
|
||||||
|
function tryInitialRender() {
|
||||||
|
safeResize();
|
||||||
|
if ((canvas.clientWidth === 0 || canvas.clientHeight === 0) && resizeRetries < 10) {
|
||||||
|
resizeRetries++;
|
||||||
|
requestAnimationFrame(tryInitialRender);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tryInitialRender);
|
||||||
|
|
||||||
|
// Listen for modal-open to re-check sizing
|
||||||
|
if (container.classList.contains('graph-modal')) {
|
||||||
|
const observer = new ResizeObserver(() => safeResize());
|
||||||
|
observer.observe(canvas);
|
||||||
|
ctx.cleanupObserver = () => observer.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
ctx.render = render;
|
||||||
|
ctx.cleanup = () => {
|
||||||
|
canvas.removeEventListener('pointerdown', onDown);
|
||||||
|
canvas.removeEventListener('pointermove', onMove);
|
||||||
|
canvas.removeEventListener('pointerup', onUp);
|
||||||
|
canvas.removeEventListener('pointercancel', onUp);
|
||||||
|
canvas.removeEventListener('pointerleave', onUp);
|
||||||
|
canvas.removeEventListener('touchstart', onDown);
|
||||||
|
canvas.removeEventListener('touchmove', onMove);
|
||||||
|
canvas.removeEventListener('touchend', onUp);
|
||||||
|
canvas.removeEventListener('touchcancel', onUp);
|
||||||
|
canvas.removeEventListener('mousedown', onDown);
|
||||||
|
canvas.removeEventListener('mousemove', onMove);
|
||||||
|
canvas.removeEventListener('mouseup', onUp);
|
||||||
|
canvas.removeEventListener('mouseleave', onUp);
|
||||||
|
canvas.removeEventListener('wheel', onWheel);
|
||||||
|
canvas.removeEventListener('touchmove', onTouchMoveForPinch);
|
||||||
|
canvas.removeEventListener('touchend', onTouchEndForPinch);
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
if (ctx.cleanupObserver) ctx.cleanupObserver();
|
||||||
|
};
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeLayout(nodeIds, edges, radius) {
|
||||||
|
// Simple force-directed-ish: angle-distributed with edge-based clustering
|
||||||
|
const positions = {};
|
||||||
|
const N = nodeIds.length;
|
||||||
|
if (N === 0) return positions;
|
||||||
|
|
||||||
|
// Initialize: distribute on sphere
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const phi = Math.acos(-1 + (2 * i) / N);
|
||||||
|
const theta = Math.sqrt(N * Math.PI) * phi;
|
||||||
|
positions[nodeIds[i]] = {
|
||||||
|
x: radius * Math.cos(theta) * Math.sin(phi),
|
||||||
|
y: radius * Math.sin(theta) * Math.sin(phi),
|
||||||
|
z: radius * Math.cos(phi),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1-2 iterations of relaxation
|
||||||
|
for (let iter = 0; iter < 2; iter++) {
|
||||||
|
// Repulsion
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const a = nodeIds[i];
|
||||||
|
let fx = 0, fy = 0, fz = 0;
|
||||||
|
for (let j = 0; j < N; j++) {
|
||||||
|
if (i === j) continue;
|
||||||
|
const b = nodeIds[j];
|
||||||
|
const dx = positions[a].x - positions[b].x;
|
||||||
|
const dy = positions[a].y - positions[b].y;
|
||||||
|
const dz = positions[a].z - positions[b].z;
|
||||||
|
const d2 = dx*dx + dy*dy + dz*dz + 0.01;
|
||||||
|
const f = 50 / d2;
|
||||||
|
fx += dx * f;
|
||||||
|
fy += dy * f;
|
||||||
|
fz += dz * f;
|
||||||
|
}
|
||||||
|
positions[a].x += fx * 0.01;
|
||||||
|
positions[a].y += fy * 0.01;
|
||||||
|
positions[a].z += fz * 0.01;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Backlinks render
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function renderBacklinks() {
|
||||||
|
const container = document.getElementById('backlinks');
|
||||||
|
if (!container) return;
|
||||||
|
const links = (data.backlinks && data.backlinks[currentSlug]) || [];
|
||||||
|
if (links.length === 0) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const html = [
|
||||||
|
'<div class="note-backlinks-title">Backlinks (' + links.length + ')</div>',
|
||||||
|
'<ul>' + links.map(slug => {
|
||||||
|
const page = data.pages.find(p => p.slug === slug);
|
||||||
|
const title = page ? page.title : slug;
|
||||||
|
return `<li><a href="/${slug}.html">${escapeHtml(title)}</a></li>`;
|
||||||
|
}).join('') + '</ul>'
|
||||||
|
].join('');
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// TOC (extract from rendered headings)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function renderTOC() {
|
||||||
|
const container = document.getElementById('toc');
|
||||||
|
if (!container) return;
|
||||||
|
const headings = [...document.querySelectorAll('.note-body h2, .note-body h3')];
|
||||||
|
if (headings.length === 0) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items = headings.map(h => {
|
||||||
|
const level = h.tagName === 'H2' ? 2 : 3;
|
||||||
|
const text = h.textContent.replace(/\s*¶\s*$/, '').trim();
|
||||||
|
const id = h.id || text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||||
|
h.id = id;
|
||||||
|
return `<li style="margin-left: ${(level - 2) * 16}px"><a href="#${id}">${escapeHtml(text)}</a></li>`;
|
||||||
|
}).join('');
|
||||||
|
container.innerHTML = `<div class="note-toc-title">Inhalt</div><ul>${items}</ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Mobile bottom-nav handlers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function setupBottomNav() {
|
||||||
|
document.querySelectorAll('.app-bottom-nav button').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
if (action === 'toggle-tree') {
|
||||||
|
toggleTree();
|
||||||
|
} else if (action === 'toggle-graph') {
|
||||||
|
openGraphModal();
|
||||||
|
} else if (action === 'focus-search') {
|
||||||
|
const s = document.getElementById('search');
|
||||||
|
if (s) {
|
||||||
|
s.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
s.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTree() {
|
||||||
|
const tree = document.querySelector('.app-tree');
|
||||||
|
const backdrop = document.querySelector('.backdrop');
|
||||||
|
if (!tree) return;
|
||||||
|
const isOpen = tree.classList.contains('open');
|
||||||
|
tree.classList.toggle('open');
|
||||||
|
if (backdrop) backdrop.classList.toggle('open', !isOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGraphModal() {
|
||||||
|
const modal = document.querySelector('.graph-modal');
|
||||||
|
if (!modal) return;
|
||||||
|
modal.classList.add('open');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
// Build graph in modal canvas (once)
|
||||||
|
const canvas = modal.querySelector('canvas');
|
||||||
|
if (canvas && !canvas.dataset.built) {
|
||||||
|
canvas.dataset.built = '1';
|
||||||
|
// Wait for layout
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
graphState = buildGraph(canvas, modal);
|
||||||
|
});
|
||||||
|
} else if (canvas && graphState) {
|
||||||
|
graphState.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeGraphModal() {
|
||||||
|
const modal = document.querySelector('.graph-modal');
|
||||||
|
if (!modal) return;
|
||||||
|
modal.classList.remove('open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Sidebar/Desktop graph
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function setupDesktopGraph() {
|
||||||
|
const graphEl = document.querySelector('.app-graph');
|
||||||
|
if (!graphEl) return;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
graphEl.appendChild(canvas);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
graphState = buildGraph(canvas, graphEl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Top-bar buttons (toolbar actions)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function setupToolbar() {
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('[data-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
if (action === 'toggle-tree') {
|
||||||
|
toggleTree();
|
||||||
|
} else if (action === 'toggle-graph') {
|
||||||
|
const isMobile = window.innerWidth < 768;
|
||||||
|
if (isMobile) openGraphModal();
|
||||||
|
else if (graphState) graphState.render();
|
||||||
|
} else if (action === 'focus-search') {
|
||||||
|
const s = document.getElementById('search');
|
||||||
|
if (s) s.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Modal close handlers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function setupModalClose() {
|
||||||
|
const closeBtn = document.querySelector('.graph-modal-close');
|
||||||
|
if (closeBtn) closeBtn.onclick = closeGraphModal;
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const modal = document.querySelector('.graph-modal');
|
||||||
|
if (modal && modal.classList.contains('open')) closeGraphModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Backdrop
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function setupBackdrop() {
|
||||||
|
let backdrop = document.querySelector('.backdrop');
|
||||||
|
if (!backdrop) {
|
||||||
|
backdrop = document.createElement('div');
|
||||||
|
backdrop.className = 'backdrop';
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
}
|
||||||
|
backdrop.onclick = () => {
|
||||||
|
const tree = document.querySelector('.app-tree');
|
||||||
|
if (tree) tree.classList.remove('open');
|
||||||
|
backdrop.classList.remove('open');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Utility
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str).replace(/[&<>"']/g, (c) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||||
|
})[c]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Init
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const treeEl = document.getElementById('tree');
|
||||||
|
if (treeEl && data.tree) renderTree(data.tree, treeEl);
|
||||||
|
|
||||||
|
setupSearch();
|
||||||
|
setupToolbar();
|
||||||
|
setupModalClose();
|
||||||
|
setupBackdrop();
|
||||||
|
setupBottomNav();
|
||||||
|
setupDesktopGraph();
|
||||||
|
|
||||||
|
renderTOC();
|
||||||
|
renderBacklinks();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## Why this fork exists
|
||||||
|
|
||||||
|
[DanielCheer/obsidian-web-viewer](https://github.com/DanielCheer/obsidian-web-viewer) is a great single-file Obsidian-vault viewer (server.py + vault.html, ~10 KB + 14 KB, Python stdlib only). But for Lukas' Hermes Wiki workflow — 245 Markdown files across 18 top-level folders, frequent cross-referencing via WikiLinks, mobile reading — three specific UX gaps needed fixing:
|
||||||
|
|
||||||
|
1. **3D Graph shows all nodes uniformly** — no indication of which node the user is currently reading or which nodes are connected to it
|
||||||
|
2. **Layout breaks on small screens** — the 3-panel grid (`280px 1fr 260px`) is too narrow on 13" laptops and unusable on phones
|
||||||
|
3. **Click-to-navigate from graph** — Three.js canvas accepts clicks but doesn't trigger navigation
|
||||||
|
|
||||||
|
## Design principles
|
||||||
|
|
||||||
|
### 1. Additive layer, not fork-and-modify
|
||||||
|
|
||||||
|
We modify **exactly one line** in `vault.html` (a `<script src="vault-custom.js"></script>` tag before `</body>`). Everything else — including the customizations themselves — lives in `vault-custom.js`.
|
||||||
|
|
||||||
|
Why:
|
||||||
|
- Upstream `git pull` conflicts are trivial to resolve (the one line is recognizable)
|
||||||
|
- Customizations are clearly separated from upstream code
|
||||||
|
- Reviewing customizations is reading one file, not diffing the whole repo
|
||||||
|
- Easier to upstream-merge back if Lukas' features prove generally useful
|
||||||
|
|
||||||
|
### 2. HermesCustom namespace
|
||||||
|
|
||||||
|
All custom code lives under `window.HermesCustom`. Methods are namespaced and self-documenting:
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.HermesCustom = window.HermesCustom || {};
|
||||||
|
HermesCustom.highlightCurrentNode = function() { ... };
|
||||||
|
HermesCustom.setupGraphClickHandler = function() { ... };
|
||||||
|
HermesCustom.responsiveLayout = function() { ... };
|
||||||
|
```
|
||||||
|
|
||||||
|
This way the namespace is visible in DevTools and a future maintainer can see at a glance what's custom vs. upstream.
|
||||||
|
|
||||||
|
### 3. Server.py stays mostly upstream
|
||||||
|
|
||||||
|
We add **one** enhancement to `server.py`: a `?file=<path>` query parameter that returns the file directly (used by `HermesCustom.highlightCurrentNode` to know which file is open). This is a 5-line change that doesn't conflict with any upstream logic.
|
||||||
|
|
||||||
|
If upstream adds a similar feature, we drop our addition.
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
```
|
||||||
|
hermes-wiki-viewer/
|
||||||
|
├── server.py # obv-fork + 5 lines (file-query-param)
|
||||||
|
├── vault.html # obv-fork + 1 line (script tag for custom.js)
|
||||||
|
├── vault-custom.js # ALL Lukas-specific code
|
||||||
|
├── requirements.txt # PyYAML only (same as upstream)
|
||||||
|
├── README.md # this repo's entry point
|
||||||
|
├── LICENSE # MIT (inherited)
|
||||||
|
├── CHANGELOG.md # release notes for Lukas-additions
|
||||||
|
├── docs/
|
||||||
|
│ ├── ARCHITECTURE.md # you are here
|
||||||
|
│ └── CUSTOMIZATIONS.md # spec for each Lukas-feature
|
||||||
|
└── .gitignore # standard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why we keep `vault-custom.js` separate from `vault.html`
|
||||||
|
|
||||||
|
Embedding customizations into `vault.html` would mean:
|
||||||
|
- Every upstream update requires re-applying the entire diff
|
||||||
|
- Reviewing customizations requires diffing two HTML files
|
||||||
|
- Conflicts when upstream renames a function we override
|
||||||
|
|
||||||
|
A separate file means:
|
||||||
|
- The customizations are a stable, reviewable unit
|
||||||
|
- Upstream updates touch `vault.html` only, customizations are unchanged
|
||||||
|
- The one-line patch in `vault.html` is a clear "anchor point" that any reviewer can understand
|
||||||
|
|
||||||
|
## Why Python stdlib only (no Flask/FastAPI)
|
||||||
|
|
||||||
|
Upstream uses `http.server` from stdlib. We keep this. Adding a framework would:
|
||||||
|
- Inflate dependencies
|
||||||
|
- Require version-pinning for reproducibility
|
||||||
|
- Make the tool harder to deploy (anywhere with Python 3.8+ works)
|
||||||
|
|
||||||
|
The 5-line addition to `server.py` is plain stdlib `BaseHTTPRequestHandler` style.
|
||||||
|
|
||||||
|
## Tradeoffs we accepted
|
||||||
|
|
||||||
|
- **No build step.** Customizations are vanilla JS. No bundler, no TypeScript. Means no static type-checking, but means anyone can read and modify the code without toolchain setup.
|
||||||
|
- **No tests in CI.** We rely on manual testing for now. CI tests would add complexity that doesn't match the project's "simple tool" character. (Future: a small Playwright test for the graph highlighting.)
|
||||||
|
- **No auto-update from upstream.** When obv updates, Lukas has to `git pull upstream && git merge`. The one-line `vault.html` patch needs re-application if upstream modified it. This is acceptable for a personal side-project.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
See CHANGELOG.md for planned additions. Current priorities:
|
||||||
|
|
||||||
|
1. ✅ Current-page highlighting in 3D Graph
|
||||||
|
2. ✅ Click-to-navigate on graph
|
||||||
|
3. ✅ Responsive layout (1024px and 768px breakpoints)
|
||||||
|
4. ⏳ Backlinks panel (per-page incoming WikiLinks)
|
||||||
|
5. ⏳ Keyboard shortcuts (j/k for next/prev file, [/] for nav, g/G for graph focus)
|
||||||
|
6. ⏳ Light theme variant (currently Catppuccin-dark only)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Customizations — Feature Spec
|
||||||
|
|
||||||
|
Each section describes one Lukas-feature: what it does, how it works, where the code lives, and known limitations.
|
||||||
|
|
||||||
|
## 1. Current-page highlighting in 3D Graph
|
||||||
|
|
||||||
|
**What:** When you open a file, the corresponding node in the 3D Graph becomes brighter and larger. Nodes that the open file links to (via `[[wikilinks]]`) also brighten. All other nodes dim.
|
||||||
|
|
||||||
|
**How:**
|
||||||
|
1. `HermesCustom.getCurrentFile()` reads the file path from `?file=` URL param (preferred) or tracks via the `loadFile()` hook
|
||||||
|
2. `HermesCustom.getConnectedFiles(currentFile)` looks up outgoing edges in the global graph data
|
||||||
|
3. `HermesCustom.highlightCurrentNode()` iterates `window.graphNodeObjects` (obv's Three.js node meshes) and adjusts `material.color`, `emissive`, `scale`, `opacity`
|
||||||
|
|
||||||
|
**Code:** `vault-custom.js`, methods under namespace `HermesCustom.*`.
|
||||||
|
|
||||||
|
**Materials used (Catppuccin palette, matches theme):**
|
||||||
|
- Current: `#FFD700` (gold) + emissive glow + 1.5x scale
|
||||||
|
- Connected: `#89b4fa` (light blue) + faint emissive + 1.1x scale
|
||||||
|
- Other: `#313244` (muted) + 20% opacity (transparent)
|
||||||
|
|
||||||
|
**Known limitation:**
|
||||||
|
- Uses `window.graphNodeObjects` global from obv — if obv renames this, we need to update. Mitigation: detection in `highlightCurrentNode` waits up to 500ms for obv's graph to be ready.
|
||||||
|
- Highlight doesn't preserve selection across page reload — fresh fetch on every load.
|
||||||
|
|
||||||
|
## 2. Click-to-navigate on 3D Graph
|
||||||
|
|
||||||
|
**What:** Clicking a node in the 3D Graph navigates to that file's page.
|
||||||
|
|
||||||
|
**How:**
|
||||||
|
1. `HermesCustom.setupGraphClickHandler()` sets up a Three.js Raycaster on the canvas DOM element
|
||||||
|
2. On click, computes normalized mouse coords, raycasts into scene
|
||||||
|
3. First intersection's `userData.nodeId` is the file path
|
||||||
|
4. Calls `window.loadFile(nodeId)` (obv's existing loader)
|
||||||
|
5. Updates URL with `window.history.pushState({}, '', '?file=<path>')`
|
||||||
|
|
||||||
|
**Code:** `vault-custom.js`, ~25 lines.
|
||||||
|
|
||||||
|
**Known limitation:**
|
||||||
|
- Three.js raycasting requires nodes to have `userData.nodeId` set — verified in obv's `addNodeToScene` function (line ~310 in obv's graph render). If obv removes this, our handler silently does nothing.
|
||||||
|
- No keyboard navigation in graph (yet). Roadmap item.
|
||||||
|
|
||||||
|
## 3. Responsive Layout
|
||||||
|
|
||||||
|
**What:** The 3-panel desktop layout collapses gracefully on smaller screens.
|
||||||
|
|
||||||
|
**Breakpoints:**
|
||||||
|
- **Desktop (≥1024px):** Full 3-panel layout — Tree 240px | Content 1fr | Graph 240px
|
||||||
|
- **Tablet (768px–1023px):** Tree 240px | Content 1fr | Graph 200px (bottom panel)
|
||||||
|
- **Mobile (<768px):** Tree as hamburger menu (off-canvas, slides in from left) | Content full-width | Graph hidden
|
||||||
|
|
||||||
|
**How:**
|
||||||
|
- `HermesCustom.injectResponsiveCSS()` adds `<style>` tag with media queries
|
||||||
|
- `HermesCustom.responsiveLayout()` adds `.mobile-mode` / `.tablet-mode` classes to `.vault-app` based on `window.innerWidth`
|
||||||
|
- Resize handler is throttled (150ms) to avoid jank
|
||||||
|
- Hamburger toggle button is appended to header in mobile mode
|
||||||
|
|
||||||
|
**Code:** `vault-custom.js`, ~80 lines (including CSS string).
|
||||||
|
|
||||||
|
**Known limitation:**
|
||||||
|
- The injected CSS uses `!important` to override obv's `grid-template-columns`. If obv restructures the layout, we may need to adjust selectors.
|
||||||
|
- Hamburger menu doesn't auto-close on file selection — minor UX nit. Roadmap.
|
||||||
|
|
||||||
|
## 4. URL-based navigation (?file=)
|
||||||
|
|
||||||
|
**What:** The URL contains the currently-open file as a `?file=<path>` query param. This makes pages deep-linkable and back-button friendly.
|
||||||
|
|
||||||
|
**How:**
|
||||||
|
- `HermesCustom.getCurrentFile()` reads `?file=` from `URLSearchParams`
|
||||||
|
- `vault-custom.js` updates URL via `history.pushState()` on file load
|
||||||
|
- Server.py patch accepts `?file=` in API calls
|
||||||
|
|
||||||
|
**Server-side patch (server.py):**
|
||||||
|
```python
|
||||||
|
# Lukas-add: also support ?file=<path> query for clean URL navigation
|
||||||
|
if "?" in rel:
|
||||||
|
rel, _, query = rel.partition("?")
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
file_param = parse_qs(query).get("file", [None])[0]
|
||||||
|
if file_param:
|
||||||
|
rel = file_param
|
||||||
|
```
|
||||||
|
|
||||||
|
**Known limitation:**
|
||||||
|
- Server-side patch assumes `?file=` is the only query parameter obv might add. If obv adds another (e.g. `?theme=`), the simple `partition("?")` still works but is naive. Acceptable for current state.
|
||||||
|
|
||||||
|
## 5. Tracking current file via loadFile hook
|
||||||
|
|
||||||
|
**What:** `vault-custom.js` patches obv's `window.loadFile` to remember the last loaded path. Used as a fallback when the URL doesn't have `?file=` (e.g., when obv opens a file from search without updating URL).
|
||||||
|
|
||||||
|
**How:**
|
||||||
|
```js
|
||||||
|
const originalLoadFile = window.loadFile;
|
||||||
|
window.loadFile = function(path) {
|
||||||
|
HermesCustom._lastLoadedFile = path;
|
||||||
|
const result = originalLoadFile.apply(this, arguments);
|
||||||
|
setTimeout(HermesCustom.highlightCurrentNode, 300);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Known limitation:**
|
||||||
|
- The 300ms delay assumes the render finishes within that time. Slow devices may need a longer delay. Could be improved by hooking into a render-completion signal if obv provides one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Manual testing only for now. Test cases:
|
||||||
|
|
||||||
|
| Scenario | Expected |
|
||||||
|
|----------|----------|
|
||||||
|
| Open `concepts/agent-reference-model.md` | Graph: that node gold + glow, connected nodes light blue, others dim |
|
||||||
|
| Open file via search (not via tree click) | Same highlighting (URL gets `?file=`) |
|
||||||
|
| Click graph node | File loads, URL updates |
|
||||||
|
| Resize browser to <1024px | Graph moves to bottom panel |
|
||||||
|
| Resize browser to <768px | Tree becomes hamburger, graph hidden |
|
||||||
|
| Hard reload on file URL | File loads directly (server.py parses `?file=`) |
|
||||||
|
| Upstream obv update (theoretical) | Only 1 line of conflict in vault.html, easy merge |
|
||||||
|
|
||||||
|
Planned: Playwright-based automated test for the 5 visual features.
|
||||||
+769
@@ -0,0 +1,769 @@
|
|||||||
|
#!/tmp/ea-venv/bin/python3
|
||||||
|
"""
|
||||||
|
hermes-wiki-generator: Static HTML generator for Lukas' karpathy-style Wiki.
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
1. Watchdog observes /home/admin/my-karpathy-wiki/ for changes
|
||||||
|
2. On .md change → re-render single HTML file to /home/admin/.local/share/hermes-wiki/site/
|
||||||
|
3. On Wiki startup → generate tree.json, graph.json, tag-cloud.json (one-time)
|
||||||
|
4. Python http.server serves /home/admin/.local/share/hermes-wiki/site/ on 127.0.0.1:8765
|
||||||
|
|
||||||
|
Why static HTML:
|
||||||
|
- Mobile-first: load once, works offline (with service worker)
|
||||||
|
- Fast: zero JS for content rendering, JSON data lazy-loaded
|
||||||
|
- Bookmarkable: each page is a real URL like /concepts/agent-reference-model.html
|
||||||
|
- Touch-friendly: no animation that fights tap-targets
|
||||||
|
|
||||||
|
Run:
|
||||||
|
python3 ~/repos/hermes-wiki-static/generator.py --start
|
||||||
|
python3 ~/repos/hermes-wiki-static/generator.py --stop
|
||||||
|
python3 ~/repos/hermes-wiki-static/generator.py --status
|
||||||
|
python3 ~/repos/hermes-wiki-static/generator.py --once (one-shot regen, no watcher)
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Lock, Thread
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
import frontmatter
|
||||||
|
import markdown as md
|
||||||
|
import yaml
|
||||||
|
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
||||||
|
from watchdog.observers import Observer
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Configuration
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
WIKI_DIR = Path("/home/admin/my-karpathy-wiki")
|
||||||
|
SITE_DIR = Path("/home/admin/.local/share/hermes-wiki/site")
|
||||||
|
LOG_DIR = Path("/home/admin/.local/share/hermes-wiki/log")
|
||||||
|
PID_FILE = Path("/tmp/hermes-wiki-static.pid")
|
||||||
|
PORT = 8765
|
||||||
|
BIND = "127.0.0.1"
|
||||||
|
|
||||||
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
SITE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(LOG_DIR / "generator.log"),
|
||||||
|
logging.StreamHandler(sys.stdout),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
log = logging.getLogger("hermes-wiki")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Markdown → HTML rendering
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# WikiLink regex: [[entity-name]] or [[path/to/entity|display-text]]
|
||||||
|
WIKILINK_RE = re.compile(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]")
|
||||||
|
# Hashtag: #tag-name (max 30 chars, alphanumeric + dash)
|
||||||
|
TAG_RE = re.compile(r"(?<![\w/])#([a-zA-Z][a-z0-9-]{1,30})")
|
||||||
|
|
||||||
|
# Markdown extensions: TOC, tables, fenced code, footnotes, attr lists
|
||||||
|
MD_EXTENSIONS = [
|
||||||
|
"toc",
|
||||||
|
"tables",
|
||||||
|
"fenced_code",
|
||||||
|
"footnotes",
|
||||||
|
"attr_list",
|
||||||
|
"def_list",
|
||||||
|
"sane_lists",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(path: Path) -> str:
|
||||||
|
"""Convert /concepts/agent-reference-model.md → concepts/agent-reference-model"""
|
||||||
|
rel = path.relative_to(WIKI_DIR).with_suffix("")
|
||||||
|
return str(rel).replace(os.sep, "/")
|
||||||
|
|
||||||
|
|
||||||
|
def read_markdown(path: Path) -> frontmatter.Post:
|
||||||
|
"""Read .md with YAML frontmatter, fallback to plain text."""
|
||||||
|
try:
|
||||||
|
return frontmatter.load(path)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
log.warning(f"YAML-Fehler in {path}: {e} — versuche ohne Frontmatter")
|
||||||
|
text = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
return frontmatter.Post(text)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_wikilinks(text: str, slug_to_path: dict) -> str:
|
||||||
|
"""Replace [[entity-name]] with <a href="/path/entity.html">entity-name</a>."""
|
||||||
|
def replace(m: re.Match) -> str:
|
||||||
|
target = m.group(1).strip()
|
||||||
|
display = (m.group(2) or target).strip()
|
||||||
|
# Try exact match first
|
||||||
|
if target in slug_to_path:
|
||||||
|
slug = slug_to_path[target]
|
||||||
|
return f'<a class="wikilink" href="/{quote(slug)}.html">{display}</a>'
|
||||||
|
# Try with .md suffix stripped
|
||||||
|
target_no_ext = target.replace(".md", "")
|
||||||
|
if target_no_ext in slug_to_path:
|
||||||
|
slug = slug_to_path[target_no_ext]
|
||||||
|
return f'<a class="wikilink" href="/{quote(slug)}.html">{display}</a>'
|
||||||
|
# Not found — render as broken link
|
||||||
|
return f'<a class="wikilink wikilink-missing" href="/__missing.html?target={quote(target)}">{display}</a>'
|
||||||
|
return WIKILINK_RE.sub(replace, text)
|
||||||
|
|
||||||
|
|
||||||
|
def quote(s: str) -> str:
|
||||||
|
"""URL-encode path components."""
|
||||||
|
import urllib.parse
|
||||||
|
return urllib.parse.quote(s, safe="/-_~.")
|
||||||
|
|
||||||
|
|
||||||
|
def render_page(md_path: Path, slug_to_path: dict, all_meta: list) -> dict:
|
||||||
|
"""Render one .md to (HTML + metadata). Returns dict with html, title, slug, frontmatter."""
|
||||||
|
post = read_markdown(md_path)
|
||||||
|
slug = slugify(md_path)
|
||||||
|
|
||||||
|
# Replace wikilinks before markdown rendering
|
||||||
|
body_with_links = resolve_wikilinks(post.content, slug_to_path)
|
||||||
|
html_body = md.markdown(
|
||||||
|
body_with_links,
|
||||||
|
extensions=MD_EXTENSIONS,
|
||||||
|
extension_configs={"toc": {"permalink": True}},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract title from H1 if not in frontmatter
|
||||||
|
title = post.metadata.get("title") or md_path.stem.replace("-", " ").title()
|
||||||
|
if not post.metadata.get("title"):
|
||||||
|
h1_match = re.search(r"<h1[^>]*>(.*?)</h1>", html_body, re.IGNORECASE)
|
||||||
|
if h1_match:
|
||||||
|
title = re.sub(r"<[^>]+>", "", h1_match.group(1))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"slug": slug,
|
||||||
|
"title": title,
|
||||||
|
"html": html_body,
|
||||||
|
"frontmatter": dict(post.metadata),
|
||||||
|
"path": str(md_path.relative_to(WIKI_DIR)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Index data: tree, graph, tags, backlinks
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def build_index(slug_meta_list: list) -> dict:
|
||||||
|
"""Build tree.json, graph.json, tags.json, backlinks.json."""
|
||||||
|
slug_to_meta = {s["slug"]: s for s in slug_meta_list}
|
||||||
|
|
||||||
|
# Tree: hierarchical folder structure
|
||||||
|
tree = {"name": "Vault", "children": {}, "files": []}
|
||||||
|
for s in slug_meta_list:
|
||||||
|
parts = s["slug"].split("/")
|
||||||
|
node = tree
|
||||||
|
for folder in parts[:-1]:
|
||||||
|
node = node["children"].setdefault(folder, {"name": folder, "children": {}, "files": []})
|
||||||
|
node["files"].append({"name": parts[-1], "title": s["title"], "slug": s["slug"]})
|
||||||
|
|
||||||
|
# Recursively convert dict-of-children to list-of-children (easier JSON)
|
||||||
|
def tree_to_list(node):
|
||||||
|
result = {
|
||||||
|
"name": node["name"],
|
||||||
|
"files": sorted(node["files"], key=lambda f: f["name"].lower()),
|
||||||
|
}
|
||||||
|
result["folders"] = sorted(
|
||||||
|
[tree_to_list(child) for child in node["children"].values()],
|
||||||
|
key=lambda f: f["name"].lower()
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
tree_list = tree_to_list(tree)
|
||||||
|
|
||||||
|
# Graph: extract [[wikilinks]] from each rendered HTML
|
||||||
|
nodes = []
|
||||||
|
edges = []
|
||||||
|
for s in slug_meta_list:
|
||||||
|
nodes.append({"id": s["slug"], "title": s["title"], "size": 1})
|
||||||
|
# Find all wikilinks in the source
|
||||||
|
wikilinks = WIKILINK_RE.findall(s["html"] + " " + str(s["frontmatter"]))
|
||||||
|
for target, _disp in wikilinks:
|
||||||
|
target_slug = target.replace(".md", "")
|
||||||
|
if target_slug in slug_to_meta:
|
||||||
|
edges.append({"source": s["slug"], "target": target_slug})
|
||||||
|
|
||||||
|
# Tags: collect from frontmatter.tags + inline #tags
|
||||||
|
tags = {} # tag → [slugs]
|
||||||
|
for s in slug_meta_list:
|
||||||
|
# Frontmatter tags
|
||||||
|
fm_tags = s["frontmatter"].get("tags", [])
|
||||||
|
if isinstance(fm_tags, str):
|
||||||
|
fm_tags = [t.strip() for t in fm_tags.split(",")]
|
||||||
|
for tag in fm_tags or []:
|
||||||
|
tags.setdefault(str(tag).lower(), []).append(s["slug"])
|
||||||
|
# Inline #tags in content
|
||||||
|
inline_tags = TAG_RE.findall(s["html"])
|
||||||
|
for tag in inline_tags:
|
||||||
|
tags.setdefault(tag.lower(), []).append(s["slug"])
|
||||||
|
|
||||||
|
# Backlinks: for each page, list of pages that link TO it
|
||||||
|
backlinks = {s["slug"]: [] for s in slug_meta_list}
|
||||||
|
for edge in edges:
|
||||||
|
target = edge["target"]
|
||||||
|
source = edge["source"]
|
||||||
|
if source != target: # skip self-links
|
||||||
|
backlinks[target].append(source)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tree": tree_list,
|
||||||
|
"graph": {"nodes": nodes, "edges": edges},
|
||||||
|
"tags": tags,
|
||||||
|
"backlinks": backlinks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# HTML page template
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
PAGE_TEMPLATE = """<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#0a0a14">
|
||||||
|
<title>{title} · Hermes Wiki</title>
|
||||||
|
<link rel="stylesheet" href="/__/style.css">
|
||||||
|
<link rel="manifest" href="/__/manifest.json">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📓</text></svg>">
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="page-{kind}">
|
||||||
|
<div class="app-shell">
|
||||||
|
<header class="app-header">
|
||||||
|
<button class="icon-btn menu-toggle" aria-label="Toggle tree" data-action="toggle-tree">☰</button>
|
||||||
|
<a href="/__/index.html" class="brand">📓 Hermes Wiki</a>
|
||||||
|
<div class="search-wrap">
|
||||||
|
<input type="search" id="search" placeholder="Suchen…" aria-label="Search notes">
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn graph-toggle" aria-label="Toggle graph" data-action="toggle-graph">⊕</button>
|
||||||
|
</header>
|
||||||
|
<aside class="app-tree" id="tree" aria-label="File tree"></aside>
|
||||||
|
<main class="app-content">
|
||||||
|
<article class="note">
|
||||||
|
{frontmatter_card}
|
||||||
|
<div class="note-body">{body}</div>
|
||||||
|
<nav class="note-toc" id="toc" aria-label="Table of contents"></nav>
|
||||||
|
<section class="note-backlinks" id="backlinks" aria-label="Backlinks"></section>
|
||||||
|
<footer class="note-footer">
|
||||||
|
<span class="note-path">{path}</span>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
<aside class="app-graph" id="graph" aria-label="3D graph"></aside>
|
||||||
|
<nav class="app-bottom-nav" aria-label="Bottom navigation">
|
||||||
|
<div class="app-bottom-nav-inner">
|
||||||
|
<button data-action="toggle-tree"><span class="icon">☰</span><small>Files</small></button>
|
||||||
|
<button data-action="focus-search"><span class="icon">🔍</span><small>Search</small></button>
|
||||||
|
<button data-action="toggle-graph"><span class="icon">⊕</span><small>Graph</small></button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div class="graph-modal" aria-label="3D graph (full screen)">
|
||||||
|
<div class="graph-modal-header">
|
||||||
|
<span class="graph-modal-title">GRAPH VIEW · Drag to rotate · Tap a node to navigate</span>
|
||||||
|
<button class="graph-modal-close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="graph-modal-canvas"><canvas></canvas></div>
|
||||||
|
<div class="graph-legend">
|
||||||
|
<div class="graph-legend-title">Legend</div>
|
||||||
|
<div class="graph-legend-row"><span class="graph-legend-dot" style="background:#FFD700"></span>Current page</div>
|
||||||
|
<div class="graph-legend-row"><span class="graph-legend-dot" style="background:#FFD700;opacity:0.8"></span>Connected</div>
|
||||||
|
<div class="graph-legend-row"><span class="graph-legend-dot" style="background:#6c7086;opacity:0.5"></span>Other notes</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/__/data.js"></script>
|
||||||
|
<script src="/__/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def render_frontmatter_card(meta: dict) -> str:
|
||||||
|
"""Render frontmatter as a Catppuccin-styled card."""
|
||||||
|
if not meta:
|
||||||
|
return ""
|
||||||
|
rows = []
|
||||||
|
for key, value in meta.items():
|
||||||
|
if key in ("title", "tags"):
|
||||||
|
continue # shown elsewhere
|
||||||
|
if isinstance(value, list):
|
||||||
|
value = ", ".join(str(v) for v in value)
|
||||||
|
rows.append(f'<div class="meta-row"><span class="meta-key">{key}</span><span class="meta-value">{value}</span></div>')
|
||||||
|
if not rows:
|
||||||
|
return ""
|
||||||
|
return f'<aside class="meta-card">{"".join(rows)}</aside>'
|
||||||
|
|
||||||
|
|
||||||
|
def write_page(meta: dict, site_dir: Path) -> None:
|
||||||
|
"""Write a single HTML page to SITE_DIR/<slug>.html."""
|
||||||
|
target = site_dir / (quote(meta["slug"]) + ".html")
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
kind = "concept" if "concepts" in meta["slug"] else \
|
||||||
|
"entity" if "entities" in meta["slug"] else \
|
||||||
|
"scratch" if "scratch" in meta["slug"] else "note"
|
||||||
|
html = PAGE_TEMPLATE.format(
|
||||||
|
title=meta["title"],
|
||||||
|
body=meta["html"],
|
||||||
|
frontmatter_card=render_frontmatter_card(meta["frontmatter"]),
|
||||||
|
kind=kind,
|
||||||
|
path=meta["path"],
|
||||||
|
)
|
||||||
|
target.write_text(html, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def write_index_files(index_data: dict, site_dir: Path) -> None:
|
||||||
|
"""Write tree.json, graph.json, tags.json, backlinks.json."""
|
||||||
|
assets_dir = site_dir / "__"
|
||||||
|
assets_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(assets_dir / "tree.json").write_text(
|
||||||
|
json.dumps(index_data["tree"], ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
(assets_dir / "graph.json").write_text(
|
||||||
|
json.dumps(index_data["graph"], ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
(assets_dir / "tags.json").write_text(
|
||||||
|
json.dumps(index_data["tags"], ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
(assets_dir / "backlinks.json").write_text(
|
||||||
|
json.dumps(index_data["backlinks"], ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def write_data_js(slug_meta_list: list, index_data: dict, site_dir: Path) -> None:
|
||||||
|
"""Single data.js with all metadata + index baked in (avoids CORS issues)."""
|
||||||
|
assets_dir = site_dir / "__"
|
||||||
|
assets_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
pages = [{
|
||||||
|
"slug": s["slug"],
|
||||||
|
"title": s["title"],
|
||||||
|
"tags": s["frontmatter"].get("tags", []) if isinstance(s["frontmatter"].get("tags"), list) else [],
|
||||||
|
"type": s["frontmatter"].get("type", "note"),
|
||||||
|
"path": s["path"],
|
||||||
|
} for s in slug_meta_list]
|
||||||
|
js = f"""// Auto-generated by hermes-wiki-generator
|
||||||
|
window.HERMES_DATA = {{
|
||||||
|
pages: {json.dumps(pages, ensure_ascii=False)},
|
||||||
|
tree: {json.dumps(index_data['tree'], ensure_ascii=False)},
|
||||||
|
graph: {json.dumps(index_data['graph'], ensure_ascii=False)},
|
||||||
|
tags: {json.dumps(index_data['tags'], ensure_ascii=False)},
|
||||||
|
backlinks: {json.dumps(index_data['backlinks'], ensure_ascii=False)},
|
||||||
|
currentSlug: "{slug_meta_list[0]['slug'] if slug_meta_list else ''}",
|
||||||
|
}};
|
||||||
|
"""
|
||||||
|
(site_dir / "__/data.js").write_text(js, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Static assets (CSS, JS, PWA manifest)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# CSS is a placeholder — touch-first design comes in next commit
|
||||||
|
CSS_PLACEHOLDER = """/* Hermes Wiki — placeholder CSS */
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0a0a14;
|
||||||
|
--bg-secondary: #0e0e1a;
|
||||||
|
--bg-surface: #1a1a2e;
|
||||||
|
--text-primary: #cdd6f4;
|
||||||
|
--text-secondary: #a6adc8;
|
||||||
|
--text-muted: #6c7086;
|
||||||
|
--accent: #FFD700;
|
||||||
|
--link: #FFD700;
|
||||||
|
--border: #313244;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { background: var(--bg-primary); color: var(--text-primary); font-family: -apple-system, 'Segoe UI', Inter, sans-serif; }
|
||||||
|
.app-shell { display: grid; grid-template-columns: 280px 1fr 260px; grid-template-rows: 48px 1fr; height: 100vh; }
|
||||||
|
.app-header { grid-column: 1 / -1; background: var(--bg-secondary); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 16px; gap: 12px; }
|
||||||
|
.brand { color: var(--accent); font-weight: 700; }
|
||||||
|
#search { flex: 1; max-width: 400px; padding: 6px 12px; background: rgba(30,30,50,0.6); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary); }
|
||||||
|
.icon-btn { background: none; border: 1px solid var(--border); color: var(--text-secondary); padding: 4px 10px; border-radius: 4px; cursor: pointer; }
|
||||||
|
.app-tree { background: var(--bg-secondary); border-right: 1px solid var(--border); overflow-y: auto; padding: 8px; }
|
||||||
|
.app-content { overflow-y: auto; padding: 32px 48px; }
|
||||||
|
.app-graph { background: var(--bg-secondary); border-left: 1px solid var(--border); }
|
||||||
|
.meta-card { background: var(--bg-surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; margin-bottom: 24px; font-size: 12px; }
|
||||||
|
.meta-row { display: flex; gap: 8px; padding: 2px 0; }
|
||||||
|
.meta-key { color: var(--accent); font-weight: 600; min-width: 80px; }
|
||||||
|
.wikilink-missing { color: var(--text-muted); text-decoration: line-through; }
|
||||||
|
.note-body { line-height: 1.7; }
|
||||||
|
.note-body h1, .note-body h2, .note-body h3 { margin-top: 1.5em; margin-bottom: 0.5em; }
|
||||||
|
.note-body code { background: var(--bg-surface); padding: 2px 6px; border-radius: 3px; }
|
||||||
|
.note-body pre { background: var(--bg-surface); padding: 12px; border-radius: 6px; overflow-x: auto; }
|
||||||
|
.app-bottom-nav { display: none; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
JS_PLACEHOLDER = """// Hermes Wiki — placeholder JS
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
// Bootstrap from data.js
|
||||||
|
const data = window.HERMES_DATA || { pages: [], tree: [], graph: { nodes: [], edges: [] }, tags: {}, backlinks: {} };
|
||||||
|
|
||||||
|
// Render tree into sidebar
|
||||||
|
function renderTree(node, container, basePath = '') {
|
||||||
|
const ul = document.createElement('ul');
|
||||||
|
if (node.folders) {
|
||||||
|
for (const folder of node.folders) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.textContent = '📁 ' + folder.name;
|
||||||
|
header.style.cursor = 'pointer';
|
||||||
|
const childrenContainer = document.createElement('div');
|
||||||
|
header.onclick = () => {
|
||||||
|
childrenContainer.style.display = childrenContainer.style.display === 'none' ? 'block' : 'none';
|
||||||
|
};
|
||||||
|
childrenContainer.style.display = 'none';
|
||||||
|
childrenContainer.style.paddingLeft = '12px';
|
||||||
|
li.appendChild(header);
|
||||||
|
li.appendChild(childrenContainer);
|
||||||
|
renderTree(folder, childrenContainer, basePath + '/' + folder.name);
|
||||||
|
ul.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.files) {
|
||||||
|
for (const file of node.files) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = '/' + file.slug + '.html';
|
||||||
|
a.textContent = file.title;
|
||||||
|
a.style.color = 'var(--text-primary)';
|
||||||
|
a.style.textDecoration = 'none';
|
||||||
|
a.style.display = 'block';
|
||||||
|
a.style.padding = '2px 8px';
|
||||||
|
li.appendChild(a);
|
||||||
|
ul.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
container.appendChild(ul);
|
||||||
|
}
|
||||||
|
|
||||||
|
const treeEl = document.getElementById('tree');
|
||||||
|
if (treeEl && data.tree) {
|
||||||
|
renderTree(data.tree, treeEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search (client-side)
|
||||||
|
const search = document.getElementById('search');
|
||||||
|
if (search) {
|
||||||
|
search.addEventListener('input', (e) => {
|
||||||
|
const q = e.target.value.toLowerCase().trim();
|
||||||
|
if (!q) return;
|
||||||
|
// Simple substring search on titles + tags
|
||||||
|
const matches = data.pages.filter(p =>
|
||||||
|
p.title.toLowerCase().includes(q) ||
|
||||||
|
(p.tags || []).some(t => t.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
console.log('[Search]', q, '→', matches.length, 'results');
|
||||||
|
// TODO: show results dropdown
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle buttons (placeholder)
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.dataset.action === 'toggle-tree') {
|
||||||
|
document.querySelector('.app-tree').classList.toggle('open');
|
||||||
|
}
|
||||||
|
if (e.target.dataset.action === 'toggle-graph') {
|
||||||
|
document.querySelector('.app-graph').classList.toggle('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
PWA_MANIFEST = """{
|
||||||
|
"name": "Hermes Wiki",
|
||||||
|
"short_name": "Wiki",
|
||||||
|
"description": "Lukas Huber's knowledge vault — mobile-first static wiki",
|
||||||
|
"start_url": "/__/index.html",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0a0a14",
|
||||||
|
"theme_color": "#0a0a14",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📓</text></svg>",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
def write_assets(site_dir: Path) -> None:
|
||||||
|
"""Copy static assets (CSS, JS, manifest) from repo to site dir."""
|
||||||
|
assets_dir = site_dir / "__"
|
||||||
|
assets_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
repo_root = Path(__file__).parent
|
||||||
|
for asset in ("style.css", "app.js", "manifest.json"):
|
||||||
|
src = repo_root / asset
|
||||||
|
if src.exists():
|
||||||
|
content = src.read_text(encoding="utf-8")
|
||||||
|
(assets_dir / asset).write_text(content, encoding="utf-8")
|
||||||
|
else:
|
||||||
|
log.warning(f"Asset not found in repo: {src}")
|
||||||
|
# index page (redirect to first page or show all)
|
||||||
|
index_html = """<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="UTF-8"><title>Hermes Wiki</title>
|
||||||
|
<link rel="stylesheet" href="/__/style.css">
|
||||||
|
<meta http-equiv="refresh" content="0; url=/{slug}.html"></head>
|
||||||
|
<body><a href="/{slug}.html">Open Wiki</a></body></html>"""
|
||||||
|
first_slug = "index" # fall back to index.md
|
||||||
|
(site_dir / "__/index.html").write_text(
|
||||||
|
index_html.replace("{slug}", first_slug), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Generator orchestration
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class WikiState:
|
||||||
|
"""Holds in-memory state of generated pages and index."""
|
||||||
|
def __init__(self):
|
||||||
|
self.lock = Lock()
|
||||||
|
self.slug_to_path = {} # slug → original .md path
|
||||||
|
self.slug_meta_list = [] # list of {slug, title, html, frontmatter, path}
|
||||||
|
self.index_data = {} # tree, graph, tags, backlinks
|
||||||
|
|
||||||
|
def regenerate_all(self):
|
||||||
|
"""Full rebuild — used on startup."""
|
||||||
|
log.info(f"Full regenerate: scanning {WIKI_DIR}")
|
||||||
|
with self.lock:
|
||||||
|
self.slug_to_path.clear()
|
||||||
|
self.slug_meta_list.clear()
|
||||||
|
|
||||||
|
md_files = sorted(WIKI_DIR.rglob("*.md"))
|
||||||
|
skipped_raw = 0
|
||||||
|
for md_file in md_files:
|
||||||
|
rel = md_file.relative_to(WIKI_DIR)
|
||||||
|
if rel.parts[0] == "raw":
|
||||||
|
skipped_raw += 1
|
||||||
|
continue
|
||||||
|
slug = slugify(md_file)
|
||||||
|
self.slug_to_path[slug] = slug
|
||||||
|
self.slug_to_path[md_file.stem] = slug # for [[agent-hermes]] lookups
|
||||||
|
# Also index by basename without .md
|
||||||
|
base = md_file.name[:-3]
|
||||||
|
if base not in self.slug_to_path:
|
||||||
|
self.slug_to_path[base] = slug
|
||||||
|
|
||||||
|
log.info(f"Found {len(self.slug_to_path)} slugs ({skipped_raw} skipped in raw/)")
|
||||||
|
|
||||||
|
# Render each page
|
||||||
|
for md_file in md_files:
|
||||||
|
rel = md_file.relative_to(WIKI_DIR)
|
||||||
|
if rel.parts[0] == "raw":
|
||||||
|
continue
|
||||||
|
meta = render_page(md_file, self.slug_to_path, self.slug_meta_list)
|
||||||
|
self.slug_meta_list.append(meta)
|
||||||
|
write_page(meta, SITE_DIR)
|
||||||
|
|
||||||
|
log.info(f"Rendered {len(self.slug_meta_list)} HTML pages")
|
||||||
|
|
||||||
|
# Build index data
|
||||||
|
self.index_data = build_index(self.slug_meta_list)
|
||||||
|
write_index_files(self.index_data, SITE_DIR)
|
||||||
|
write_data_js(self.slug_meta_list, self.index_data, SITE_DIR)
|
||||||
|
write_assets(SITE_DIR)
|
||||||
|
log.info("Index files written: tree.json, graph.json, tags.json, backlinks.json, data.js")
|
||||||
|
|
||||||
|
def regenerate_one(self, md_path: Path):
|
||||||
|
"""Re-render one page (and its reverse-link targets if needed)."""
|
||||||
|
rel = md_path.relative_to(WIKI_DIR)
|
||||||
|
if rel.parts[0] == "raw":
|
||||||
|
return
|
||||||
|
with self.lock:
|
||||||
|
slug = slugify(md_path)
|
||||||
|
meta = render_page(md_path, self.slug_to_path, self.slug_meta_list)
|
||||||
|
# Update or append
|
||||||
|
for i, existing in enumerate(self.slug_meta_list):
|
||||||
|
if existing["slug"] == slug:
|
||||||
|
self.slug_meta_list[i] = meta
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
self.slug_meta_list.append(meta)
|
||||||
|
write_page(meta, SITE_DIR)
|
||||||
|
log.info(f"Re-rendered: {slug}")
|
||||||
|
# Rebuild index (cheap for small wikis, ensures backlinks/tags stay consistent)
|
||||||
|
self.index_data = build_index(self.slug_meta_list)
|
||||||
|
write_index_files(self.index_data, SITE_DIR)
|
||||||
|
write_data_js(self.slug_meta_list, self.index_data, SITE_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
class WikiFileWatcher(FileSystemEventHandler):
|
||||||
|
def __init__(self, state: WikiState):
|
||||||
|
self.state = state
|
||||||
|
|
||||||
|
def on_modified(self, event: FileSystemEvent):
|
||||||
|
if event.is_directory:
|
||||||
|
return
|
||||||
|
path = Path(event.src_path)
|
||||||
|
if path.suffix == ".md":
|
||||||
|
log.info(f"File modified: {path}")
|
||||||
|
self.state.regenerate_one(path)
|
||||||
|
|
||||||
|
def on_created(self, event: FileSystemEvent):
|
||||||
|
if event.is_directory:
|
||||||
|
return
|
||||||
|
path = Path(event.src_path)
|
||||||
|
if path.suffix == ".md":
|
||||||
|
log.info(f"File created: {path}")
|
||||||
|
self.state.regenerate_one(path)
|
||||||
|
|
||||||
|
def on_deleted(self, event: FileSystemEvent):
|
||||||
|
if event.is_directory:
|
||||||
|
return
|
||||||
|
path = Path(event.src_path)
|
||||||
|
if path.suffix == ".md":
|
||||||
|
log.info(f"File deleted: {path} (TODO: remove HTML)")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# HTTP server
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class WikiHandler(SimpleHTTPRequestHandler):
|
||||||
|
"""Serves from SITE_DIR. Logs requests briefly."""
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
log.debug(f"HTTP {self.address_string()} {format % args}")
|
||||||
|
|
||||||
|
def end_headers(self):
|
||||||
|
# Cache headers for static assets
|
||||||
|
if self.path.startswith("/__/"):
|
||||||
|
self.send_header("Cache-Control", "public, max-age=300")
|
||||||
|
super().end_headers()
|
||||||
|
|
||||||
|
|
||||||
|
def start_server():
|
||||||
|
"""Run HTTP server on BIND:PORT serving from SITE_DIR."""
|
||||||
|
os.chdir(SITE_DIR)
|
||||||
|
server = HTTPServer((BIND, PORT), WikiHandler)
|
||||||
|
log.info(f"HTTP server: http://{BIND}:{PORT} serving {SITE_DIR}")
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Daemon mode (PID file, signals)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def write_pid():
|
||||||
|
PID_FILE.write_text(str(os.getpid()))
|
||||||
|
|
||||||
|
|
||||||
|
def is_running():
|
||||||
|
if not PID_FILE.exists():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
pid = int(PID_FILE.read_text())
|
||||||
|
os.kill(pid, 0) # check if alive
|
||||||
|
return True
|
||||||
|
except (ValueError, ProcessLookupError, PermissionError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def stop_daemon():
|
||||||
|
if not PID_FILE.exists():
|
||||||
|
log.info("No PID file — daemon not running")
|
||||||
|
return
|
||||||
|
pid = int(PID_FILE.read_text())
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
log.info(f"Sent SIGTERM to PID {pid}")
|
||||||
|
except ProcessLookupError:
|
||||||
|
log.info(f"PID {pid} not found — already stopped?")
|
||||||
|
PID_FILE.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Main entry point
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Hermes Wiki Static Generator")
|
||||||
|
parser.add_argument("--start", action="store_true", help="Run as daemon (watcher + HTTP server)")
|
||||||
|
parser.add_argument("--stop", action="store_true", help="Stop daemon")
|
||||||
|
parser.add_argument("--status", action="store_true", help="Show status")
|
||||||
|
parser.add_argument("--once", action="store_true", help="Generate once and exit (no watcher)")
|
||||||
|
parser.add_argument("--foreground", action="store_true", help="Run in foreground (default: --start)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.stop:
|
||||||
|
stop_daemon()
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.status:
|
||||||
|
if is_running():
|
||||||
|
pid = int(PID_FILE.read_text())
|
||||||
|
print(f"Running: PID {pid}")
|
||||||
|
else:
|
||||||
|
print(f"Not running (no PID file: {PID_FILE})")
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.once:
|
||||||
|
state = WikiState()
|
||||||
|
state.regenerate_all()
|
||||||
|
print(f"Generated {len(state.slug_meta_list)} pages to {SITE_DIR}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Daemon mode
|
||||||
|
if is_running():
|
||||||
|
log.error(f"Already running (PID {int(PID_FILE.read_text())}). Use --stop first.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Initial full regen
|
||||||
|
state = WikiState()
|
||||||
|
state.regenerate_all()
|
||||||
|
|
||||||
|
# Start file watcher
|
||||||
|
observer = Observer()
|
||||||
|
observer.schedule(WikiFileWatcher(state), str(WIKI_DIR), recursive=True)
|
||||||
|
observer.start()
|
||||||
|
log.info(f"Filesystem watcher started on {WIKI_DIR}")
|
||||||
|
|
||||||
|
# Start HTTP server in background thread
|
||||||
|
server_thread = Thread(target=start_server, daemon=True)
|
||||||
|
server_thread.start()
|
||||||
|
|
||||||
|
# Write PID
|
||||||
|
write_pid()
|
||||||
|
log.info(f"Daemon PID: {os.getpid()}")
|
||||||
|
|
||||||
|
# Handle signals
|
||||||
|
def handle_term(signum, frame):
|
||||||
|
log.info(f"Received signal {signum}, shutting down")
|
||||||
|
observer.stop()
|
||||||
|
PID_FILE.unlink(missing_ok=True)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, handle_term)
|
||||||
|
signal.signal(signal.SIGINT, handle_term)
|
||||||
|
|
||||||
|
# Keep main thread alive
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(60)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
handle_term(0, None)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "Hermes Wiki",
|
||||||
|
"short_name": "Wiki",
|
||||||
|
"description": "Lukas Huber's knowledge vault — mobile-first static wiki",
|
||||||
|
"start_url": "/__/index.html",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0a0a14",
|
||||||
|
"theme_color": "#0a0a14",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📓</text></svg>",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
markdown>=3.10
|
||||||
|
pyyaml>=6.0
|
||||||
|
watchdog>=6.0
|
||||||
|
python-frontmatter>=1.3
|
||||||
Executable
+346
@@ -0,0 +1,346 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# hermes-wiki-serve.sh — Hermes Wiki via obsidian-web-viewer + Tailscale
|
||||||
|
#
|
||||||
|
# Was dieses Skript tut:
|
||||||
|
# - Klont DanielCheer/obsidian-web-viewer einmalig nach ~/.local/share/hermes-wiki/owv/
|
||||||
|
# - Startet deren server.py (Python stdlib) auf 127.0.0.1:8765 (Loopback)
|
||||||
|
# - Aktiviert tailscale serve, der den Loopback-Port als https im Tailnet exponen lässt
|
||||||
|
# - Liefert File-Tree, 3D-Graph, Volltext-Suche, WikiLink-Navigation out-of-the-box
|
||||||
|
#
|
||||||
|
# Verwendung:
|
||||||
|
# hermes-wiki-serve.sh start # Server starten (klont obv beim ersten Mal)
|
||||||
|
# hermes-wiki-serve.sh stop # Server stoppen
|
||||||
|
# hermes-wiki-serve.sh restart # stop + start
|
||||||
|
# hermes-wiki-serve.sh status # Process-Status, Health-Check, Tailscale-URL
|
||||||
|
# hermes-wiki-serve.sh logs # tail -f der Log-Files
|
||||||
|
# hermes-wiki-serve.sh install # nur das Clonen/Update, kein Server-Start
|
||||||
|
# hermes-wiki-serve.sh --help
|
||||||
|
#
|
||||||
|
# Umgebungsvariablen:
|
||||||
|
# HERMES_WIKI_PORT=8765 Lokaler HTTP-Port (Loopback)
|
||||||
|
# HERMES_WIKI_DIR=/home/admin/my-karpathy-wiki
|
||||||
|
# HERMES_WIKI_LOG_DIR=$HOME/.local/share/hermes-wiki/log
|
||||||
|
# HERMES_WIKI_TS=auto auto|yes|no — Tailscale serve aktivieren
|
||||||
|
# HERMES_WIKI_TS_PATH= leer=Root-Prefix (default), /wiki=unter-Pfad
|
||||||
|
# HERMES_WIKI_OWV_DIR=$HOME/repos/hermes-wiki-viewer (Lukas-Fork default; upstream obv als Fallback)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Defaults
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
readonly HERMES_WIKI_PORT="${HERMES_WIKI_PORT:-8765}"
|
||||||
|
readonly HERMES_WIKI_DIR="${HERMES_WIKI_DIR:-/home/admin/my-karpathy-wiki}"
|
||||||
|
readonly HERMES_WIKI_LOG_DIR="${HERMES_WIKI_LOG_DIR:-$HOME/.local/share/hermes-wiki/log}"
|
||||||
|
readonly HERMES_WIKI_TS="${HERMES_WIKI_TS:-auto}"
|
||||||
|
readonly HERMES_WIKI_TS_PATH="${HERMES_WIKI_TS_PATH:-}" # leer = Root-Prefix (obv nutzt relative URLs!)
|
||||||
|
readonly HERMES_WIKI_OWV_DIR="${HERMES_WIKI_OWV_DIR:-$HOME/repos/hermes-wiki-viewer}"
|
||||||
|
readonly HERMES_WIKI_REPO="${HERMES_WIKI_REPO:-}" # leer = lokales Lukas-Repo nutzen
|
||||||
|
readonly OWV_REPO_UPSTREAM="https://github.com/DanielCheer/obsidian-web-viewer.git"
|
||||||
|
readonly PID_FILE="/tmp/hermes-wiki-serve.pid"
|
||||||
|
readonly HEALTH_URL="http://127.0.0.1:${HERMES_WIKI_PORT}/api/vault/tree"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
log() {
|
||||||
|
printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" | tee -a "$HERMES_WIKI_LOG_DIR/server.log" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
die() {
|
||||||
|
log "ERROR: $*"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_dirs() {
|
||||||
|
mkdir -p "$HERMES_WIKI_LOG_DIR" || die "Kann Log-Dir nicht erstellen: $HERMES_WIKI_LOG_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_tools() {
|
||||||
|
command -v python3 >/dev/null || die "python3 nicht gefunden"
|
||||||
|
command -v git >/dev/null || die "git nicht gefunden"
|
||||||
|
[ -d "$HERMES_WIKI_DIR" ] || die "Wiki-Verzeichnis nicht gefunden: $HERMES_WIKI_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_tailscale() {
|
||||||
|
[ "$HERMES_WIKI_TS" != "no" ] || return 1
|
||||||
|
command -v tailscale >/dev/null || return 1
|
||||||
|
tailscale status --json >/dev/null 2>&1 || return 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Installation: Repo klonen oder updaten
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
install_repo() {
|
||||||
|
# Mode 1: Lokales Lukas-Repo (~/repos/hermes-wiki-viewer/) — default nach Fork
|
||||||
|
if [ -d "$HERMES_WIKI_OWV_DIR/.git" ] && [ -z "$HERMES_WIKI_REPO" ]; then
|
||||||
|
log "Nutze lokales Lukas-Repo: $HERMES_WIKI_OWV_DIR"
|
||||||
|
(cd "$HERMES_WIKI_OWV_DIR" && git pull --ff-only 2>>"$HERMES_WIKI_LOG_DIR/install.log") || \
|
||||||
|
log "WARN: git pull fehlgeschlagen — nutze Working-Tree-Stand"
|
||||||
|
# Mode 2: Remote Lukas-Repo klonen (z.B. wenn HERMES_WIKI_REPO gesetzt)
|
||||||
|
elif [ -n "$HERMES_WIKI_REPO" ]; then
|
||||||
|
if [ -d "$HERMES_WIKI_OWV_DIR/.git" ]; then
|
||||||
|
log "Update Lukas-Fork in $HERMES_WIKI_OWV_DIR"
|
||||||
|
(cd "$HERMES_WIKI_OWV_DIR" && git pull --ff-only 2>>"$HERMES_WIKI_LOG_DIR/install.log") || \
|
||||||
|
log "WARN: git pull fehlgeschlagen"
|
||||||
|
else
|
||||||
|
log "Klone Lukas-Fork $HERMES_WIKI_REPO nach $HERMES_WIKI_OWV_DIR"
|
||||||
|
git clone "$HERMES_WIKI_REPO" "$HERMES_WIKI_OWV_DIR" 2>>"$HERMES_WIKI_LOG_DIR/install.log" || \
|
||||||
|
die "git clone fehlgeschlagen — Repo-URL korrekt?"
|
||||||
|
fi
|
||||||
|
# Mode 3: Fallback — upstream obv direkt (vor Lukas-Fork)
|
||||||
|
else
|
||||||
|
log "Kein Lukas-Repo gefunden, klone upstream obv nach $HERMES_WIKI_OWV_DIR"
|
||||||
|
log "(Für Lukas-Customizations: HERMES_WIKI_OWV_DIR=/path/to/hermes-wiki-viewer setzen)"
|
||||||
|
git clone "$OWV_REPO_UPSTREAM" "$HERMES_WIKI_OWV_DIR" 2>>"$HERMES_WIKI_LOG_DIR/install.log" || \
|
||||||
|
die "git clone fehlgeschlagen"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# requirements.txt: nur PyYAML (optional). Stdlib reicht für unsere Zwecke.
|
||||||
|
if ! python3 -c "import yaml" 2>/dev/null; then
|
||||||
|
log "PyYAML nicht installiert (optional). Installiere..."
|
||||||
|
python3 -m pip install --user --quiet PyYAML 2>>"$HERMES_WIKI_LOG_DIR/install.log" || \
|
||||||
|
log "WARN: PyYAML-Installation fehlgeschlagen — Frontmatter wird rudimentär behandelt"
|
||||||
|
fi
|
||||||
|
log "Wiki-Viewer bereit in $HERMES_WIKI_OWV_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tailscale-Integration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
start_tailscale() {
|
||||||
|
detect_tailscale || {
|
||||||
|
[ "$HERMES_WIKI_TS" = "no" ] || \
|
||||||
|
log "Tailscale übersprungen (nicht verfügbar oder HERMES_WIKI_TS=$HERMES_WIKI_TS)"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if tailscale serve status --bg 2>/dev/null | grep -q "$HERMES_WIKI_PORT"; then
|
||||||
|
log "Tailscale serve bereits aktiv für Port $HERMES_WIKI_PORT"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Starte tailscale serve auf Root-Prefix -> 127.0.0.1:$HERMES_WIKI_PORT"
|
||||||
|
local ts_path_arg=()
|
||||||
|
if [ -n "$HERMES_WIKI_TS_PATH" ]; then
|
||||||
|
ts_path_arg=(--set-path="$HERMES_WIKI_TS_PATH")
|
||||||
|
log "(Tailscale wird unter /$HERMES_WIKI_TS_PATH/ exposen — Achtung: obv nutzt relative URLs!)"
|
||||||
|
fi
|
||||||
|
if tailscale serve --bg --https=443 "${ts_path_arg[@]}" \
|
||||||
|
"http://127.0.0.1:$HERMES_WIKI_PORT" 2>>"$HERMES_WIKI_LOG_DIR/tailscale.log"; then
|
||||||
|
local ts_url
|
||||||
|
ts_url=$(tailscale serve status --json 2>/dev/null | python3 -c "
|
||||||
|
import json, sys
|
||||||
|
try:
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
for entry in data.get('Web', {}).values():
|
||||||
|
print(f'https://{entry[\"HTTPS\"]}{entry[\"Path\"]}/')
|
||||||
|
break
|
||||||
|
except: pass
|
||||||
|
" 2>/dev/null)
|
||||||
|
log "Tailscale serve aktiv: ${ts_url:-URL konnte nicht ermittelt werden}"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log "WARN: tailscale serve fehlgeschlagen — siehe $HERMES_WIKI_LOG_DIR/tailscale.log"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_tailscale() {
|
||||||
|
detect_tailscale || return 0
|
||||||
|
if tailscale serve status --bg 2>/dev/null | grep -q "$HERMES_WIKI_PORT"; then
|
||||||
|
log "Stoppe tailscale serve"
|
||||||
|
tailscale serve --bg --https=443 --set-path="$HERMES_WIKI_TS_PATH" off \
|
||||||
|
"http://127.0.0.1:$HERMES_WIKI_PORT" 2>>"$HERMES_WIKI_LOG_DIR/tailscale.log" || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Server-Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
is_running() {
|
||||||
|
[ -f "$PID_FILE" ] || return 1
|
||||||
|
local pid
|
||||||
|
pid=$(cat "$PID_FILE" 2>/dev/null || echo "")
|
||||||
|
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
start_server() {
|
||||||
|
ensure_dirs
|
||||||
|
require_tools
|
||||||
|
install_repo
|
||||||
|
|
||||||
|
if is_running; then
|
||||||
|
log "Server läuft bereits (PID $(cat "$PID_FILE"))"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Port-Konflikt-Check
|
||||||
|
if ss -tln 2>/dev/null | grep -q ":${HERMES_WIKI_PORT} "; then
|
||||||
|
die "Port ${HERMES_WIKI_PORT} ist bereits belegt. HERMES_WIKI_PORT ändern oder Prozess beenden."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check, dass die Wiki-Page gerendert werden kann
|
||||||
|
local md_count
|
||||||
|
md_count=$(find "$HERMES_WIKI_DIR" -name "*.md" -not -path "*/raw/*" 2>/dev/null | wc -l)
|
||||||
|
log "Wiki-Dir: $HERMES_WIKI_DIR ($md_count .md-Dateien, ohne /raw/)"
|
||||||
|
|
||||||
|
# Server starten — Loopback only, NICHT 0.0.0.0
|
||||||
|
log "Starte obsidian-web-viewer server.py auf 127.0.0.1:$HERMES_WIKI_PORT"
|
||||||
|
nohup python3 "$HERMES_WIKI_OWV_DIR/server.py" \
|
||||||
|
--vault "$HERMES_WIKI_DIR" \
|
||||||
|
--host 127.0.0.1 \
|
||||||
|
--port "$HERMES_WIKI_PORT" \
|
||||||
|
> "$HERMES_WIKI_LOG_DIR/renderer.log" 2>&1 &
|
||||||
|
local pid=$!
|
||||||
|
echo "$pid" > "$PID_FILE"
|
||||||
|
|
||||||
|
# Health-Check (das Tool hat keine /__health, aber /api/vault/tree ist immer da)
|
||||||
|
local i
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
sleep 1
|
||||||
|
if curl -sf "$HEALTH_URL" >/dev/null 2>&1; then
|
||||||
|
log "Server gestartet (PID $pid), Health-Check OK"
|
||||||
|
start_tailscale
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
log "ERROR: Health-Check fehlgeschlagen nach 5s"
|
||||||
|
log "--- renderer.log ---"
|
||||||
|
tail -20 "$HERMES_WIKI_LOG_DIR/renderer.log" >&2
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_server() {
|
||||||
|
if is_running; then
|
||||||
|
local pid
|
||||||
|
pid=$(cat "$PID_FILE")
|
||||||
|
log "Stoppe Server (PID $pid)"
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
local i
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
kill -0 "$pid" 2>/dev/null || break
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
kill -9 "$pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
fi
|
||||||
|
# Tailscale NICHT stoppen — soll laufen bleiben für Re-Start ohne Tailscale-Re-Init
|
||||||
|
# stop_tailscale
|
||||||
|
log "(Tailscale serve bleibt aktiv für Re-Start)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status / Help
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
show_status() {
|
||||||
|
echo "=== Hermes Wiki Server Status (via obsidian-web-viewer) ==="
|
||||||
|
echo "Wiki-Dir: $HERMES_WIKI_DIR"
|
||||||
|
echo "Viewer-Repo: $HERMES_WIKI_OWV_DIR"
|
||||||
|
echo "Port: $HERMES_WIKI_PORT (Loopback only)"
|
||||||
|
echo "Log-Dir: $HERMES_WIKI_LOG_DIR"
|
||||||
|
|
||||||
|
if is_running; then
|
||||||
|
local pid
|
||||||
|
pid=$(cat "$PID_FILE")
|
||||||
|
echo "Status: RUNNING (PID $pid)"
|
||||||
|
ps -o pid,rss,etime,cmd -p "$pid" 2>/dev/null | tail -1 | \
|
||||||
|
awk '{printf " Memory: %.1f MB, Uptime: %s\n", $2/1024, $3}'
|
||||||
|
|
||||||
|
if curl -sf "$HEALTH_URL" >/dev/null 2>&1; then
|
||||||
|
local md_count
|
||||||
|
md_count=$(curl -sf "$HEALTH_URL" | python3 -c "
|
||||||
|
import json, sys
|
||||||
|
try:
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
count = sum(1 + len(c.get('children',[])) for c in [data])
|
||||||
|
# grobe zählung
|
||||||
|
print(len(json.dumps(data)))
|
||||||
|
except: print('?')
|
||||||
|
" 2>/dev/null || echo "?")
|
||||||
|
echo "Health: OK (Tree-API reachable, $md_count bytes)"
|
||||||
|
else
|
||||||
|
echo "Health: FAILED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if detect_tailscale; then
|
||||||
|
if tailscale serve status --bg 2>&1 | grep -q "$HERMES_WIKI_PORT"; then
|
||||||
|
local ts_url
|
||||||
|
ts_url=$(tailscale serve status --json 2>/dev/null | python3 -c "
|
||||||
|
import json, sys
|
||||||
|
try:
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
for entry in data.get('Web', {}).values():
|
||||||
|
print(f'https://{entry[\"HTTPS\"]}{entry[\"Path\"]}/')
|
||||||
|
break
|
||||||
|
except: pass
|
||||||
|
" 2>/dev/null)
|
||||||
|
echo "Tailscale: AKTIV — ${ts_url:-URL nicht extrahierbar}"
|
||||||
|
else
|
||||||
|
echo "Tailscale: nicht aktiv für Port $HERMES_WIKI_PORT (HERMES_WIKI_TS=$HERMES_WIKI_TS)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Tailscale: nicht verfügbar oder deaktiviert"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Status: NOT RUNNING"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
show_help() {
|
||||||
|
cat <<EOF
|
||||||
|
hermes-wiki-serve.sh — Hermes Wiki via obsidian-web-viewer + Tailscale
|
||||||
|
|
||||||
|
Dieses Skript wrapper't DanielCheer/obsidian-web-viewer (3D-Graph, File-Tree,
|
||||||
|
WikiLink-Navigation, Volltext-Suche, Catppuccin-Theme) und integriert Tailscale
|
||||||
|
serve für Tailnet-Zugriff.
|
||||||
|
|
||||||
|
Verwendung:
|
||||||
|
$0 start # Server starten (klont obv beim ersten Mal)
|
||||||
|
$0 stop # Server stoppen
|
||||||
|
$0 restart # stop + start
|
||||||
|
$0 status # Process-Status, Health-Check, Tailscale-URL
|
||||||
|
$0 logs # tail -f der Log-Files
|
||||||
|
$0 install # nur das Clonen/Update, kein Server-Start
|
||||||
|
$0 --help
|
||||||
|
|
||||||
|
Umgebungsvariablen:
|
||||||
|
HERMES_WIKI_PORT=8765
|
||||||
|
HERMES_WIKI_DIR=/home/admin/my-karpathy-wiki
|
||||||
|
HERMES_WIKI_LOG_DIR=\$HOME/.local/share/hermes-wiki/log
|
||||||
|
HERMES_WIKI_TS=auto auto|yes|no
|
||||||
|
HERMES_WIKI_TS_PATH=/wiki URL-Pfad unter dem Tailscale
|
||||||
|
HERMES_WIKI_OWV_DIR=\$HOME/.local/share/hermes-wiki/owv
|
||||||
|
|
||||||
|
Dependencies (einmalig):
|
||||||
|
- python3 (stdlib)
|
||||||
|
- git
|
||||||
|
- pip install PyYAML (optional, für sauberes Frontmatter)
|
||||||
|
- tailscale (für Tailnet-Zugriff)
|
||||||
|
|
||||||
|
Zugriff:
|
||||||
|
- Lokal: http://127.0.0.1:8765/
|
||||||
|
- Tailnet: https://<hostname>.<tailnet>.ts.net/wiki/
|
||||||
|
- API: http://127.0.0.1:8765/api/vault/{tree,graph,search,file/<path>}
|
||||||
|
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
case "${1:-}" in
|
||||||
|
start) start_server ;;
|
||||||
|
stop) stop_server ;;
|
||||||
|
restart) stop_server; start_server ;;
|
||||||
|
status) show_status ;;
|
||||||
|
logs) tail -f "$HERMES_WIKI_LOG_DIR"/*.log 2>/dev/null || die "Keine Logs gefunden" ;;
|
||||||
|
install) ensure_dirs; require_tools; install_repo ;;
|
||||||
|
--help|-h|help|"") show_help ;;
|
||||||
|
*) die "Unbekanntes Kommando: $1 (--help für Hilfe)" ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,715 @@
|
|||||||
|
/* Hermes Wiki — Touch-First CSS for Phase 2
|
||||||
|
*
|
||||||
|
* Design principles:
|
||||||
|
* - Bottom-Nav on mobile (3 tabs: Files, Search, Graph)
|
||||||
|
* - Hamburger-Tree always off-canvas on mobile, slide-in on click
|
||||||
|
* - Graph shown via FAB-style toggle → opens full-screen modal
|
||||||
|
* - Desktop: 3-panel layout (tree 280px, content flex, graph 320px)
|
||||||
|
* - Tablet (768-1023px): 2-panel (tree 220px, content flex), graph as bottom panel
|
||||||
|
* - Mobile (<768px): 1-panel (content only), tree/graph via toggles
|
||||||
|
* - Tap targets ≥ 44px, no accidental 300ms delay
|
||||||
|
* - Safe-area-insets for iOS Dynamic Island
|
||||||
|
* - Pull-to-refresh via overscroll-behavior: contain
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0a0a14;
|
||||||
|
--bg-secondary: #0e0e1a;
|
||||||
|
--bg-surface: #1a1a2e;
|
||||||
|
--bg-elevated: #252537;
|
||||||
|
--text-primary: #cdd6f4;
|
||||||
|
--text-secondary: #a6adc8;
|
||||||
|
--text-muted: #6c7086;
|
||||||
|
--accent: #FFD700;
|
||||||
|
--link: #FFD700;
|
||||||
|
--link-visited: #cba6f7;
|
||||||
|
--border: #313244;
|
||||||
|
--green: #a6e3a1;
|
||||||
|
--red: #f38ba8;
|
||||||
|
--blue: #89b4fa;
|
||||||
|
--teal: #94e2d5;
|
||||||
|
--sat: env(safe-area-inset-top, 0px);
|
||||||
|
--sab: env(safe-area-inset-bottom, 0px);
|
||||||
|
--sal: env(safe-area-inset-left, 0px);
|
||||||
|
--sar: env(safe-area-inset-right, 0px);
|
||||||
|
--tap-min: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
overscroll-behavior: contain; /* pull-to-refresh only where intended */
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
padding-top: var(--sat);
|
||||||
|
padding-left: var(--sal);
|
||||||
|
padding-right: var(--sar);
|
||||||
|
padding-bottom: var(--sab);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Layout: Desktop (≥1024px) — 3-panel
|
||||||
|
* ============================================================ */
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr 320px;
|
||||||
|
grid-template-rows: 48px 1fr;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
gap: 12px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 480px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px 0 36px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
#search:focus { border-color: var(--accent); }
|
||||||
|
#search::placeholder { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.search-wrap { position: relative; flex: 1; max-width: 480px; }
|
||||||
|
.search-wrap::before {
|
||||||
|
content: '🔍';
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 13px;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.icon-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
.app-tree {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-content {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 32px 48px 64px;
|
||||||
|
max-width: 100%;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
hyphens: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-graph {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 2;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-graph canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.app-graph canvas:active { cursor: grabbing; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Tree rendering
|
||||||
|
* ============================================================ */
|
||||||
|
.tree-root { padding: 4px 0; }
|
||||||
|
.tree-folder { margin: 1px 0; }
|
||||||
|
.tree-folder-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 12px;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 1px 4px;
|
||||||
|
min-height: var(--tap-min);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.tree-folder-header:hover { background: rgba(255, 215, 0, 0.05); }
|
||||||
|
.tree-folder-header.active { background: rgba(255, 215, 0, 0.08); color: var(--accent); }
|
||||||
|
.tree-folder-header .arrow {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
width: 12px;
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.tree-folder.open > .tree-folder-header .arrow { transform: rotate(90deg); }
|
||||||
|
.tree-children { padding-left: 12px; display: none; }
|
||||||
|
.tree-folder.open > .tree-children { display: block; }
|
||||||
|
|
||||||
|
.tree-file {
|
||||||
|
display: block;
|
||||||
|
padding: 6px 12px 6px 24px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 1px 4px;
|
||||||
|
min-height: var(--tap-min);
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.tree-file:hover { background: rgba(255, 215, 0, 0.05); color: var(--accent); }
|
||||||
|
.tree-file.current {
|
||||||
|
background: rgba(255, 215, 0, 0.12);
|
||||||
|
color: var(--accent);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
padding-left: 21px;
|
||||||
|
}
|
||||||
|
.tree-file-icon { margin-right: 6px; opacity: 0.5; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Note content
|
||||||
|
* ============================================================ */
|
||||||
|
.note { max-width: 920px; margin: 0 auto; }
|
||||||
|
.meta-card {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 12px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 6px 16px;
|
||||||
|
}
|
||||||
|
.meta-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.meta-key {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
min-width: 70px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.meta-value {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: ui-monospace, 'SF Mono', Monaco, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-body {
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.note-body h1 { font-size: 2em; margin: 0.6em 0 0.4em; padding-bottom: 8px; border-bottom: 1px solid var(--border); color: var(--text-primary); }
|
||||||
|
.note-body h2 { font-size: 1.5em; margin: 1.5em 0 0.5em; color: var(--text-primary); border-bottom: 1px solid rgba(49,50,68,0.5); padding-bottom: 4px; }
|
||||||
|
.note-body h3 { font-size: 1.25em; margin: 1.2em 0 0.4em; color: var(--text-primary); }
|
||||||
|
.note-body h4 { font-size: 1.1em; margin: 1em 0 0.4em; color: var(--text-secondary); }
|
||||||
|
.note-body p { margin: 0.8em 0; }
|
||||||
|
.note-body ul, .note-body ol { margin: 0.5em 0; padding-left: 1.8em; }
|
||||||
|
.note-body li { margin: 0.3em 0; }
|
||||||
|
.note-body blockquote {
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
margin: 1em 0;
|
||||||
|
background: rgba(255, 215, 0, 0.04);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.note-body code {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: ui-monospace, 'SF Mono', Monaco, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--teal);
|
||||||
|
}
|
||||||
|
.note-body pre {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 1em 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.note-body pre code {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
.note-body table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1em 0;
|
||||||
|
font-size: 14px;
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.note-body th {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.note-body td {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.note-body tr:nth-child(even) { background: rgba(30, 30, 50, 0.3); }
|
||||||
|
.note-body a {
|
||||||
|
color: var(--link);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid rgba(255, 215, 0, 0.3);
|
||||||
|
}
|
||||||
|
.note-body a:hover { border-bottom-color: var(--accent); }
|
||||||
|
.note-body a:visited { color: var(--link-visited); }
|
||||||
|
.note-body .wikilink {
|
||||||
|
color: var(--blue);
|
||||||
|
border-bottom: 1px dashed var(--blue);
|
||||||
|
}
|
||||||
|
.note-body .wikilink:hover { color: var(--accent); border-bottom-color: var(--accent); }
|
||||||
|
.note-body .wikilink-missing {
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 1px dotted var(--text-muted);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
.note-body img { max-width: 100%; height: auto; border-radius: 6px; margin: 1em 0; }
|
||||||
|
.note-body hr { border: none; border-top: 1px solid var(--border); margin: 2em 0; }
|
||||||
|
.note-body .headerlink { color: var(--text-muted); margin-left: 8px; font-size: 0.7em; text-decoration: none; opacity: 0; transition: opacity 0.15s; }
|
||||||
|
.note-body h1:hover .headerlink, .note-body h2:hover .headerlink, .note-body h3:hover .headerlink { opacity: 1; }
|
||||||
|
|
||||||
|
/* TOC sidebar (in note footer area) */
|
||||||
|
.note-toc {
|
||||||
|
margin-top: 48px;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.note-toc-title {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.note-toc ul { list-style: none; padding-left: 0; }
|
||||||
|
.note-toc li { margin: 3px 0; }
|
||||||
|
.note-toc a {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.note-toc a:hover { color: var(--accent); }
|
||||||
|
.note-toc ul ul { padding-left: 16px; }
|
||||||
|
|
||||||
|
/* Backlinks section */
|
||||||
|
.note-backlinks {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.note-backlinks-title {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.note-backlinks ul { list-style: none; padding-left: 0; }
|
||||||
|
.note-backlinks li { margin: 4px 0; }
|
||||||
|
.note-backlinks a { color: var(--blue); border-bottom: 1px dashed var(--blue); }
|
||||||
|
|
||||||
|
.note-footer {
|
||||||
|
margin-top: 32px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: ui-monospace, 'SF Mono', Monaco, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Search dropdown
|
||||||
|
* ============================================================ */
|
||||||
|
.search-results {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: none;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
.search-results.open { display: block; }
|
||||||
|
.search-result {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid rgba(49, 50, 68, 0.3);
|
||||||
|
min-height: var(--tap-min);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.search-result:hover, .search-result.active {
|
||||||
|
background: rgba(255, 215, 0, 0.08);
|
||||||
|
}
|
||||||
|
.search-result-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.search-result-path {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
.search-result-tag {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.search-empty {
|
||||||
|
padding: 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Graph modal (full-screen on mobile)
|
||||||
|
* ============================================================ */
|
||||||
|
.graph-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
z-index: 200;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-top: var(--sat);
|
||||||
|
padding-left: var(--sal);
|
||||||
|
padding-right: var(--sar);
|
||||||
|
padding-bottom: var(--sab);
|
||||||
|
}
|
||||||
|
.graph-modal.open { display: flex; }
|
||||||
|
.graph-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
height: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.graph-modal-title {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.graph-modal-close {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.graph-modal-close:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.graph-modal-canvas {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: none; /* critical: prevent iOS Safari scroll-hijacking */
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
min-height: 0; /* flex-child needs explicit min-height for sizing */
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.graph-modal-canvas canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none; /* critical: same on canvas itself */
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.graph-modal-canvas canvas:active { cursor: grabbing; }
|
||||||
|
|
||||||
|
/* Modal body must not scroll on background */
|
||||||
|
.graph-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
z-index: 200;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-top: var(--sat);
|
||||||
|
padding-left: var(--sal);
|
||||||
|
padding-right: var(--sar);
|
||||||
|
padding-bottom: var(--sab);
|
||||||
|
touch-action: none;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
.graph-modal.open { display: flex; }
|
||||||
|
|
||||||
|
/* Graph legend (small floating panel) */
|
||||||
|
.graph-legend {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 12px;
|
||||||
|
background: rgba(14, 14, 26, 0.92);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.graph-legend-title {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.graph-legend-row { display: flex; align-items: center; gap: 6px; margin: 2px 0; }
|
||||||
|
.graph-legend-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Bottom navigation (mobile only)
|
||||||
|
* ============================================================ */
|
||||||
|
.app-bottom-nav {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
z-index: 150;
|
||||||
|
padding-bottom: var(--sab);
|
||||||
|
}
|
||||||
|
.app-bottom-nav-inner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
.app-bottom-nav button {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: var(--tap-min);
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.app-bottom-nav button:hover, .app-bottom-nav button.active { color: var(--accent); }
|
||||||
|
.app-bottom-nav button span.icon { font-size: 18px; line-height: 1; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Tablet (768px-1023px) — 2-panel + bottom graph
|
||||||
|
* ============================================================ */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.app-shell {
|
||||||
|
grid-template-columns: 220px 1fr;
|
||||||
|
grid-template-rows: 48px 1fr 240px;
|
||||||
|
}
|
||||||
|
.app-tree {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2 / 4;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.app-content {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
padding: 24px 32px 32px;
|
||||||
|
}
|
||||||
|
.app-graph {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 3;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
height: 240px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Mobile (<768px) — single column + bottom-nav
|
||||||
|
* ============================================================ */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.app-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 48px 1fr;
|
||||||
|
}
|
||||||
|
.app-header { padding: 0 12px; gap: 8px; }
|
||||||
|
.app-header .icon-btn[data-action="toggle-tree"],
|
||||||
|
.app-header .icon-btn[data-action="toggle-graph"] {
|
||||||
|
display: none; /* hide redundant buttons, use bottom-nav */
|
||||||
|
}
|
||||||
|
.search-wrap { max-width: none; }
|
||||||
|
|
||||||
|
.app-tree {
|
||||||
|
position: fixed;
|
||||||
|
top: 48px;
|
||||||
|
top: calc(48px + var(--sat));
|
||||||
|
left: 0;
|
||||||
|
bottom: 56px;
|
||||||
|
bottom: calc(56px + var(--sab));
|
||||||
|
width: 85vw;
|
||||||
|
max-width: 320px;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.25s ease-in-out;
|
||||||
|
z-index: 250;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.app-tree.open { transform: translateX(0); }
|
||||||
|
|
||||||
|
.app-content {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
padding: 16px 20px 72px; /* bottom-nav space + safe area */
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-graph { display: none; }
|
||||||
|
|
||||||
|
.app-bottom-nav { display: block; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Backdrop for mobile tree/graph overlay */
|
||||||
|
.backdrop {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 48px;
|
||||||
|
top: calc(48px + var(--sat));
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 56px;
|
||||||
|
bottom: calc(56px + var(--sab));
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
.backdrop.open { display: block; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Inline highlight for hash anchors
|
||||||
|
* ============================================================ */
|
||||||
|
:target { scroll-margin-top: 60px; }
|
||||||
|
:target h1, :target h2, :target h3 {
|
||||||
|
background: rgba(255, 215, 0, 0.08);
|
||||||
|
transition: background 0.6s ease-out;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user