Compare commits

..

37 Commits

Author SHA1 Message Date
luxick f78ebe7d5d Remove auto new tab for external links 2026-08-05 18:26:09 +02:00
luxick d943fc5596 Update CLAUDE.md 2026-08-05 18:25:52 +02:00
luxick e9061a221c Hide console on windows
The companion does no longer show a terminal window pop up
2026-08-03 19:29:27 +02:00
luxick 5a5305dd42 Update CLAUDE.md 2026-08-03 19:28:03 +02:00
luxick ab57377034 Fix toolbar on mobile. 2026-07-24 16:14:20 +02:00
luxick 65983ec6be Missing changes 2026-07-24 16:10:44 +02:00
luxick 52bc694ffa Allow image embedding 2026-07-24 16:10:29 +02:00
luxick e37dde1e40 preserve scroll position when entering page editor 2026-07-24 15:24:40 +02:00
luxick 3abcc94eca Improve desktop editing experience 2026-07-24 15:16:02 +02:00
luxick 8b9f47f793 Adjust saving button 2026-07-24 12:10:16 +02:00
luxick 85527cfde8 Improve table editing 2026-07-24 11:38:15 +02:00
luxick 5f835a8a4e Omit Files section when no files are present 2026-07-24 11:26:23 +02:00
luxick 1071dbaa09 "Today" link no longer goes directly to editor 2026-07-24 11:21:40 +02:00
luxick da30bb8fc3 Fix back-to-cancel behavior of editor 2026-07-24 10:55:29 +02:00
luxick a849474b26 Remove obsolete parts from overlay 2026-07-23 18:06:12 +02:00
luxick 49c6240416 Remove TODO.txt integration 2026-07-23 17:38:13 +02:00
luxick ddfd29ea0e Prevent page deletion on save 2026-07-21 17:26:31 +02:00
luxick 3f2beafd94 Adjust header spacing 2026-07-21 17:26:15 +02:00
luxick 28d22c040f Do not consider edit mode as a history entry 2026-07-20 17:31:59 +02:00
luxick 34f750beff Exact page matches in search 2026-07-20 09:08:35 +02:00
luxick 5303645173 Improve search result page 2026-07-20 08:54:12 +02:00
luxick d0325fdec5 Center align link favicons 2026-07-05 18:13:22 +02:00
luxick 22059a0b06 Update todo buttons 2026-07-05 18:10:22 +02:00
luxick aa95d04d9e Add todo to the Readme 2026-07-03 12:28:19 +02:00
luxick 08be8c68fe Add quick actions toolbar for todo 2026-07-03 12:24:50 +02:00
luxick a407a2eeaa Hanging indent for todos 2026-07-03 12:11:30 +02:00
luxick 75d6c4d430 Auto refresh todo editor. 2026-07-03 12:02:16 +02:00
luxick 5e72b073b8 todo.txt integration 2026-07-03 11:08:27 +02:00
luxick 8c9b448bdc Render all months in sidebar 2026-07-01 11:00:41 +02:00
luxick ecaf3f4e12 Clean up CSS 2026-07-01 10:09:14 +02:00
luxick 1055c110f4 Rework app-shell layout 2026-07-01 09:59:32 +02:00
luxick 5d069683c4 Enable Hardwraps 2026-06-25 14:23:15 +02:00
luxick 9f6a611dff Video thumbnail generation 2026-06-24 08:36:03 +02:00
luxick fa22244709 Use companion for diary photos 2026-06-22 17:25:11 +02:00
luxick d92c2f008d No hover highlighting on mobile 2026-06-16 18:27:42 +02:00
luxick 7346050b51 Tree styling update
More dense display
2026-06-16 18:05:48 +02:00
luxick 1619240905 Fix missing label 2026-06-16 17:49:34 +02:00
38 changed files with 1667 additions and 608 deletions
+3 -126
View File
@@ -1,130 +1,7 @@
# CLAUDE.md
## Project Overview
I want to understand every line of code that goes into this project. Never create, edit, move, rename, or delete project files unless I explicitly ask you to do so. Instead, show me every proposed edit in the chat so I can type it in manually.
`datascape` is a minimal personal wiki where **the folder structure is the wiki**.
No database, no CMS, no abstraction layer — every folder is a page, and `index.md`
in a folder is that page's content.
Do not run commands that modify project files, install dependencies, or change repository state unless I explicitly request that action. Instead, show me those commands in the chat so I can run them manually.
## Build & Deploy
```bash
# Local build (host architecture)
go build .
# Deploy to NAS
make deploy
```
### Editor bundle (the one build-pipeline exception)
The page editor uses CodeMirror 6, vendored as a single pre-built IIFE at
`assets/editor/vendor/codemirror.bundle.js` and embedded via `embed.FS`. This is
the **only** deliberate exception to the "no build pipeline" rule below — it is a
one-time, committed artifact, not a runtime build. `go build` / `make deploy`
never touch Node and only consume the committed bundle.
Regenerate the bundle **only** when upgrading the `@codemirror/*` versions:
```bash
# bump versions in editor-build/package.json first, then:
make editor # runs `npm ci && npm run build` in editor-build/, rewrites the vendored bundle
```
Commit the regenerated `codemirror.bundle.js` and the updated
`editor-build/package-lock.json`. `editor-build/node_modules/` is gitignored.
The bundle is served immutable under a stable filename, so the edit template
appends `?v=<content-hash>` to its `<script>` src (`editorBundleVersion` in
`main.go`). The hash changes whenever the bundle bytes change, so a rebuilt
bundle busts client caches automatically — no manual version bump needed.
## HTTP API Surface
| Method | Path | Behaviour |
|--------|------|-----------|
| GET | `/{path}/` | If folder exists: render `index.md` + list contents. If not: show empty create prompt. |
| GET | `/{path}/?edit` | CodeMirror 6 editor initialized with `index.md` content |
| POST | `/{path}` | Write `index.md` to disk; creates the folder if it does not exist yet |
Non-existent paths without a trailing slash redirect to the slash form (GET only — POSTs
are not redirected because `path.Clean` strips the trailing slash from `PostURL` and the
content would be lost).
Do not add new endpoints without a concrete stated need.
## Code Structure
When adding a new special folder type, create a new `.go` file. Do not add type-specific logic to `main.go` or `render.go`.
Prefer separate, human-readable `.html` files over inlined HTML strings in Go. Embed them via `embed.FS` if needed.
## Architecture Rules
- **Single binary** — no installer, no runtime dependencies, no Docker
- **Go stdlib `net/http`** only — no web framework
- **`goldmark`** for Markdown rendering — no other Markdown libraries
- **`embed.FS`** for all assets — no external serving, no CDN
- **No database** of any kind
- **No indexing or caching** unless explicitly requested and justified
- Keep dependencies to an absolute minimum; if stdlib can do it, use stdlib
## Frontend Rules
- Vanilla JS only — no frameworks, no build pipeline (the single exception is the vendored CodeMirror editor bundle; see Build & Deploy)
- Each feature gets its own JS file; global behaviour goes in `global-shortcuts.js`
- Do not inline JS in templates or merge unrelated features into one file
- `ALT+SHIFT` is the modifier for all keyboard shortcuts — do not introduce others
- Editor toolbar buttons use `data-action` + `data-key`; adding `data-key` auto-registers the shortcut
- For mutating modals (anything that POSTs and then navigates), call `closeModal()` and then `postReplace(action, body, target)` from `page/actions.js`. Do NOT use `<form>.submit()`. Two reasons:
1. The modal must be removed from the DOM before navigation, or the browser's bfcache snapshots it open and back-nav restores the modal.
2. `postReplace` uses `window.location.replace` so the action + result occupy a single history entry. A naive POST → 303 → GET creates two entries, and back-nav lands on a stale pre-mutation snapshot of the same page.
## CSS
Follow **SMACSS** conventions (Scalable and Modular Architecture for CSS). The stylesheet is organized into five categories:
- **Base** — element resets and global defaults only. Never style `header`, `textarea`, `input`, `aside`, `footer`, etc. directly for visual treatment — always via a class.
- **Layout** — `.row`, `.col`, `.page-wrap`. Use these for flex layout; do not inline `display: flex` on feature classes.
- **Modules** — reusable components: `.panel`, `.panel-header`, `.menu-row`, `.btn`, `.input`, `.muted`, `.truncate`, etc. New visual patterns should reuse these. Before adding a new module, check whether an existing one + a modifier already covers the case.
- **State** — `.is-*` prefix only (`.is-open`, `.is-selected`, `.is-active`, `.is-disabled`, `.is-empty`). State is the only place a class describes a moment in time rather than a structural role.
- **Theme** — colors, borders, spacing, and font sizes come from CSS variables defined in `:root` (`--bg`, `--secondary`, `--border`, `--border-dashed`, `--space-*`, `--font-*`). No hardcoded `1px solid #...`, no hardcoded rem spacing in component rules.
Naming: flat-dash (`.panel-header`, `.btn-small`), not BEM (`.panel__header--small`). Modifiers attach as additional classes (`<div class="btn btn-small">`), not as new standalone classes.
Anti-patterns to reject:
- One-off classes that duplicate an existing module (`.save-button` when `.btn` exists, `.form-name-input` when `.input` exists).
- Element selectors (`textarea { ... }`, `header { ... }`) for visual treatment — add a class instead.
- Inlining `display: flex; gap: X` on a feature class instead of composing with `.row` / `.col`.
- Adding a new module for a single use site — prefer a modifier on an existing module first.
- Hardcoded colors, border widths, or spacing values inside component rules — pull a variable, or add one to `:root` if it's missing.
## Development Priorities
When building features, apply this order:
1. Correctness on the filesystem — never corrupt or lose files
2. Mobile usability (primary editing device is Android over Wireguard VPN)
3. Simplicity of implementation, adhere to KISS
4. Performance
## Date Formatting
- General UI dates (file listings, metadata): ISO `YYYY-MM-DD`
- Diary headings (year/month/day) are also ISO short form: `# 2026`, `## 2026-05`, `### 2026-05-28`. No long-form rendering.
- Calendar widget month names are German; the `germanMonths` map in `diary.go` keeps the labels keyed by `time.Month` since Go's `time.Format` is English-only.
## What to Avoid
- Any parallel folder structure (e.g. a separate `media/` tree mirroring `pages/`)
- Over-engineering auth — Basic auth is sufficient for a personal VPN tool
- Heavy payloads or expensive rendering (target CPU: ARMv7 32-bit NAS)
- Suggesting Docker (plain binary is preferred)
## Out of Scope (do not implement unless explicitly asked)
- Full-text search
- Browser-based file upload
- Version history / git integration
- Multi-user support
- Tagging or metadata beyond `index.md` content
I'm an experienced developer. Do not explain syntax, APIs, programming concepts, or implementation details unless explicitly asked.
+25 -3
View File
@@ -116,11 +116,16 @@
if (!state.available) return;
document.addEventListener('click', function (e) {
if (!e.target.closest) return;
// Match both listing styles: table rows expose the file link inside
// a .list-item row; thumbnail tiles are bare a.thumb-tile anchors.
// Match every listing style: table rows expose the file link inside
// a .list-item row; thumbnail tiles are bare a.thumb-tile anchors;
// diary photo grids wrap each thumbnail in a .photo-grid anchor.
// The left tree rail's file anchors (a.tree-file) join in too;
// folder anchors there end in "/" and fall through to navigation.
var anchor = e.target.closest('.list-item a, a.thumb-tile, aside.tree-sidebar a.tree-file');
// Search file-result anchors join in too: page results end in "/"
// and fall through to navigation, file results don't and open locally.
// Embedded images (a.embed-link inside rendered content) wrap the
// raw file href, so they open the full-size original locally too.
var anchor = e.target.closest('.list-item a, a.thumb-tile, .photo-grid a, aside.tree-sidebar a.tree-file, .search-card a, .content a.embed-link');
if (!anchor) return;
var item = anchor.closest('.list-item');
// Only intercept the primary file link, and only for files (not folders).
@@ -152,6 +157,22 @@
});
}
function wireFileRevealButtons() {
// Per-result "open location" buttons on the search page. Unlike the
// page reveal button (which reveals window.location), each carries its
// own file path so it reveals that specific file in its folder.
if (!state.available) return;
var btns = document.querySelectorAll('[data-companion-file-reveal]');
btns.forEach(function (btn) {
btn.hidden = false;
btn.addEventListener('click', function () {
var rel = btn.getAttribute('data-companion-file-reveal');
if (!rel) return;
companionGET('/open-folder', { path: rel }).catch(function () { });
});
});
}
function probeStatus() {
return companionGET('/status').then(function (r) {
if (!r.ok) throw new Error('status ' + r.status);
@@ -170,6 +191,7 @@
updateFooterIcon();
wireFileLinks();
wireRevealButton();
wireFileRevealButtons();
});
}
+8 -13
View File
@@ -1,29 +1,24 @@
<div class="diary-cal panel panel-sidebar"
data-display-year="{{.DisplayYear}}"
data-display-month="{{.DisplayMonth}}">
<div class="panel-header"><a href="{{.DiaryURL}}">Chronological</a></div>
<div class="diary-cal-nav">
<div class="panel-header row">
<a href="{{.DiaryURL}}">Chronological</a>
<div class="dropdown diary-cal-drop">
<button type="button" class="btn" data-cal-month-link data-action="cal-month-drop" aria-expanded="false" title="Monat wählen"> {{.DisplayMonthName}} </button>
<div class="dropdown-menu scrollable">
{{range .Months}}<a class="btn btn-block" data-cal-month-jump="{{.Num}}" href="{{.AnchorURL}}">{{.Name}}</a>{{end}}
</div>
</div>
<a href="{{.YearURL}}" class="diary-cal-heading">{{.DisplayYear}}</a>
<div class="dropdown diary-cal-drop">
<button type="button" class="btn" data-action="cal-year-drop" aria-expanded="false" title="Jahr wählen"></button>
<button type="button" class="btn" data-action="cal-year-drop" aria-expanded="false" title="Jahr wählen">{{.DisplayYear}} </button>
<div class="dropdown-menu align-right scrollable">
{{range .Years}}<a class="btn btn-block{{if .IsCurrent}} cal-current{{end}}" href="{{.URL}}">{{.Num}}</a>{{end}}
</div>
</div>
</div>
{{range .Months}}
<table class="diary-cal-grid" data-cal-month="{{.Num}}"{{if ne .Num $.DisplayMonth}} hidden{{end}}>
{{range $i, $m := .Months}}
{{if $i}}<hr/>{{end}}
<div class="diary-cal-month"><a href="{{$m.AnchorURL}}">{{$m.Name}}</a></div>
<table class="diary-cal-grid" data-cal-month="{{$m.Num}}">
<thead>
<tr><th>Mo</th><th>Di</th><th>Mi</th><th>Do</th><th>Fr</th><th>Sa</th><th>So</th></tr>
</thead>
<tbody>
{{range .Weeks}}<tr>{{range .}}<td class="{{if .IsCurrent}}cal-current{{else if .IsToday}}cal-today{{end}}{{if and .Num (not .HasEntry)}} cal-empty{{end}}">{{if .Num}}<a href="{{.URL}}">{{.Num}}</a>{{end}}</td>{{end}}</tr>
{{range $m.Weeks}}<tr>{{range .}}<td class="{{if .IsCurrent}}cal-current{{else if .IsToday}}cal-today{{end}}{{if and .Num (not .HasEntry)}} cal-empty{{end}}">{{if .Num}}<a href="{{.URL}}">{{.Num}}</a>{{end}}</td>{{end}}</tr>
{{end}}
</tbody>
</table>
+30 -34
View File
@@ -3,47 +3,43 @@
if (!cal) return;
cal.querySelectorAll(".dropdown > button").forEach(wireDropdown);
var displayYear = parseInt(cal.dataset.displayYear, 10);
var current = parseInt(cal.dataset.displayMonth, 10);
var monthLabel = cal.querySelector("[data-cal-month-link]");
var displayMonth = parseInt(cal.dataset.displayMonth, 10);
var months = {};
cal.querySelectorAll("[data-cal-month]").forEach(function (t) {
months[parseInt(t.dataset.calMonth, 10)] = t;
});
var jumpLinks = {};
cal.querySelectorAll("[data-cal-month-jump]").forEach(function (a) {
jumpLinks[parseInt(a.dataset.calMonthJump, 10)] = a;
});
function show(m) {
if (m === current) return;
if (!months[m]) return;
months[current].hidden = true;
months[m].hidden = false;
current = m;
if (monthLabel) {
var label = jumpLinks[m];
if (label) monthLabel.textContent = " " + label.textContent + " ";
// Centering only applies inside the persistent desktop rail (.sidebar). On
// mobile the widget is re-parented into .overlay-body, where we leave the
// grids stacked from January and do not scroll (design decision).
function railContainer() {
var el = cal.parentElement;
while (el) {
if (el.classList) {
if (el.classList.contains("overlay-body")) return null;
if (el.classList.contains("sidebar")) return el;
}
el = el.parentElement;
}
return null;
}
// Dropdown month picks scroll via the href anchor; we also swap the grid
// so the calendar reflects what the user just navigated to.
Object.keys(jumpLinks).forEach(function (key) {
var a = jumpLinks[key];
a.addEventListener("click", function () { show(parseInt(key, 10)); });
});
// Any in-page anchor click (#YYYY-MM or #YYYY-MM-DD) updates the calendar
// so it tracks the user's focus through the year page.
function syncFromHash() {
var h = window.location.hash;
if (!h) return;
var m = h.match(/^#(\d{4})-(\d{2})(?:-\d{2})?$/);
if (!m) return;
if (parseInt(m[1], 10) !== displayYear) return;
show(parseInt(m[2], 10));
// Scroll only the rail so the given month grid sits in its vertical center;
// the main document/window position is untouched (priority: don't disturb
// the reading position).
function centerMonth(m) {
var grid = months[m];
if (!grid) return;
var container = railContainer();
if (!container) return;
var offset = grid.getBoundingClientRect().top -
container.getBoundingClientRect().top + container.scrollTop;
container.scrollTop = offset - (container.clientHeight - grid.clientHeight) / 2;
}
window.addEventListener("hashchange", syncFromHash);
syncFromHash();
// Center the current month once, on initial render. Anchor navigation
// (#YYYY-MM / #YYYY-MM-DD) deliberately does NOT re-center the rail —
// re-scrolling on every hop is disorienting; the today/current cell
// highlighting is enough to keep bearings.
centerMonth(displayMonth);
})();
+1 -1
View File
@@ -6,7 +6,7 @@
{{if .Photos}}
<div class="photo-grid">
{{range .Photos}}
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
<a href="{{.URL}}"><img src="{{.ThumbURL}}" alt="" loading="lazy"></a>
{{end}}
</div>
{{end}}
+1 -1
View File
@@ -1,6 +1,6 @@
{{define "headerActions"}}
<a class="btn" href="{{.PostURL}}">CANCEL</a>
<button class="btn" type="submit" form="edit-form" data-action="save" data-key="S" title="Save (S)">SAVE</button>
<button class="btn" type="button" data-action="save" data-key="S" title="Save (S)">SAVE</button>
{{end}}
{{define "content"}}
+65 -1
View File
@@ -23,13 +23,38 @@
};
}
// Enter/Tab cell navigation must yield to an open autocomplete popup so those
// keys still accept a suggestion (e.g. a [[wiki-link]] typed inside a cell).
// The table keymap sits at Prec.highest, so without this guard it would
// swallow the key before the completion keymap ever sees it. Detect the popup
// via its tooltip element rather than exporting completionStatus from the
// vendored bundle (which would force a bundle rebuild).
function completionOpen() {
return !!document.querySelector('.cm-tooltip-autocomplete');
}
function tableNavKey(fn) {
var handler = tableKey(fn);
return function (view) {
if (completionOpen()) return false;
return handler(view);
};
}
var tableKeymap = [
{ key: 'Shift-Enter', run: tableKey(T.insertRowBelow) },
{ key: 'Shift-Delete', run: tableKey(T.deleteRow) },
{ key: 'Enter', run: tableNavKey(T.nextRowSameColumn) },
{ key: 'Tab', run: tableNavKey(T.nextCell) },
{ key: 'Shift-Tab', run: tableNavKey(T.prevCell) },
];
var state = CM.EditorState.create({
doc: hidden.value,
// Start with the caret at the end of the existing content — editing an
// existing page almost always means appending, and the default (start
// of doc) buries the caret behind the leading heading.
selection: { anchor: hidden.value.length },
extensions: [
CM.history(),
CM.drawSelection(),
@@ -140,7 +165,46 @@
function syncContent() {
hidden.value = view.state.doc.toString();
}
form.addEventListener('submit', syncContent);
// Save POSTs via fetch and then rewrites the *editor's* history entry with
// the resulting page, so the edit session and its result share a single
// entry (see history-nav.js). A plain form submit would push a second one
// and leave the editor sitting in history behind the saved page.
//
// The server answers 204 + X-Target instead of a 303 because the target may
// carry a #section anchor the client cannot compute, and fetch drops the
// fragment from a followed redirect.
function postSave() {
syncContent();
var body = new URLSearchParams(new FormData(form)).toString();
fetch(form.action, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Save-Mode': 'replace',
},
body: body,
}).then(function (res) {
if (!res.ok) {
return res.text().then(function (msg) {
alert(msg || ('Save failed (' + res.status + ')'));
});
}
var target = res.headers.get('X-Target') || form.action;
// replaceState + reload rather than location.replace: if target
// differs from the current URL only by fragment the browser would
// skip the re-fetch and show pre-save content.
window.history.replaceState(null, '', target);
window.location.reload();
}).catch(function () {
alert('Network error — the page was not saved');
});
}
form.addEventListener('submit', function (e) {
e.preventDefault();
postSave();
});
// --- Actions ---
+86
View File
@@ -262,8 +262,94 @@ window.EditorTables = (function () {
return formatTableText(newLines.join('\n'), Math.min(newCursor, newLines.join('\n').length));
}
// Absolute cursor offset at the start of the `col`-th cell's content on
// `lineIdx` of a *formatted* table (i.e. just past the "| " / " | " that
// opens the cell). Clamps to the last cell if col overflows the row.
function cellCursor(text, lineIdx, col) {
var lines = text.split('\n');
var offset = 0;
for (var i = 0; i < lineIdx; i++) offset += lines[i].length + 1;
var line = lines[lineIdx] || '';
var pipes = [];
for (var c = 0; c < line.length; c++) if (line.charAt(c) === '|') pipes.push(c);
if (pipes.length === 0) return Math.min(offset, text.length);
var p = pipes[Math.min(col, pipes.length - 1)];
return Math.min(offset + p + 2, text.length);
}
// Shared engine for the Enter / Tab / Shift-Tab cell navigation. Reformats
// the table and drops the cursor into the target cell, appending an empty
// row when navigation runs past the last row. Returns null when the cursor
// is not in a table (so the caller can fall back to the editor default).
function moveInTable(text, cursorPos, mode) {
var range = findTableRange(text, cursorPos);
if (!range) return null;
var colIdx = getCursorColumn(text, cursorPos);
if (colIdx === null) return null;
var sepRel = -1, colCount = 0;
for (var i = range.start; i <= range.end; i++) {
var cells = parseTableRow(range.lines[i]);
if (cells.length > colCount) colCount = cells.length;
if (sepRel === -1 && isSeparatorRow(cells)) sepRel = i - range.start;
}
if (sepRel === -1) return null;
var lastRel = range.end - range.start;
var rowRel = range.cursorLine - range.start;
var targetRow = rowRel, targetCol = colIdx;
if (mode === 'enter') {
targetRow = rowRel + 1;
if (targetRow === sepRel) targetRow++;
} else if (mode === 'tab') {
targetCol = colIdx + 1;
if (targetCol >= colCount) {
targetCol = 0;
targetRow = rowRel + 1;
if (targetRow === sepRel) targetRow++;
}
} else if (mode === 'shifttab') {
targetCol = colIdx - 1;
if (targetCol < 0) {
targetCol = colCount - 1;
targetRow = rowRel - 1;
if (targetRow === sepRel) targetRow--;
}
if (targetRow < 0) return null;
}
var lines = range.lines.slice();
if (targetRow > lastRel) {
var emptyCells = [];
for (var c = 0; c < colCount; c++) emptyCells.push('');
var emptyLine = '| ' + emptyCells.join(' | ') + ' |';
while (lastRel < targetRow) {
lines.splice(range.start + lastRel + 1, 0, emptyLine);
lastRel++;
}
}
var tableStartOffset = 0;
for (var i = 0; i < range.start; i++) tableStartOffset += lines[i].length + 1;
var formatted = formatTableText(lines.join('\n'), tableStartOffset);
if (!formatted) return null;
return {
text: formatted.text,
cursor: cellCursor(formatted.text, range.start + targetRow, targetCol),
};
}
function nextRowSameColumn(text, cursorPos) { return moveInTable(text, cursorPos, 'enter'); }
function nextCell(text, cursorPos) { return moveInTable(text, cursorPos, 'tab'); }
function prevCell(text, cursorPos) { return moveInTable(text, cursorPos, 'shifttab'); }
return {
formatTableText: formatTableText,
nextRowSameColumn: nextRowSameColumn,
nextCell: nextCell,
prevCell: prevCell,
setColumnAlignment: setColumnAlignment,
insertColumn: insertColumn,
deleteColumn: deleteColumn,
+2
View File
@@ -4,6 +4,8 @@
switch (e.key) {
case 'E':
e.preventDefault();
// assign, not replace — opening the editor pushes a history
// entry so Back cancels the edit (see history-nav.js).
window.location.href = window.location.pathname + '?edit';
break;
case 'N':
+37
View File
@@ -0,0 +1,37 @@
// Keeps the editor from lingering in history once you leave it — while still
// letting the browser/Android Back button CANCEL an edit session.
//
// Opening the editor pushes a normal history entry, so Back exits the editor
// and returns to the page (the primary "back to cancel" gesture on mobile).
//
// Leaving the editor by CANCEL is the one transition we rewrite: the CANCEL
// link points back at the same page, so we replace the editor entry instead of
// pushing a second page entry on top of it. Without this, page -> edit -> CANCEL
// would leave [page, editor, page] and Back would walk straight back into the
// editor. SAVE does the equivalent from editor/main.js (replaceState + reload).
(function () {
function isEdit(loc) {
return new URLSearchParams(loc.search).has('edit');
}
document.addEventListener('click', function (e) {
if (e.defaultPrevented || e.button !== 0) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var a = e.target.closest ? e.target.closest('a[href]') : null;
if (!a || a.target || a.hasAttribute('download')) return;
// Only act while inside the editor. Entering the editor stays a normal
// push so Back can cancel it.
if (!isEdit(window.location)) return;
var url = new URL(a.href, window.location.href);
if (url.origin !== window.location.origin) return;
if (url.pathname !== window.location.pathname) return;
// Leaving the editor to another page (e.g. a wikilink) keeps its normal
// push; only the same-page exit (CANCEL) is collapsed.
if (isEdit(url)) return;
e.preventDefault();
window.location.replace(url.href);
});
}());
+12 -5
View File
@@ -10,10 +10,13 @@
<link rel="stylesheet" href="/_/style.css" />
<script src="/_/modal.js"></script>
<script src="/_/global-shortcuts.js"></script>
<script src="/_/history-nav.js"></script>
<script src="/_/scroll-sync.js" defer></script>
<script src="/_/search-suggest.js" defer></script>
<script src="/_/tree-picker.js"></script>
<script src="/_/companion.js" defer></script>
{{if not .EditMode}}<script src="/_/tree-sidebar.js" defer></script>{{end}}
{{if not .EditMode}}<script src="/_/overlay.js" defer></script>
<script src="/_/tree-sidebar.js" defer></script>{{end}}
{{block "headScripts" .}}{{end}}
</head>
<body>
@@ -28,13 +31,14 @@
{{end}}
<div class="header-actions row">{{block "headerActions" .}}{{end}}</div>
</header>
<div class="page-wrap">
{{if not .EditMode}}<aside class="tree-sidebar"></aside>{{end}}
<div class="shell">
{{if not .EditMode}}<aside class="tree-sidebar col">
<div class="tree-scroll"></div>
</aside>{{end}}
<div class="center">
<main>
{{block "content" .}}{{end}}
</main>
<aside class="sidebar">{{block "sidebar" .}}{{end}}</aside>
</div>
<footer>
<span class="muted">Request: {{.RenderMS}} ms</span>
{{block "footerExtras" .}}{{end}}
@@ -43,6 +47,9 @@
<div class="dropdown-menu align-right open-up companion-flyout"></div>
</span>
</footer>
</div>
{{if not .EditMode}}<aside class="sidebar">{{block "sidebar" .}}{{end}}</aside>{{end}}
</div>
{{block "extras" .}}{{end}}
</body>
</html>
+103
View File
@@ -0,0 +1,103 @@
// Full-viewport Overlay component (mobile). Distinct from the .modal dialog:
// this hosts a whole rail (the folder tree or the right-rail widgets/TOC) at
// full screen with its own scroll and a top-right close control. It reuses the
// low-level focus/Escape/bfcache patterns from modal.js rather than that
// component's panel chrome.
//
// openOverlay(node) MOVES `node` (an existing rail <aside>) into the overlay
// body and restores it to its original DOM position on close, so the rail keeps
// its state (expanded tree folders, rendered TOC). Only one overlay is open at
// a time.
(function () {
var overlay = null;
var bodyEl = null;
var closeBtn = null;
var hosted = null; // the moved node
var placeholder = null; // marks where to restore it
var prevFocus = null;
var onKeydown = null;
function build() {
overlay = document.createElement('div');
overlay.className = 'overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
var bar = document.createElement('div');
bar.className = 'overlay-bar';
closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'btn btn-fab overlay-close';
closeBtn.title = 'Close';
closeBtn.setAttribute('aria-label', 'Close');
closeBtn.textContent = '×';
closeBtn.addEventListener('click', close);
bar.appendChild(closeBtn);
bodyEl = document.createElement('div');
bodyEl.className = 'overlay-body';
// Any navigation or anchor activation inside a rail dismisses the
// overlay: tree/calendar links navigate away, TOC links scroll the
// page underneath — either way the user should see the result, and
// closing synchronously (before navigation) avoids a bfcache snapshot
// that would restore the overlay open on back-nav.
bodyEl.addEventListener('click', function (e) {
if (e.target.closest('a')) close();
});
overlay.appendChild(bar);
overlay.appendChild(bodyEl);
}
function open(node) {
if (overlay && overlay.parentNode) close();
if (!overlay) build();
prevFocus = document.activeElement;
hosted = node;
placeholder = document.createComment('overlay-slot');
node.parentNode.insertBefore(placeholder, node);
bodyEl.appendChild(node);
document.body.appendChild(overlay);
overlay.classList.add('is-open');
onKeydown = function (e) {
if (e.key === 'Escape') { e.preventDefault(); close(); }
};
document.addEventListener('keydown', onKeydown);
setTimeout(function () { closeBtn.focus(); }, 0);
}
function close() {
if (!overlay || !overlay.parentNode) return;
if (onKeydown) {
document.removeEventListener('keydown', onKeydown);
onKeydown = null;
}
// Restore the hosted rail to its original spot in the shell.
if (hosted && placeholder && placeholder.parentNode) {
placeholder.parentNode.insertBefore(hosted, placeholder);
placeholder.parentNode.removeChild(placeholder);
}
hosted = null;
placeholder = null;
overlay.classList.remove('is-open');
overlay.parentNode.removeChild(overlay);
var toRestore = prevFocus;
prevFocus = null;
if (toRestore && toRestore.focus) {
try { toRestore.focus(); } catch (e) {}
}
}
// Belt-and-suspenders bfcache guard: force the overlay closed (restoring
// the rail) if the page is being hidden while it is still open.
window.addEventListener('pagehide', close);
window.openOverlay = open;
window.closeOverlay = close;
})();
+1 -1
View File
@@ -5,7 +5,7 @@
img.src = 'https://icons.duckduckgo.com/ip3/' + hostname + '.ico';
img.width = 16;
img.height = 16;
img.style.verticalAlign = 'middle';
img.style.verticalAlign = 'center';
img.style.marginRight = '3px';
a.prepend(img);
});
+16
View File
@@ -0,0 +1,16 @@
// Wires the header-right [ACTIONS] dropdown. The buttons inside keep calling
// the existing page/actions.js handlers (newPage / movePage / deletePage) and
// the ?edit link, so the N/E/M keyboard shortcuts continue to work whether or
// not the menu is open. Toggling reuses the generic wireDropdown mechanism
// (global-shortcuts.js), which also handles outside-click / Escape close.
(function () {
var trigger = document.querySelector('[data-action="page-actions"]');
if (!trigger || typeof wireDropdown !== 'function') return;
wireDropdown(trigger);
var menu = trigger.parentElement.querySelector('.dropdown-menu');
if (!menu) return;
// Keep aria-expanded in sync with the menu's open state.
trigger.addEventListener('click', function () {
trigger.setAttribute('aria-expanded', menu.classList.contains('is-open') ? 'true' : 'false');
});
})();
+20 -13
View File
@@ -1,5 +1,23 @@
{{define "headScripts"}}<script src="/_/page/actions.js"></script>{{end}}
{{define "headerActions"}}{{if .CanEdit}}
<span class="dropdown">
<button type="button" class="btn" data-action="page-actions" aria-haspopup="true" aria-expanded="false" title="Page actions">ACTIONS</button>
<div class="dropdown-menu align-right">
<button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW PAGE</button>
<a class="btn btn-block" href="?edit" title="Edit page (E)">EDIT PAGE</a>
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager">REVEAL ON CLIENT</button>
{{if not .IsRoot}}
<button class="btn btn-block" onclick="movePage()" title="Move page (M)">MOVE PAGE</button>
{{end}}
{{if not .IsRoot}}
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page">DELETE PAGE</button>
{{end}}
</div>
</span>
<script src="/_/page/header-actions.js"></script>
{{end}}{{end}}
{{define "content"}}
{{if .Content}}
<div class="content">{{.Content}}</div>
@@ -13,7 +31,7 @@
<div class="thumb-grid">
{{range .Entries}}
<a class="thumb-tile" href="{{.URL}}" title="{{.Name}}">
{{if .ThumbURL}}<img class="thumb-img" src="{{.ThumbURL}}" alt="" loading="lazy" width="300">{{else}}<span class="thumb-icon">{{.Icon}}</span>{{end}}
{{if .ThumbURL}}<span class="thumb-media"><img class="thumb-img" src="{{.ThumbURL}}" alt="" loading="lazy" width="300">{{if .IsVideo}}<span class="thumb-play" aria-hidden="true"></span>{{end}}</span>{{else}}<span class="thumb-icon">{{.Icon}}</span>{{end}}
<span class="thumb-label truncate">{{.Name}}</span>
</a>
{{end}}
@@ -49,15 +67,4 @@
<script src="/_/page/sidebar-fab.js"></script>
{{end}}
{{define "sidebar"}}{{if .CanEdit}}<nav class="actions panel panel-sidebar">
<div class="panel-header">ACTIONS</div>
<button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW PAGE</button>
<a class="btn btn-block" href="?edit" title="Edit page (E)">EDIT PAGE</a>
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager">REVEAL ON CLIENT</button>
{{if not .IsRoot}}
<button class="btn btn-block" onclick="movePage()" title="Move page (M)">MOVE PAGE</button>
{{end}}
{{if not .IsRoot}}
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page">DELETE PAGE</button>
{{end}}
</nav>{{end}}{{if .SidebarWidget}}{{.SidebarWidget}}{{end}}{{end}}
{{define "sidebar"}}{{if .SidebarWidget}}{{.SidebarWidget}}{{end}}{{end}}
+9 -12
View File
@@ -1,23 +1,20 @@
// Mobile: the right-rail FAB is stacked ABOVE the tree FAB and opens the right
// rail (TOC + page widgets) in the full-viewport Overlay (overlay.js). It only
// appears when the rail actually has content — runs after toc.js has had a
// chance to populate the rail (script order in page/main.html), so an
// otherwise-empty rail shows no FAB. Hidden on desktop by .fab CSS.
document.addEventListener("DOMContentLoaded", function () {
var aside = document.querySelector("aside.sidebar");
if (!aside || !aside.children.length) return;
var fab = document.createElement("button");
fab.type = "button";
fab.className = "btn btn-fab fab";
fab.title = "Menu";
fab.setAttribute("aria-label", "Menu");
fab.setAttribute("aria-expanded", "false");
fab.className = "btn btn-fab fab fab-rail";
fab.title = "Contents";
fab.setAttribute("aria-label", "Contents");
fab.textContent = "≡";
fab.addEventListener("click", function () {
var open = aside.classList.toggle("is-open");
fab.setAttribute("aria-expanded", open ? "true" : "false");
});
aside.addEventListener("click", function (e) {
if (e.target.tagName === "A") {
aside.classList.remove("is-open");
fab.setAttribute("aria-expanded", "false");
}
if (typeof openOverlay === "function") openOverlay(aside);
});
document.body.appendChild(fab);
});
+2 -1
View File
@@ -28,6 +28,7 @@ document.addEventListener("DOMContentLoaded", function () {
});
nav.appendChild(list);
// Stack the TOC on top of any server-rendered widget(s) already in the rail.
var rail = document.querySelector("aside.sidebar");
rail.appendChild(nav);
rail.insertBefore(nav, rail.firstChild);
});
+61
View File
@@ -0,0 +1,61 @@
// Roughly preserve reading position when entering the editor.
//
// While reading a page we record how far the center column is scrolled (as a
// fraction of its scrollable height, keyed by path). When the same path opens
// in the editor we restore that fraction, so you land near where you were
// reading instead of at the top. It is fraction-based — the rendered page and
// the raw-markdown editor have different heights — so the match is deliberately
// approximate. This only moves the viewport; the caret default (end of doc) is
// unchanged, so a click still decides where you actually edit.
(function () {
var center = document.querySelector('.center');
if (!center) return;
var key = 'scrollsync:' + location.pathname;
// Only restore a freshly-recorded position: a stale entry from earlier in
// the tab session would jump a direct ?edit open to the wrong place.
var MAX_AGE_MS = 5 * 60 * 1000;
var params = new URLSearchParams(location.search);
var inEditor = params.has('edit');
// Section / insert edits load only a slice of the document into the editor,
// so a whole-page reading fraction does not map onto them — restore only for
// a full-page ?edit.
var fullPageEdit = inEditor && !params.has('section') && !params.has('insert_before');
function maxScroll() { return Math.max(0, center.scrollHeight - center.clientHeight); }
if (inEditor) {
if (!fullPageEdit) return;
var raw = sessionStorage.getItem(key);
if (!raw) return;
var data;
try { data = JSON.parse(raw); } catch (e) { return; }
if (!data || typeof data.f !== 'number' || Date.now() - (data.t || 0) > MAX_AGE_MS) return;
// Wait for CodeMirror to mount and the column to reach full height, then
// map the fraction onto it. A double rAF after load lets CM's initial
// layout and focus scroll settle so ours wins.
window.addEventListener('load', function () {
requestAnimationFrame(function () {
requestAnimationFrame(function () {
center.scrollTop = data.f * maxScroll();
});
});
});
return;
}
// Reading mode: keep the latest scroll fraction recorded (throttled to one
// write per frame) so it is ready the moment the editor opens.
var pending = false;
center.addEventListener('scroll', function () {
if (pending) return;
pending = true;
requestAnimationFrame(function () {
pending = false;
var m = maxScroll();
try {
sessionStorage.setItem(key, JSON.stringify({ f: m > 0 ? center.scrollTop / m : 0, t: Date.now() }));
} catch (e) {}
});
}, { passive: true });
})();
+49
View File
@@ -11,9 +11,58 @@ function rebuildIndex() {
});
}
function encodeSearchPath(p) {
if (p === '/' || p === '') return '/';
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
}
// createSearchPage runs the new-page flow (pick a parent folder, then confirm
// the name) starting from the search query, so a query with no exact page can
// be turned into that page in one step. The name field is pre-filled with the
// query but stays editable.
function createSearchPage(name) {
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '') || '/';
openTreePicker({
title: 'New page — where?',
mode: 'folder',
initialPath: current,
preselect: current,
hideFiles: true,
confirmLabel: 'NEXT',
onSelect: function (parentPath) {
var input = document.createElement('input');
input.type = 'text';
input.className = 'input';
input.placeholder = 'Page name';
input.value = name || '';
openModal({
title: 'New page — name?',
body: input,
confirm: {
label: 'CREATE',
onConfirm: function () {
var finalName = input.value.trim();
if (!finalName) return;
var base = parentPath === '/' ? '/' : encodeSearchPath(parentPath) + '/';
window.location.href = base + encodeURIComponent(finalName) + '/?edit';
}
}
});
}
});
}
document.addEventListener('DOMContentLoaded', function () {
wireDropdown(document.querySelector('[data-action="actions-drop"]'));
var createLink = document.querySelector('[data-create-page]');
if (createLink) {
createLink.addEventListener('click', function (e) {
e.preventDefault();
createSearchPage(createLink.getAttribute('data-create-page'));
});
}
// Focus the search input on results pages so Tab steps directly into the
// first match — the input sits immediately before the results in DOM
// order, so the natural tab sequence is input → first result → next, …
+26 -5
View File
@@ -4,17 +4,38 @@
{{define "content"}}
{{if .Query}}
{{if .Results}}
<p class="muted">{{len .Results}} match{{if ne (len .Results) 1}}es{{end}} for &ldquo;{{.Query}}&rdquo;</p>
<hr/>
{{range .Results}}
{{if .Exact}}
<h2 class="search-section">Exact Match</h2>
{{range .Exact}}
<article class="search-card">
<a href="{{.URL}}">{{.Name}}</a>
<div class="muted">/{{.Path}}</div>
</article>
{{end}}
{{else}}
<p class="empty">No matches for &ldquo;{{.Query}}&rdquo;.</p>
<p class="muted">No page named &ldquo;{{.Query}}&rdquo;<button class="btn btn-small" data-create-page="{{.Query}}">create it</button></p>
{{end}}
{{if .Pages}}
<h2 class="search-section">Matching Pages <span class="muted">{{.PageTotal}}</span></h2>
{{range .Pages}}
<article class="search-card">
<a href="{{.URL}}">{{.Name}}</a>
<div class="muted">/{{.Path}}</div>
</article>
{{end}}
{{end}}
{{if .Files}}
<h2 class="search-section">Matching Files <span class="muted">{{.FileTotal}}</span></h2>
{{range .Files}}
<article class="search-card">
<div class="row">
{{fileIcon .Name}}
<a href="{{.URL}}">{{.Name}}</a>
<button class="btn btn-small" data-companion-file-reveal="{{.Path}}" hidden title="Open location in file manager">open</button>
</div>
<div class="muted">/{{.Path}} · {{.Meta}}</div>
</article>
{{end}}
{{end}}
{{else}}
<p class="empty">Enter a query above.</p>
+266 -164
View File
@@ -24,9 +24,14 @@
--link-hover: #d6d24d;
--danger: #c40141;
--danger-hover: #d03467;
/* Translucent scrim for badges/overlays sitting on top of media. */
--overlay: rgba(0, 0, 0, 0.55);
--border: 1px solid var(--secondary);
--border-dashed: 1px dashed var(--secondary);
/* Subtle vertical rail for tree-nesting depth — quieter than --border so
it reads as a guide, not a divider. */
--border-guide: 1px solid var(--bg-panel-hover);
--space-1: 0.25rem;
--space-2: 0.5rem;
@@ -37,31 +42,35 @@
--font-xs: 0.75rem;
--font-sm: 0.85rem;
/* Height of the sticky top header. Single source of truth for the
sidebar/TOC top offsets and anchor scroll-padding. Hardcoded (the
desktop header is a single-row grid); revisit if the header wraps. */
--header-h: 3.25rem;
/* Width of the persistent left folder-tree rail (desktop). */
--tree-width: 15rem;
/* Comfortable max reading width for the center column's content + footer.
The shell itself is edge-to-edge; this keeps prose lines readable on
wide monitors (content stays centered within the center column). */
--reading-width: 60rem;
}
/* Anchor / TOC jumps land below the sticky header instead of behind it.
Harmless on mobile view mode (header not sticky there) — adds only a
small gap above the target. */
html { scroll-padding-top: var(--header-h); }
/* Anchor / TOC jumps are handled by the .center scroll container's own
scroll-padding-top (the header no longer overlaps content in the app-shell),
so no html-level scroll padding is needed. */
/* === Base === */
/* App-shell: the body is a full-viewport, non-scrolling grid of two rows —
the fixed-height header and a 1fr shell that owns all scrolling internally.
100dvh (not 100vh) so the mobile browser's dynamic toolbar doesn't clip the
bottom row; overflow:hidden pins the header and lets each shell column scroll
on its own. */
body {
background: var(--bg);
color: var(--text);
min-height: 100vh;
height: 100dvh;
margin: 0;
padding: 0;
overflow: auto;
overflow: hidden;
font: 1rem "Iosevka Etoile", monospace;
display: flex;
flex-direction: column;
display: grid;
grid-template-rows: auto 1fr;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -86,41 +95,63 @@ hr { border: none; border-top: var(--border-dashed); margin: var(--space-4) 0; }
.space-between { justify-content: space-between; }
.divider-dashed { border-bottom: var(--border-dashed); }
/* === Page layout ===
Note: sticky positioning on .sidebar depends on no ancestor having
overflow: auto/hidden. If you add scroll containment above this, sticky
will silently break. */
.page-wrap {
display: grid;
grid-template-columns: var(--tree-width) minmax(0, 1fr) 14rem;
gap: var(--space-5);
max-width: 1440px;
margin: 0 auto;
padding: 0 var(--space-4);
width: 100%;
flex: 1;
align-items: start;
/* === Page layout (app-shell) ===
The shell is a full-height flex row below the header: left rail | center |
right rail. Each column owns its own vertical scroll (overflow-y:auto +
min-height:0), so nothing scrolls the page as a whole. Edge-to-edge: no
centered max-width container. The right rail collapses out of the flex flow
when empty (:empty { display:none }), letting the center reclaim the width. */
.shell {
display: flex;
min-height: 0;
overflow: hidden;
}
/* Center column: main content plus the footer beneath it, scrolling together.
Content and footer are held to a comfortable reading width and centered
within the (edge-to-edge) column. */
.center {
flex: 1 1 auto;
min-width: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: center;
scroll-padding-top: var(--space-4);
}
main {
width: 100%;
max-width: var(--reading-width);
padding: var(--space-5) var(--space-4);
flex: 1 0 auto;
min-width: 0;
}
main { padding: var(--space-5) var(--space-4); width: 100%; flex: 1; min-width: 0; }
/* === Header / footer ===
Three-column grid (breadcrumbs left, search centre, actions right) so the
centre stays reserved even when search is hidden in editor mode. Mobile
(≤1100px) collapses to a two-row layout — see responsive block below. */
Header is the app-shell's fixed top row (grid row 1 of <body>): full width,
never scrolls. Three-column grid (breadcrumbs left, search centre, actions
right) so the centre stays reserved even when search is hidden in editor
mode; the far-right actions column hosts the [ACTIONS] dropdown. position +
z-index keep the header (and its open dropdown) painting above the shell.
Mobile (≤1100px) collapses to a compact three-column layout — see responsive
block below. */
header {
position: sticky;
top: 0;
position: relative;
z-index: 40;
background: var(--bg);
padding: var(--space-3) var(--space-4);
border-bottom: var(--border-dashed);
display: grid;
grid-template-columns: 1fr minmax(0, 60rem) 1fr;
grid-template-columns: 1fr minmax(0, 50rem) 1fr;
grid-template-areas: "crumbs search actions";
align-items: center;
gap: var(--space-2);
}
/* Footer rides at the end of the center column's content (scrolls with it, not
pinned to the viewport). Held to the same reading width as main so its top
border delimits the content, not the whole shell. */
footer {
width: 100%;
max-width: var(--reading-width);
padding: var(--space-3) var(--space-4);
border-top: var(--border-dashed);
display: flex;
@@ -277,6 +308,42 @@ main > h2 {
text-decoration: line-through;
}
/* === Embedded images ===
Wiki-style figure box: a bounded, framed thumbnail with an optional caption.
The fixed width matches the 300px thumbnail request so the box never scales
the image up. left/right float so body text wraps; center is a plain block. */
.embed {
margin: var(--space-2) 0;
width: 300px;
max-width: 100%;
padding: var(--space-2);
background: var(--bg-panel);
border: var(--border);
}
.embed-left {
float: left;
margin-right: var(--space-4);
}
.embed-right {
float: right;
margin-left: var(--space-4);
}
.embed-center {
margin-inline: auto;
}
.embed .embed-link { display: block; }
.embed img {
display: block;
width: 100%;
height: auto;
}
.embed figcaption {
margin-top: var(--space-2);
color: var(--text-muted);
font-size: var(--font-sm);
line-height: 1.4;
}
a.heading-anchor {
color: var(--text-muted);
margin-right: 0.4em;
@@ -293,8 +360,13 @@ a.heading-anchor:hover { color: var(--primary-hover); }
.data-table th,
.data-table td { padding: 0.4rem var(--space-3); text-align: left; }
.data-table:not(.data-table-grid) tbody tr + tr { border-top: var(--border); }
.data-table:not(.data-table-grid) tbody tr:hover,
.data-table tr.is-active { background: var(--bg-panel-hover); }
/* Hover-highlight only on devices with a real pointer. On touch, a tap or
drag-scroll leaves an emulated :hover stuck on the last-touched row until
the next tap — distracting while scrolling a listing. */
@media (hover: hover) {
.data-table:not(.data-table-grid) tbody tr:hover { background: var(--bg-panel-hover); }
}
.data-table tbody tr.is-empty,
.data-table tbody tr.is-empty:hover {
color: var(--text-muted);
@@ -374,6 +446,12 @@ a.heading-anchor:hover { color: var(--primary-hover); }
background: var(--bg-panel-hover);
overflow-x: auto;
scrollbar-width: none;
/* Keep the toolbar in reach on long pages: stick it to the top of the
center column's scroll viewport (just under the header). Mobile overrides
this to a keyboard-hugging bottom bar (see responsive block). */
position: sticky;
top: 0;
z-index: 30;
}
.editor-toolbar::-webkit-scrollbar { display: none; }
.editor-toolbar > * { flex-shrink: 0; }
@@ -386,14 +464,22 @@ a.heading-anchor:hover { color: var(--primary-hover); }
/* === Edit form === */
.edit-form { display: flex; flex-direction: column; }
/* The sidebar is always empty while editing, so the editor uses the full
viewport: drop the reserved 14rem sidebar track and the centered max-width. */
body.edit-mode .page-wrap { grid-template-columns: minmax(0, 1fr); max-width: none; }
/* No rails are rendered while editing, so the editor uses the full center
column: drop the reading-width cap on main (and the footer) so the toolbar
and CodeMirror mount span the whole width. */
body.edit-mode main,
body.edit-mode footer { max-width: none; }
/* CodeMirror mount. The .cm-editor visual treatment (border, bg, font, padding)
lives in the CM theme (editor-build/entry.js), keyed off the same :root
variables; this only sizes the container. */
.editor-cm { min-height: 60vh; }
.editor-cm .cm-editor { height: 100%; }
/* Stretch the editable surface to fill the mount. Without this the
contenteditable is only as tall as the text, so clicking in the empty region
below a short document lands on the (non-editable) scroller and places no
caret. Filling it means a click anywhere maps to the nearest position — the
end of the document. */
.editor-cm .cm-content { min-height: 60vh; }
/* === Search === */
.search-form {
@@ -410,20 +496,24 @@ body.edit-mode .page-wrap { grid-template-columns: minmax(0, 1fr); max-width: no
display: flex;
flex-direction: column;
gap: var(--space-1);
padding-bottom: var(--space-4);
margin-bottom: var(--space-4);
border-bottom: var(--border-dashed);
word-break: break-word;
}
.search-card:last-child { border-bottom: none; }
.search-card a { color: var(--link); font-size: 1.1rem; }
.search-card a:hover { color: var(--link-hover); }
.search-section {
padding-bottom: var(--space-2);
border-bottom: var(--border-dashed);
}
/* === Floating action button ===
Standalone FAB buttons (page TOC) are mobile-only. Wrapped FABs (search
actions dropdown) stay visible on desktop. */
Standalone FAB buttons (the tree rail, the right rail) are mobile-only and
stack bottom-right: the tree FAB anchors the bottom, the right-rail FAB sits
above it. Wrapped FABs (the search actions dropdown) stay visible on desktop
and, on mobile, sit in the upper slot so they never overlap the tree FAB. */
.fab { position: fixed; bottom: var(--space-4); right: var(--space-4); z-index: 50; }
button.fab { display: none; }
.fab-rail { bottom: calc(var(--space-4) + 3rem + var(--space-2)); }
/* === Companion status === */
.companion-status { margin-left: auto; }
@@ -476,6 +566,24 @@ button.fab { display: none; }
display: block;
background: var(--bg) url("/_/icons/thumb-placeholder.svg") center/2rem no-repeat;
}
/* Wrapper so a video tile can overlay a play badge on its extracted frame. */
.thumb-media { position: relative; display: block; }
.thumb-play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
padding-left: 0.15rem;
border-radius: 50%;
background: var(--overlay);
color: var(--text);
pointer-events: none;
}
.thumb-icon {
height: 150px;
display: flex;
@@ -494,54 +602,38 @@ button.fab { display: none; }
::-webkit-scrollbar-thumb { background: var(--primary); }
::-webkit-scrollbar-thumb:hover { background: var(--primary-hover); }
/* === Sidebar === */
/* === Sidebar (right rail) ===
Full-height column in the app-shell that scrolls its own overflow (TOC on
top, page widget(s) below). Collapses out of the flex row when it has no
content so the center column widens. A dashed separator to main mirrors the
left tree rail; widgets within drop their panel outlines (see
.panel-sidebar). */
.sidebar {
position: sticky;
/* Park below the sticky header. The space-2 buffer also absorbs the small
difference between --header-h and the real rendered header height, so the
sidebar is already at its pinned offset at scroll 0 — no pre-pin travel.
Do NOT add margin-top: a top margin sits above the pin point and makes
the sidebar visibly jump up by that margin when scrolling starts. */
top: calc(var(--header-h) + var(--space-2));
align-self: start;
max-height: calc(100vh - var(--header-h) - var(--space-4));
width: 14rem;
flex-shrink: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--space-4);
padding-top: 1rem;
/* Mirror the left tree rail: a dashed separator to main, no panel outlines
on the widgets within (see .panel-sidebar). */
padding-left: var(--space-2);
padding: var(--space-4) 0 var(--space-4) var(--space-2);
border-left: var(--border-dashed);
}
aside.sidebar:empty { display: none; }
/* Density modifier for panels in the sidebar (smaller font, tighter padding).
Drops the .panel outline so the rail reads as a clean column separated from
main by the .sidebar dashed border, matching the left tree rail. The mobile
drawer re-adds a full border + bg (see responsive block) so it stays legible
floating over content. */
main by the .sidebar dashed border, matching the left tree rail. Inside the
mobile Overlay the widgets render as plain full-width content. */
.panel-sidebar {
padding: var(--space-2) var(--space-3);
font-size: var(--font-sm);
border: none;
}
.actions { display: flex; flex-direction: column; gap: 0.15rem; }
/* === Table of contents (floating variant) ===
Default rendering is a fixed floating panel; inside .sidebar it becomes
static via the .panel-sidebar density class. */
.toc {
position: fixed;
top: calc(var(--header-h) + var(--space-2));
right: var(--space-4);
width: 14rem;
max-height: calc(100vh - 6rem);
overflow-y: auto;
padding: var(--space-2) var(--space-3);
font-size: var(--font-sm);
}
.sidebar .toc { position: static; width: auto; max-height: none; }
/* === Table of contents ===
Rendered as a .panel-sidebar block in the right rail (padding + font-size
come from that density class); toc.js always mounts it there, on both
desktop and inside the mobile Overlay. */
.toc ul { list-style: none; margin: 0; padding: 0; }
.toc li { margin: 0.15rem 0; }
.toc a {
@@ -592,15 +684,59 @@ aside.sidebar:empty { display: none; }
padding: 0.4rem 0.6rem;
}
/* === Overlay (full-viewport rail host, mobile) ===
A distinct component from .modal: it hosts a whole rail (the folder tree or
the right rail's TOC/widgets) at full screen with its own scroll and a
top-right close control. overlay.js MOVES the rail node into .overlay-body,
so the rules below strip the rail's column chrome (fixed width, borders, the
mobile display:none) and let it render as plain full-width content. */
.overlay {
position: fixed;
inset: 0;
z-index: 200;
background: var(--bg);
display: flex;
flex-direction: column;
overflow: hidden;
}
.overlay-bar {
display: flex;
justify-content: flex-end;
padding: var(--space-2);
border-bottom: var(--border-dashed);
flex-shrink: 0;
}
.overlay-close { width: 2.5rem; height: 2.5rem; font-size: 1.5rem; }
.overlay-body {
flex: 1;
overflow-y: auto;
padding: var(--space-3) var(--space-4);
-webkit-overflow-scrolling: touch;
}
.overlay-body .tree-scroll,
.overlay-body .sidebar {
display: block;
width: auto;
max-height: none;
border: none;
padding: 0;
overflow: visible;
}
/* Right rail keeps its column spacing (TOC above widgets) inside the overlay;
the base .sidebar already sets flex-direction + gap. */
.overlay-body .sidebar { display: flex; }
/* === Tree picker === */
.tree-picker { max-height: 60vh; overflow-y: auto; }
/* Rows are click targets (navigate / toggle), not prose — suppress the
text-selection highlight that double/drag clicks would otherwise leave,
matching the persistent .tree-sidebar rail. */
.tree-picker { max-height: 60vh; overflow-y: auto; user-select: none; }
.tree-row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0.4rem var(--space-2);
gap: var(--space-1);
padding: 0.25rem var(--space-1);
cursor: pointer;
min-height: 2rem;
}
.tree-row:hover, .tree-row.is-selected { background: var(--bg-panel-hover); }
.tree-row.is-selected {
@@ -613,7 +749,13 @@ aside.sidebar:empty { display: none; }
.tree-chevron { width: 1.25rem; color: var(--secondary); }
.tree-chevron.is-leaf { visibility: hidden; }
.tree-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-children { padding-left: var(--space-2); }
/* Each nesting level gets a vertical guide rail down its left edge so depth
stays scannable in the compact view (cf. VS Code / file-explorer trees). */
.tree-children {
margin-left: var(--space-2);
padding-left: var(--space-2);
border-left: var(--border-guide);
}
.tree-selected-path {
font-size: var(--font-sm);
padding: var(--space-1) 0;
@@ -630,23 +772,32 @@ aside.sidebar:empty { display: none; }
/* === Tree sidebar (persistent left navigation rail) ===
Reuses the .tree-row / .tree-children / .tree-name / .tree-chevron modules.
Desktop: a sticky, scrollable rail parked below the header (mirrors .sidebar
offsets). Mobile: an overlay drawer toggled by .fab-tree (see responsive). */
Desktop: a full-height flex column in the app-shell (composes with .col)
holding the tree's own scroll region. Mobile: not laid out inline — its
content is surfaced through the Overlay via the stacked FABs (see
responsive). */
/* No right padding on the aside: the scrolling children below reach the
border-right so their scrollbars sit flush against the divider line (they
pad their own content off the scrollbar instead). */
.tree-sidebar {
position: sticky;
top: calc(var(--header-h) + var(--space-2));
align-self: start;
max-height: calc(100vh - var(--header-h) - var(--space-4));
overflow-y: auto;
padding-top: 1rem;
padding-right: var(--space-2);
width: var(--tree-width);
flex-shrink: 0;
min-height: 0;
padding: var(--space-4) 0 var(--space-4) var(--space-4);
border-right: var(--border-dashed);
}
/* The tree's scroll region: the tree overflows here. Also the node the tree FAB
moves into the Overlay, so font sizing lives here rather than on the aside. */
.tree-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-right: var(--space-2);
font-size: var(--font-sm);
/* Rows are click targets (navigate / toggle), not prose — suppress the
text-selection highlight that double/drag clicks would otherwise leave. */
user-select: none;
}
aside.tree-sidebar:empty { display: none; }
/* === Movie info box === */
.movie-info { margin: var(--space-3) 0; }
@@ -666,23 +817,17 @@ aside.tree-sidebar:empty { display: none; }
}
/* === Diary calendar === */
.diary-cal-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 0.2rem;
margin-bottom: 0.4rem;
/* Header row: "Chronological" on the left, the [year ▾] dropdown pushed to the
right. Composes with .row (flex + centering); space-between splits the two. */
.diary-cal .panel-header { justify-content: space-between; }
.diary-cal-drop .dropdown-menu { min-width: 0; }
/* Per-month caption above each grid, linking to that month's anchor. */
.diary-cal-month {
font-size: var(--font-sm);
margin: var(--space-3) 0 0.2rem;
}
.diary-cal-nav .diary-cal-drop + .diary-cal-heading { margin-left: var(--space-3); }
/* Anchor the month/year dropdowns to the nav row instead of the ▾ button so
the menu spans the full panel width rather than overflowing the 14rem
sidebar with its default min-width: 9rem. */
.diary-cal-nav { position: relative; }
.diary-cal-nav .diary-cal-drop { position: static; }
.diary-cal-nav .dropdown-menu { left: 0; right: 0; min-width: 0; }
.diary-cal-heading { color: var(--link); }
.diary-cal-heading:hover { color: var(--link-hover); }
.diary-cal-month a { color: var(--link); }
.diary-cal-month a:hover { color: var(--link-hover); }
.diary-cal-grid {
width: 100%;
border-collapse: collapse;
@@ -728,64 +873,20 @@ aside.tree-sidebar:empty { display: none; }
/* === Responsive === */
@media (max-width: 1100px) {
.page-wrap { grid-template-columns: 1fr; }
/* Single-row mobile header: the compact logo sits left so search can take
the middle flex column, with actions on the right. */
/* Rails are not laid out inline on mobile — their content is surfaced
through the Overlay via the stacked FABs. The <aside> elements stay in
the DOM (their JS renders into them and the Overlay moves them in/out);
we only pull them out of the shell flow here. */
.tree-sidebar, .sidebar { display: none; }
/* Compact three-column header: logo left, search stretches the middle,
actions right. */
header { grid-template-columns: auto 1fr auto; }
/* Mobile view mode: header scrolls away with the page (out of scope to
stick it). It re-sticks only in edit mode so SAVE/CANCEL stay reachable
on long documents. */
header { position: static; }
body.edit-mode header { position: sticky; top: 0; }
.search-form { width: 100%; max-width: none; justify-self: stretch; }
/* Sidebar on mobile is a floating overlay toggled by the FAB. The aside
itself is the scroll container; children render at natural height. The
full border + bg replace the per-panel outlines dropped on desktop so the
drawer stays legible floating over content. */
.sidebar {
position: fixed;
bottom: 5rem;
right: var(--space-4);
top: auto;
left: auto;
width: calc(100% - 2rem);
max-width: 20rem;
max-height: calc(100vh - 8rem);
overflow-y: auto;
display: none;
z-index: 60;
padding: var(--space-2);
border: var(--border);
background: var(--bg);
}
.sidebar.is-open { display: flex; }
/* Reveal the mobile FAB stack (tree at the bottom, right rail above it).
The search actions dropdown FAB moves to the upper slot too so it never
overlaps the tree FAB. */
button.fab { display: inline-flex; }
/* Tree rail becomes a left-anchored overlay drawer toggled by .fab-tree.
Off-grid (position: fixed) so it never steals horizontal space from
<main> on narrow viewports. */
.tree-sidebar {
position: fixed;
top: auto;
bottom: 5rem;
left: var(--space-4);
right: auto;
width: calc(100% - 2rem);
max-width: 20rem;
max-height: calc(100vh - 8rem);
padding: var(--space-2);
border: var(--border);
background: var(--bg);
display: none;
z-index: 60;
}
.tree-sidebar.is-open { display: block; }
/* Smaller FAB stacked just above the menu FAB (3rem tall at --space-4). */
.fab-tree {
bottom: calc(var(--space-4) + 3rem + var(--space-2));
width: 2.5rem;
height: 2.5rem;
font-size: 1.1rem;
}
.fab.dropdown { bottom: calc(var(--space-4) + 3rem + var(--space-2)); }
}
@media (max-width: 600px) {
@@ -793,10 +894,8 @@ aside.tree-sidebar:empty { display: none; }
main { padding: var(--space-4) var(--space-3); }
.app-name {display: none;}
.editor-cm { min-height: 50vh; }
.sidebar { width: calc(100% - 1.5rem); }
/* Editing on mobile is full-bleed: drop the page/main inset so the toolbar
and editor use the entire viewport width. */
body.edit-mode .page-wrap { padding: 0; gap: 0; }
/* Editing on mobile is full-bleed: drop the main inset so the toolbar and
editor use the entire viewport width. */
body.edit-mode main { padding: 0; }
/* Fingers, not cursors: give every toolbar control a ~44px tap target. */
.editor-toolbar { gap: var(--space-2); padding: var(--space-2); }
@@ -817,6 +916,9 @@ aside.tree-sidebar:empty { display: none; }
position: fixed;
left: 0;
right: 0;
/* Reset the desktop sticky `top: 0`; with it, fixed + top + bottom would
stretch the bar to the full viewport height. */
top: auto;
bottom: 0;
z-index: 50;
border: none;
+1
View File
@@ -194,6 +194,7 @@
var rootChev = document.createElement('span');
rootChev.className = 'tree-chevron is-leaf';
rootRow.appendChild(rootChev);
var rootLabel = document.createElement('span');
rootLabel.className = 'tree-name';
rootLabel.textContent = '/';
rootRow.appendChild(rootLabel);
+8 -12
View File
@@ -7,7 +7,8 @@
// omits the container).
(function () {
var container = document.querySelector('aside.tree-sidebar');
// This script owns the .tree-scroll child of the aside, not the aside itself.
var container = document.querySelector('aside.tree-sidebar .tree-scroll');
if (!container) return;
function joinPath(parent, name) {
@@ -187,25 +188,20 @@
});
}
// Mobile: a smaller FAB stacked above the menu FAB toggles the rail as an
// overlay drawer; selecting any entry closes it (mirrors sidebar-fab.js).
// Mobile: the tree FAB sits at the bottom of the stacked FAB group and
// opens the tree's scroll container in the full-viewport Overlay
// (overlay.js). The container always exists when not editing, so — per the
// layout spec — the FAB always renders; the overlay auto-closes when a
// folder/file link inside it navigates. Hidden on desktop by .fab CSS.
function setupFab() {
var fab = document.createElement('button');
fab.type = 'button';
fab.className = 'btn btn-fab fab fab-tree';
fab.title = 'Folder tree';
fab.setAttribute('aria-label', 'Folder tree');
fab.setAttribute('aria-expanded', 'false');
fab.innerHTML = '<svg viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter"><path d="M1 6h14v8H1zm0 0V4h5l1 2"/></svg>';
fab.addEventListener('click', function () {
var open = container.classList.toggle('is-open');
fab.setAttribute('aria-expanded', open ? 'true' : 'false');
});
container.addEventListener('click', function (e) {
if (e.target.closest('a')) {
container.classList.remove('is-open');
fab.setAttribute('aria-expanded', 'false');
}
if (typeof openOverlay === 'function') openOverlay(container);
});
document.body.appendChild(fab);
}
+3 -1
View File
@@ -57,7 +57,9 @@ func runOpenCommand(template, path string) error {
if !sawPath {
tokens = append(tokens, path)
}
return exec.Command(tokens[0], tokens[1:]...).Start()
cmd := exec.Command(tokens[0], tokens[1:]...)
hideConsole(cmd)
return cmd.Start()
}
// tokenizeCommand splits a command-line string into argv tokens, honouring
+8
View File
@@ -0,0 +1,8 @@
//go:build !windows
package main
import "os/exec"
// Allows running commands without showing a terminal window on startup when not running on windows
func hideConsole(*exec.Cmd) {}
+18
View File
@@ -0,0 +1,18 @@
package main
import (
"os/exec"
"syscall"
)
// This is the windows CREATE_NO_WINDOW syscall.
// We have no golang.org/x/sys dependency, so this will suffice
const createNoWindow = 0x08000000
// Allows running commands without showing a terminal window on startup on windows
func hideConsole(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: createNoWindow,
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"path/filepath"
)
const version = "1"
const version = "2"
func main() {
flag.Parse()
+5 -17
View File
@@ -28,7 +28,8 @@ type diaryHandler struct{}
// page anchor (or to the year-file editor when ?edit is set).
//
// 1. `today` / `this-month` / `this-year` shortcuts resolve directly to a
// year+anchor target (or insert flow when today's section is missing).
// year+anchor target (never the editor, even when today's section does
// not exist yet).
// 2. A virtual month URL (/diary/<root>/YYYY/MM/) redirects to
// /diary/<root>/YYYY/#YYYY-MM (or to ?edit&section=N when ?edit is set).
// 3. A virtual day URL (/diary/<root>/YYYY/MM/DD/) redirects to
@@ -57,7 +58,7 @@ func (d *diaryHandler) dateShortcutRedirect(root, fsPath, urlPath string) (strin
parentFS := filepath.Dir(fsPath)
parentURLPath := parentURL(urlPath)
_, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, parentFS, parentURLPath)
_, _, diaryRootURL, ok := findDiaryContext(root, parentFS, parentURLPath)
if !ok {
return "", false
}
@@ -71,16 +72,7 @@ func (d *diaryHandler) dateShortcutRedirect(root, fsPath, urlPath string) (strin
switch base {
case "today":
dayHeading := fmt.Sprintf("%s-%s-%s", year, month, day)
raw, _ := os.ReadFile(filepath.Join(diaryRootFS, year, "index.md"))
sections := splitSections(raw)
if _, found := findSectionIndex(sections, dayHeading); found {
return yearURL + "#" + dayHeading, true
}
// Missing day: route through the insert flow so today's section
// is spliced in at the right chronological position.
insertIdx := computeInsertIndex(sections, dayHeading)
return fmt.Sprintf("%s?edit&insert_before=%d&heading=%s",
yearURL, insertIdx, url.QueryEscape(dayHeading)), true
case "this-month":
return yearURL + "#" + fmt.Sprintf("%s-%s", year, month), true
case "this-year":
@@ -343,8 +335,8 @@ type calYear struct {
}
// calMonthGrid carries everything the template needs to render one month's
// grid plus the dropdown / heading entry that targets it. The calendar
// widget ships all 12 in the initial HTML; JS swaps which one is visible.
// grid plus the caption that links to it. The calendar widget renders all 12
// stacked vertically; JS centers the current month in the rail on load.
type calMonthGrid struct {
Num int
Name string
@@ -355,9 +347,7 @@ type calMonthGrid struct {
type calendarData struct {
DisplayYear int
DisplayMonth int
DisplayMonthName string // pre-resolved so the template doesn't need arithmetic
DiaryURL string
YearURL string
Months []calMonthGrid
Years []calYear
}
@@ -491,9 +481,7 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int)
data := calendarData{
DisplayYear: displayYear,
DisplayMonth: displayMonth,
DisplayMonthName: months[displayMonth-1].Name,
DiaryURL: diaryRootURL,
YearURL: yearURL,
Months: months,
Years: years,
}
+254
View File
@@ -0,0 +1,254 @@
package main
import (
"bytes"
"path"
"regexp"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
// wikiEmbedRe matches an ![[...]] token anchored at the current inline reader.
// The inner blob forbids brackets and newlines but allows ':' so the whole
// target::caption::align payload travels as one capture — the caption and
// alignment tail is split apart in Go (parseEmbedFields). This mirrors
// wikiLinkPattern's shape so the move rewriter needs no embed-specific changes.
var wikiEmbedRe = regexp.MustCompile(`^!\[\[([^\[\]\n]+)\]\]`)
const (
alignLeft = "left"
alignRight = "right"
alignCenter = "center"
)
// wikiEmbedNode is the AST node produced by wikiEmbedParser.
type wikiEmbedNode struct {
ast.BaseInline
Target string
Caption string
Align string
}
var kindWikiEmbed = ast.NewNodeKind("WikiEmbed")
func (n *wikiEmbedNode) Kind() ast.NodeKind { return kindWikiEmbed }
func (n *wikiEmbedNode) Dump(source []byte, level int) {
ast.DumpHelper(n, source, level, map[string]string{
"Target": n.Target,
"Caption": n.Caption,
"Align": n.Align,
}, nil)
}
// alignKeyword reports whether s (trimmed) is one of the alignment keywords.
func alignKeyword(s string) (string, bool) {
switch strings.TrimSpace(s) {
case alignLeft:
return alignLeft, true
case alignRight:
return alignRight, true
case alignCenter:
return alignCenter, true
}
return "", false
}
// parseEmbedFields splits the inner ![[...]] blob into target, caption, and
// alignment per the embed syntax rules. The target is the field before the
// first "::". The trailing "::"-fields are interpreted as:
// - none: no caption, default alignment (right)
// - one keyword field: that alignment, no caption
// - one non-keyword field: that caption, default alignment
// - two or more: the last field is the alignment slot (default when it is not
// a keyword) and the earlier fields re-join with "::" as the caption, so a
// caption may contain a literal "::" as long as an alignment field trails it.
func parseEmbedFields(inner string) (target, caption, align string) {
fields := strings.Split(inner, "::")
target = strings.TrimSpace(fields[0])
tail := fields[1:]
align = alignRight
switch len(tail) {
case 0:
// target only
case 1:
if kw, ok := alignKeyword(tail[0]); ok {
align = kw
} else {
caption = strings.TrimSpace(tail[0])
}
default:
if kw, ok := alignKeyword(tail[len(tail)-1]); ok {
align = kw
}
caption = strings.TrimSpace(strings.Join(tail[:len(tail)-1], "::"))
}
return target, caption, align
}
type wikiEmbedParser struct{}
func (p *wikiEmbedParser) Trigger() []byte { return []byte{'!'} }
func (p *wikiEmbedParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
line, _ := block.PeekLine()
// Require the exact "![[" opener; a bare '!' (or a Markdown image "![](…)")
// falls through to goldmark's default image parser at priority 200.
if len(line) < 5 || line[0] != '!' || line[1] != '[' || line[2] != '[' {
return nil
}
m := wikiEmbedRe.FindSubmatchIndex(line)
if m == nil {
return nil
}
target, caption, align := parseEmbedFields(string(line[m[2]:m[3]]))
if !isValidWikiTarget([]byte(target)) {
return nil
}
block.Advance(m[1])
return &wikiEmbedNode{Target: target, Caption: caption, Align: align}
}
// embedBlockTransformer lifts an embed that sits alone in a paragraph up to
// block level. Goldmark wraps inline content in <p>, but the embed renders a
// <figure> (block), and a <figure> inside a <p> is auto-closed by the browser —
// which strands empty <p> elements (they still carry .content paragraph margins)
// and breaks the float layout once several embeds share a page. Dissolving the
// wrapping paragraph makes each embed render as a clean block sibling with no
// stray <p>.
type embedBlockTransformer struct{ root string }
func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
source := reader.Source()
var paras []*ast.Paragraph
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if p, ok := n.(*ast.Paragraph); ok && t.paragraphFiguresOnly(p, source) {
paras = append(paras, p)
}
}
return ast.WalkContinue, nil
})
for _, p := range paras {
parent := p.Parent()
if parent == nil {
continue
}
// InsertBefore isolates the embed from p first, so hoist each embed to a
// block sibling ahead of the (soon removed) paragraph, then drop p.
for c := p.FirstChild(); c != nil; {
next := c.NextSibling()
if _, ok := c.(*wikiEmbedNode); ok {
parent.InsertBefore(parent, p, c)
}
c = next
}
parent.RemoveChild(parent, p)
}
}
// paragraphFiguresOnly reports whether p holds at least one figure-rendering
// embed and nothing else visible — only such embeds and whitespace/line-break
// text. Only these are safe to dissolve: a paragraph carrying prose, or an embed
// that degrades to an inline link (missing / non-image target), stays wrapped so
// the fallback anchor keeps its paragraph.
func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []byte) bool {
hasFigure := false
for c := p.FirstChild(); c != nil; c = c.NextSibling() {
switch n := c.(type) {
case *wikiEmbedNode:
if !embedIsImage(t.root, n.Target) {
return false
}
hasFigure = true
case *ast.Text:
if len(bytes.TrimSpace(n.Segment.Value(source))) != 0 {
return false
}
default:
return false
}
}
return hasFigure
}
// embedIsImage reports whether target resolves to an existing image file, i.e.
// the embed will render as a <figure> rather than degrade to a link fallback.
func embedIsImage(root, target string) bool {
name := path.Base(normalizeWikiTarget(target))
return wikiTargetExists(root, target) && isImageFile(name)
}
type wikiEmbedRenderer struct {
root string
}
func (r *wikiEmbedRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(kindWikiEmbed, r.render)
}
func (r *wikiEmbedRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*wikiEmbedNode)
// Embed only when the target exists and is an image we can thumbnail.
// Anything else — a missing target, or an existing non-image file — degrades
// to the same anchor a plain [[wikilink]] would render (broken or working).
if !embedIsImage(r.root, n.Target) {
writeWikiLink(w, r.root, n.Target, "")
return ast.WalkContinue, nil
}
fileHref := wikiFileHref(n.Target)
alt := n.Caption
if alt == "" {
alt = path.Base(normalizeWikiTarget(n.Target))
}
w.WriteString(`<figure class="embed embed-`)
w.WriteString(n.Align)
w.WriteString(`"><a class="embed-link" href="`)
w.WriteString(fileHref)
w.WriteString(`"><img src="`)
w.WriteString(thumbURL(fileHref, 300))
w.WriteString(`" alt="`)
w.Write(util.EscapeHTML([]byte(alt)))
w.WriteString(`"></a>`)
if n.Caption != "" {
w.WriteString(`<figcaption>`)
w.Write(util.EscapeHTML([]byte(n.Caption)))
w.WriteString(`</figcaption>`)
}
w.WriteString(`</figure>`)
return ast.WalkContinue, nil
}
type wikiEmbedExt struct{ root string }
// newWikiEmbedExt returns a goldmark extension that turns ![[...]] tokens into
// image embeds resolved against root.
func newWikiEmbedExt(root string) goldmark.Extender {
return &wikiEmbedExt{root: root}
}
func (e *wikiEmbedExt) Extend(m goldmark.Markdown) {
// Priority 199 — one higher than the default image parser (200) so ![[...]]
// is consumed as an embed before the default `!` parser sees it.
m.Parser().AddOptions(parser.WithInlineParsers(
util.Prioritized(&wikiEmbedParser{}, 199),
))
m.Parser().AddOptions(parser.WithASTTransformers(
util.Prioritized(embedBlockTransformer{root: e.root}, 100),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&wikiEmbedRenderer{root: e.root}, 500),
))
}
-57
View File
@@ -1,57 +0,0 @@
package main
import (
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
type extLinksTransformer struct{}
func (extLinksTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
link, ok := n.(*ast.Link)
if !ok {
return ast.WalkContinue, nil
}
if isExternalURL(string(link.Destination)) {
link.SetAttribute([]byte("target"), []byte("_blank"))
link.SetAttribute([]byte("rel"), []byte("noopener noreferrer"))
}
return ast.WalkContinue, nil
})
}
func isExternalURL(dest string) bool {
if strings.HasPrefix(dest, "//") {
return true
}
i := strings.Index(dest, ":")
if i <= 0 {
return false
}
for _, c := range dest[:i] {
if !(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') &&
!(c >= '0' && c <= '9') && c != '+' && c != '-' && c != '.' {
return false
}
}
return true
}
type extLinksExt struct{}
func newExtLinksExt() goldmark.Extender { return &extLinksExt{} }
func (e *extLinksExt) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithASTTransformers(
util.Prioritized(extLinksTransformer{}, 999),
))
}
+40 -13
View File
@@ -36,12 +36,17 @@ func hashAsset(name string) string {
return hex.EncodeToString(sum[:])[:12]
}
var (
pageTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
editTmpl = template.Must(template.New("edit").Funcs(template.FuncMap{
// tmplFuncs is shared by every layout-based template: the edit template appends
// editorBundleVersion to its CodeMirror <script> src to cache-bust the bundle.
var tmplFuncs = template.FuncMap{
"editorBundleVersion": func() string { return editorBundleVersion },
}).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
searchTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
"fileIcon": fileIcon,
}
var (
pageTmpl = template.Must(template.New("page").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
editTmpl = template.Must(template.New("edit").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
searchTmpl = template.Must(template.New("search").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
)
// specialPage is the result returned by a pageTypeHandler.
@@ -127,13 +132,19 @@ func main() {
// so the first search after a cold start still returns correct results.
go func() {
folderIndex.buildMu.Lock()
entries := buildFolderIndex(root)
folders, files := buildIndexes(root)
now := time.Now()
folderIndex.Lock()
folderIndex.entries = entries
folderIndex.builtAt = time.Now()
folderIndex.entries = folders
folderIndex.builtAt = now
folderIndex.Unlock()
fileIndex.Lock()
fileIndex.entries = files
fileIndex.builtAt = now
fileIndex.Unlock()
folderIndex.buildMu.Unlock()
close(folderIndex.ready)
close(fileIndex.ready)
}()
if *reindexInterval > 0 {
@@ -413,9 +424,14 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
}
rawMD, _ := os.ReadFile(indexPath)
sections := splitSections(rawMD)
if sectionIndex < len(sections) {
sections[sectionIndex] = []byte(content)
// Out of range means the file changed under the editor (or the index
// never matched it). Writing back the untouched file would swallow the
// edit silently, so refuse and keep the editor's content in the browser.
if sectionIndex >= len(sections) {
http.Error(w, "section no longer exists — the page changed since you opened the editor", http.StatusConflict)
return
}
sections[sectionIndex] = []byte(content)
content = string(joinSections(sections))
// Section index ≥ 1 is a heading-anchored section. Redirect to its
// anchor so the user lands on the section they just saved, even if
@@ -428,12 +444,15 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
}
}
// A save must never remove a page. An empty POST — a truncated mobile
// request, a lost `section` field, an editor that came up blank — is
// indistinguishable from "clear this page", and deleting index.md on that
// signal loses the whole file even though the user only edited one section.
// Removing a page is the explicit ?delete action's job (moves.go).
if strings.TrimSpace(content) == "" {
if err := os.Remove(indexPath); err != nil && !os.IsNotExist(err) {
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
http.Error(w, "refusing to save empty content — use DELETE to remove this page", http.StatusBadRequest)
return
}
} else {
// Stat first so we know whether MkdirAll actually created the folder
// — if it did, the search index needs a new entry.
_, statErr := os.Stat(fsPath)
@@ -451,6 +470,14 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
folderIndexAdd(filepath.ToSlash(rel))
}
}
// The editor saves via fetch so the save and its result share one history
// entry (see assets/history-nav.js). Hand it the target instead of a 303:
// the browser would follow the redirect into a second entry, and fetch
// drops the #section fragment from a followed redirect anyway.
if r.Header.Get("X-Save-Mode") == "replace" {
w.Header().Set("X-Target", redirectTarget)
w.WriteHeader(http.StatusNoContent)
return
}
http.Redirect(w, r, redirectTarget, http.StatusSeeOther)
}
+2 -2
View File
@@ -112,9 +112,9 @@ func formatAppendEntry(title, rawURL, comment string, ts time.Time) string {
b.WriteString(escapeLinkLabel(title))
b.WriteString("](")
b.WriteString(rawURL)
b.WriteString(")</br>")
b.WriteString(")\n")
b.WriteString(ts.Format("2006-01-02 15:04"))
b.WriteString("</br>")
b.WriteString("\n")
if comment != "" {
b.WriteString(" ")
b.WriteString(comment)
+13 -2
View File
@@ -24,9 +24,9 @@ var md goldmark.Markdown
// targets against the filesystem.
func initMarkdown(root string) {
md = goldmark.New(
goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(root), newExtLinksExt()),
goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(root), newWikiEmbedExt(root)),
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
goldmark.WithRendererOptions(html.WithUnsafe()),
goldmark.WithRendererOptions(html.WithUnsafe(), html.WithHardWraps()),
)
}
@@ -36,6 +36,9 @@ type entry struct {
// ThumbURL is set for thumbnailable files; the thumbnail view renders an
// <img> when it is non-empty and falls back to Icon otherwise.
ThumbURL string
// IsVideo marks a thumbnail tile as a video so the grid can overlay a play
// badge. Only meaningful when ThumbURL is set.
IsVideo bool
// modTime/size carry the raw sort keys; the template only reads the
// formatted Meta string.
modTime time.Time
@@ -217,6 +220,7 @@ func listEntries(fsPath, urlPath, sortKey, order string) []entry {
}
if hasThumbnail(name) {
f.ThumbURL = thumbURL(path.Join(urlPath, url.PathEscape(name)), 300)
f.IsVideo = isVideoFile(name)
}
files = append(files, f)
}
@@ -228,6 +232,13 @@ func listEntries(fsPath, urlPath, sortKey, order string) []entry {
sortEntries(folders, sortName, order)
sortEntries(files, sortKey, order)
// Nothing to list (only index.md, which is rendered above): return no
// entries so the Files section is suppressed entirely rather than showing
// an empty listing with just the `..` row.
if len(folders) == 0 && len(files) == 0 {
return nil
}
// `..` row mirrors the header Up button so the listing itself is
// navigable without reaching for the header on mobile. Prepended after
// sort so it always sits at the top regardless of folder names.
+221 -55
View File
@@ -5,6 +5,7 @@ import (
"io/fs"
"log"
"net/http"
"net/url"
"path/filepath"
"sort"
"strings"
@@ -13,31 +14,65 @@ import (
"unicode"
)
// searchSectionCap bounds how many rows each results section renders. The
// section header still reports the true total so the user knows more exist.
const searchSectionCap = 10
// exactNameScore is the score scoreName assigns to a whole-name exact match.
// handleSearch peels these into the promoted "Exact Match" section.
const exactNameScore = 1000
type searchResult struct {
Name string
URL string
Path string
Score int
// Meta is the formatted "size · date" line for file results; empty for
// page results.
Meta string
}
type searchPageData struct {
Title string
EditMode bool
Query string
Results []searchResult
// Exact holds page(s) whose name equals the query exactly, promoted to
// their own section above the fuzzy matches.
Exact []searchResult
Pages []searchResult
Files []searchResult
// PageTotal/FileTotal are the true match totals; Pages/Files are capped at
// searchSectionCap for rendering.
PageTotal int
FileTotal int
IndexBuiltAt time.Time
RenderMS int64
}
// folderEntry is a single indexed directory: its forward-slash relative path
// plus pre-tokenized basename so the per-query scoring loop avoids redoing
// the lowercasing and tokenization on every keystroke.
type folderEntry struct {
// indexEntry is the shared scoreable core of both folder and file index
// entries: a forward-slash relative path plus its pre-tokenized basename so
// the per-query scoring loop avoids redoing the lowercasing and tokenization
// on every request.
type indexEntry struct {
Path string
NameLower string
NameTokens []string
}
// folderEntry is a single indexed directory. It is exactly an indexEntry; the
// alias keeps the existing folder-index code readable while letting files and
// folders share the scoring loop.
type folderEntry = indexEntry
// fileEntry is a single indexed file: its scoreable core plus the size/modtime
// captured during the walk so results can show listing-parity metadata without
// a second stat.
type fileEntry struct {
indexEntry
Size int64
ModTime time.Time
}
// folderIndex holds the in-memory directory index used by search. Writers
// always replace the entries slice wholesale so a reader that snapshots the
// header under RLock can score without holding the lock.
@@ -49,15 +84,38 @@ var folderIndex struct {
ready chan struct{}
}
// fileIndex mirrors folderIndex for files. It is held separately so file
// volume can't perturb the page index, and shares folderIndex.buildMu since
// both are populated by the same single-pass walk. It is refreshed only by the
// full rebuild (startup / ticker / manual), never by the incremental folder
// hooks — file freshness on disk lags until the next rebuild.
var fileIndex struct {
sync.RWMutex
entries []fileEntry
builtAt time.Time
ready chan struct{}
}
func init() {
folderIndex.ready = make(chan struct{})
fileIndex.ready = make(chan struct{})
}
// handleSearch renders the search results page for the query in
// r.URL.Query().Get("q"). Only invoked when path is "/" and "q" is present.
func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
query := strings.TrimSpace(r.URL.Query().Get("q"))
results, builtAt := searchWiki(query)
pages, builtAt := searchWiki(query)
files := searchFiles(query)
// searchWiki sorts by score desc, so exact-name matches (score 1000) are
// at the front; peel them into their own section and drop them from the
// fuzzy page list so they aren't shown twice.
var exact []searchResult
for len(pages) > 0 && pages[0].Score == exactNameScore {
exact = append(exact, pages[0])
pages = pages[1:]
}
title := "Search"
if query != "" {
@@ -66,7 +124,11 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
data := searchPageData{
Title: title,
Query: query,
Results: results,
Exact: exact,
Pages: capResults(pages),
Files: capResults(files),
PageTotal: len(pages),
FileTotal: len(files),
IndexBuiltAt: builtAt,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -76,6 +138,55 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
}
}
// capResults truncates a section to searchSectionCap rows for rendering.
func capResults(results []searchResult) []searchResult {
if len(results) > searchSectionCap {
return results[:searchSectionCap]
}
return results
}
// scoredEntry pairs a matched index entry with its score before it is turned
// into a UI searchResult.
type scoredEntry[T any] struct {
entry T
score int
}
// scoreEntries scores every entry against query, drops non-matches, and returns
// the survivors sorted by score (desc), then path depth (asc), then basename
// (asc). core extracts the shared scoreable fields so the folder and file
// indexes reuse one loop. fuzzy toggles the levenshtein fallback (pages yes,
// files no). Returns nil for an empty/tokenless query.
func scoreEntries[T any](entries []T, query string, fuzzy bool, core func(T) indexEntry) []scoredEntry[T] {
qLower := strings.ToLower(query)
qTokens := tokenize(qLower)
if len(qTokens) == 0 {
return nil
}
var out []scoredEntry[T]
for _, e := range entries {
c := core(e)
score := scoreName(c.NameLower, c.NameTokens, qLower, qTokens, fuzzy)
if score == 0 {
continue
}
out = append(out, scoredEntry[T]{entry: e, score: score})
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].score != out[j].score {
return out[i].score > out[j].score
}
ci, cj := core(out[i].entry), core(out[j].entry)
di, dj := strings.Count(ci.Path, "/"), strings.Count(cj.Path, "/")
if di != dj {
return di < dj
}
return ci.NameLower < cj.NameLower
})
return out
}
// searchWiki scores the cached folder index against query. Blocks on the
// initial build so the very first request after startup serves correct
// results rather than an empty list. Returns the snapshot's builtAt so the
@@ -90,45 +201,65 @@ func searchWiki(query string) ([]searchResult, time.Time) {
if query == "" {
return nil, builtAt
}
qLower := strings.ToLower(query)
qTokens := tokenize(qLower)
if len(qTokens) == 0 {
return nil, builtAt
}
var results []searchResult
for _, e := range entries {
score := scoreName(e.NameLower, e.NameTokens, qLower, qTokens)
if score == 0 {
continue
}
scored := scoreEntries(entries, query, true, func(e folderEntry) indexEntry { return e })
results := make([]searchResult, 0, len(scored))
for _, s := range scored {
results = append(results, searchResult{
Name: filepath.Base(e.Path),
URL: "/" + e.Path + "/",
Path: e.Path,
Score: score,
Name: filepath.Base(s.entry.Path),
URL: "/" + s.entry.Path + "/",
Path: s.entry.Path,
Score: s.score,
})
}
sort.SliceStable(results, func(i, j int) bool {
if results[i].Score != results[j].Score {
return results[i].Score > results[j].Score
}
di, dj := strings.Count(results[i].Path, "/"), strings.Count(results[j].Path, "/")
if di != dj {
return di < dj
}
return strings.ToLower(results[i].Name) < strings.ToLower(results[j].Name)
})
return results, builtAt
}
// searchFiles scores the cached file index against query, matching on filename
// only. Blocks on the initial build so the first request after startup doesn't
// serve an empty Files section while the walk is still running.
func searchFiles(query string) []searchResult {
<-fileIndex.ready
fileIndex.RLock()
entries := fileIndex.entries
fileIndex.RUnlock()
if query == "" {
return nil
}
scored := scoreEntries(entries, query, false, func(e fileEntry) indexEntry { return e.indexEntry })
results := make([]searchResult, 0, len(scored))
for _, s := range scored {
p := s.entry.Path
results = append(results, searchResult{
Name: filepath.Base(p),
URL: fileURL(p),
Path: p,
Score: s.score,
Meta: formatSize(s.entry.Size) + " · " + s.entry.ModTime.Format("2006-01-02"),
})
}
return results
}
// fileURL builds the browser URL for a file's forward-slash relative path,
// percent-escaping each segment so spaces/umlauts/&c. survive round-tripping
// through the companion's wikiPathFromHref decode.
func fileURL(relPath string) string {
parts := strings.Split(relPath, "/")
for i, p := range parts {
parts[i] = url.PathEscape(p)
}
return "/" + strings.Join(parts, "/")
}
// scoreName ranks how well nameLower matches the query. Whole-name exact
// match dominates; otherwise score is the sum of each token's best match
// against the words in the name. nameTokens is precomputed by the index.
func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []string) int {
// against the words in the name. nameTokens is precomputed by the index. fuzzy
// enables the levenshtein near-match fallback; the file index passes false so
// large file volumes don't pay the edit-distance cost per query.
func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []string, fuzzy bool) int {
if nameLower == qLower {
return 1000
return exactNameScore
}
score := 0
for _, qt := range qTokens {
@@ -147,7 +278,7 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
if best < 20 {
best = 20
}
case levenshtein(w, qt) <= 2:
case fuzzy && levenshtein(w, qt) <= 2:
if best < 5 {
best = 5
}
@@ -214,12 +345,15 @@ func (h *handler) handleReindex(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// buildFolderIndex walks root and returns a fresh slice of folder entries.
// Hidden directories (`.git`, `.thumbs`, …) are pruned; the root itself is
// excluded since it cannot be a search match.
func buildFolderIndex(root string) []folderEntry {
// buildIndexes walks root once and returns fresh folder and file entries. The
// single pass avoids a second full traversal on the ARMv7 NAS. Hidden
// directories (`.git`, `.thumbs`, …) are pruned and hidden files skipped; the
// root itself and every `index.md` (page content, not a browsable file) are
// excluded.
func buildIndexes(root string) ([]folderEntry, []fileEntry) {
walkRoot := resolveWalkRoot(root)
var entries []folderEntry
var folders []folderEntry
var files []fileEntry
_ = filepath.WalkDir(walkRoot, func(fsPath string, d fs.DirEntry, err error) error {
if err != nil {
return nil
@@ -227,46 +361,78 @@ func buildFolderIndex(root string) []folderEntry {
if skip, walkErr := hiddenSkip(fsPath, walkRoot, d); skip {
return walkErr
}
if !d.IsDir() || fsPath == walkRoot {
return nil
}
rel, relErr := filepath.Rel(walkRoot, fsPath)
if relErr != nil {
return nil
}
entries = append(entries, newFolderEntry(filepath.ToSlash(rel)))
relSlash := filepath.ToSlash(rel)
if d.IsDir() {
if fsPath != walkRoot {
folders = append(folders, newFolderEntry(relSlash))
}
return nil
}
if d.Name() == "index.md" {
return nil
}
info, infoErr := d.Info()
if infoErr != nil {
return nil
}
files = append(files, newFileEntry(relSlash, info.Size(), info.ModTime()))
return nil
})
return entries
return folders, files
}
// newFolderEntry builds a folderEntry from a forward-slash relative path,
// computing the lowercased basename and its tokens once so search scoring
// doesn't have to redo it per query.
func newFolderEntry(relPath string) folderEntry {
return newIndexEntry(relPath)
}
// newFileEntry builds a fileEntry, capturing the walk-time size/modtime so
// results show listing-parity metadata without a second stat.
func newFileEntry(relPath string, size int64, modTime time.Time) fileEntry {
return fileEntry{
indexEntry: newIndexEntry(relPath),
Size: size,
ModTime: modTime,
}
}
// newIndexEntry precomputes the lowercased basename and its tokens for the
// per-query scoring loop.
func newIndexEntry(relPath string) indexEntry {
name := relPath
if i := strings.LastIndex(relPath, "/"); i >= 0 {
name = relPath[i+1:]
}
nameLower := strings.ToLower(name)
return folderEntry{
return indexEntry{
Path: relPath,
NameLower: nameLower,
NameTokens: tokenize(nameLower),
}
}
// rebuildFolderIndex walks root and replaces the index entries atomically.
// buildMu serializes overlapping rebuilds (manual + ticker + startup) so
// the WalkDir cost is paid once even under contention.
// rebuildFolderIndex walks root once and atomically replaces both the folder
// and file indexes. buildMu serializes overlapping rebuilds (manual + ticker +
// startup) so the WalkDir cost is paid once even under contention.
func rebuildFolderIndex(root string) {
folderIndex.buildMu.Lock()
defer folderIndex.buildMu.Unlock()
entries := buildFolderIndex(root)
folders, files := buildIndexes(root)
now := time.Now()
folderIndex.Lock()
folderIndex.entries = entries
folderIndex.builtAt = time.Now()
folderIndex.entries = folders
folderIndex.builtAt = now
folderIndex.Unlock()
fileIndex.Lock()
fileIndex.entries = files
fileIndex.builtAt = now
fileIndex.Unlock()
}
// folderIndexAdd appends relPath as a new entry. No-op for empty/root paths.
+51 -18
View File
@@ -26,6 +26,14 @@ type Thumbnailer interface {
Generate(src io.Reader, dst io.Writer, width int) error
}
// PathThumbnailer is an optional capability for thumbnailers whose source must
// be a seekable file on disk rather than a stream (e.g. ffmpeg for video). When
// a Thumbnailer also implements this, handleThumb passes the file path directly
// and skips reading the file into memory and content-hashing it.
type PathThumbnailer interface {
GenerateFromPath(srcPath string, dst io.Writer, width int) error
}
var thumbnailers []Thumbnailer
// thumbCacheDir is set from the -cache flag at startup.
@@ -116,12 +124,40 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
}
}
digest, data, err := sourceDigest(srcFS, srcInfo)
// Path thumbnailers (video/ffmpeg) take the source path directly and key
// the cache on path+mtime+size, so the file is never read into memory.
// Content thumbnailers (images) stay content-addressed so renames reuse
// the cache entry. Either way, generation runs through the closure below.
var (
digest string
write func(dst io.Writer) error
)
if pt, ok := t.(PathThumbnailer); ok {
digest = pathDigest(srcFS, srcInfo)
write = func(dst io.Writer) error { return pt.GenerateFromPath(srcFS, dst, width) }
} else {
d, data, err := sourceDigest(srcFS, srcInfo)
if err != nil {
log.Printf("thumb digest %s: %v", rel, err)
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
return
}
digest = d
write = func(dst io.Writer) error {
var src io.Reader
if data != nil {
src = bytes.NewReader(data)
} else {
f, err := os.Open(srcFS)
if err != nil {
return err
}
defer f.Close()
src = f
}
return t.Generate(src, dst, width)
}
}
cacheFS := filepath.Join(thumbCacheDir, digest[:2], fmt.Sprintf("%s.%d.jpg", digest, width))
if _, err := os.Stat(cacheFS); err == nil {
@@ -138,21 +174,7 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
return
}
var src io.Reader
if data != nil {
src = bytes.NewReader(data)
} else {
f, err := os.Open(srcFS)
if err != nil {
log.Printf("thumb open %s: %v", rel, err)
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
return
}
defer f.Close()
src = f
}
if err := generateThumb(t, src, cacheFS, width); err != nil {
if err := generateThumb(cacheFS, write); err != nil {
log.Printf("thumb %s: %v", rel, err)
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
return
@@ -160,6 +182,14 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
serveThumb(w, r, cacheFS)
}
// pathDigest keys the cache for path-based thumbnailers on the source path plus
// its mtime and size, so the (potentially large) file is never read into memory
// just to hash it. Overwriting the file changes mtime/size and busts the entry.
func pathDigest(srcFS string, info os.FileInfo) string {
sum := sha256.Sum256(fmt.Appendf(nil, "%s\x00%d\x00%d", srcFS, info.ModTime().UnixNano(), info.Size()))
return hex.EncodeToString(sum[:])
}
// sourceDigest returns the SHA-256 hex digest of a source file's content.
// On a cache hit (path + mtime + size unchanged) the returned data is nil,
// so the caller knows to open the file itself. On a miss the file is read
@@ -191,7 +221,10 @@ func serveThumb(w http.ResponseWriter, r *http.Request, cacheFS string) {
http.ServeFile(w, r, cacheFS)
}
func generateThumb(t Thumbnailer, src io.Reader, cacheFS string, width int) error {
// generateThumb writes a thumbnail to cacheFS atomically: write builds the
// image into a temp file in the same dir, which is renamed into place only on
// success so readers never see a partial file.
func generateThumb(cacheFS string, write func(dst io.Writer) error) error {
if err := os.MkdirAll(filepath.Dir(cacheFS), 0755); err != nil {
return err
}
@@ -200,7 +233,7 @@ func generateThumb(t Thumbnailer, src io.Reader, cacheFS string, width int) erro
return err
}
tmpName := tmp.Name()
if err := t.Generate(src, tmp, width); err != nil {
if err := write(tmp); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
+10
View File
@@ -7,12 +7,22 @@ import (
"image/jpeg"
_ "image/png"
"io"
"path/filepath"
"strings"
)
func init() {
thumbnailers = append(thumbnailers, &imageThumbnailer{})
}
// isImageFile reports whether name is a raster image we can thumbnail and thus
// embed on a page. Video files also carry thumbnails but are not embeddable, so
// this keys on the image thumbnailer's own extension set rather than
// hasThumbnail.
func isImageFile(name string) bool {
return (&imageThumbnailer{}).CanHandle(strings.ToLower(filepath.Ext(name)))
}
type imageThumbnailer struct{}
func (it *imageThumbnailer) CanHandle(ext string) bool {
+136
View File
@@ -0,0 +1,136 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
func init() {
thumbnailers = append(thumbnailers, &videoThumbnailer{})
}
// ffmpegPath is resolved once at startup. Empty means ffmpeg is not installed,
// in which case the video thumbnailer disables itself and videos keep their
// icon. ffmpeg is a graceful soft-dependency, not a hard requirement: the
// binary still runs standalone without it.
var ffmpegPath, _ = exec.LookPath("ffmpeg")
type videoThumbnailer struct{}
func isVideoExt(ext string) bool {
switch ext {
case ".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v":
return true
}
return false
}
// isVideoFile reports whether name is a video container we generate previews
// for. Independent of ffmpeg availability — used to tag tiles with a play badge.
func isVideoFile(name string) bool {
return isVideoExt(strings.ToLower(filepath.Ext(name)))
}
func (vt *videoThumbnailer) CanHandle(ext string) bool {
return ffmpegPath != "" && isVideoExt(ext)
}
// Generate satisfies Thumbnailer but is never used: handleThumb routes video
// through the PathThumbnailer branch (GenerateFromPath), since ffmpeg needs a
// seekable file path and we must not read large videos into memory.
func (vt *videoThumbnailer) Generate(src io.Reader, dst io.Writer, width int) error {
return fmt.Errorf("video thumbnails require a file path; use GenerateFromPath")
}
// GenerateFromPath extracts a single frame and writes it to dst as JPEG. It
// prefers a frame from the middle of the clip (see seekOffsets) to avoid the
// black/fade-in frames common at the very start, falling back to earlier
// offsets. The (small) JPEG is buffered first so a failed attempt never leaves
// partial bytes in dst.
func (vt *videoThumbnailer) GenerateFromPath(srcPath string, dst io.Writer, width int) error {
for _, seek := range seekOffsets(srcPath) {
var buf bytes.Buffer
if err := runFFmpegFrame(srcPath, &buf, width, seek); err == nil && buf.Len() > 0 {
_, err = dst.Write(buf.Bytes())
return err
}
}
return fmt.Errorf("ffmpeg produced no frame for %s", srcPath)
}
// seekOffsets returns seek targets (in seconds) to try in order. When the clip
// duration is known the midpoint comes first — a representative frame that
// dodges black intros and end credits. "1" and "0" are fallbacks for when the
// duration is unknown or the midpoint seek yields nothing (very short clips, or
// a keyframe gap at the midpoint).
func seekOffsets(srcPath string) []string {
if d := videoDurationSec(srcPath); d > 2 {
return []string{strconv.FormatFloat(d/2, 'f', 2, 64), "1", "0"}
}
return []string{"1", "0"}
}
// videoDurationSec parses the clip duration from ffmpeg's own stderr, so it
// depends only on ffmpeg (ffprobe is not installed everywhere we deploy).
// `ffmpeg -i <file>` with no output exits non-zero but prints the container
// metadata, including a "Duration: HH:MM:SS.ss" line. Returns 0 when the
// duration can't be determined, so the caller falls back to a fixed offset.
func videoDurationSec(srcPath string) float64 {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, ffmpegPath, "-i", srcPath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
_ = cmd.Run() // expected to "fail": no output file specified. We want stderr.
return parseFFmpegDuration(stderr.String())
}
// parseFFmpegDuration extracts seconds from an ffmpeg log containing a line like
// " Duration: 00:01:23.45, start: 0.000000, bitrate: 1234 kb/s".
func parseFFmpegDuration(log string) float64 {
_, after, ok := strings.Cut(log, "Duration:")
if !ok {
return 0
}
field := strings.TrimSpace(after)
if c := strings.IndexByte(field, ','); c >= 0 {
field = field[:c]
}
parts := strings.Split(strings.TrimSpace(field), ":")
if len(parts) != 3 {
return 0
}
h, err1 := strconv.ParseFloat(parts[0], 64)
m, err2 := strconv.ParseFloat(parts[1], 64)
s, err3 := strconv.ParseFloat(parts[2], 64)
if err1 != nil || err2 != nil || err3 != nil {
return 0
}
return h*3600 + m*60 + s
}
func runFFmpegFrame(srcPath string, dst io.Writer, width int, seek string) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, ffmpegPath,
"-ss", seek,
"-i", srcPath,
"-frames:v", "1",
// Cap width at the requested size but never upscale (matches the image
// thumbnailer); -2 keeps the height even. The comma in min() is escaped
// so ffmpeg does not read it as a filter separator.
"-vf", "scale='min(iw\\,"+strconv.Itoa(width)+")':-2",
"-f", "mjpeg",
"-q:v", "5",
"pipe:1",
)
cmd.Stdout = dst
return cmd.Run()
}
+27 -4
View File
@@ -115,6 +115,23 @@ func wikiTargetHref(target string) string {
return b.String()
}
// wikiFileHref converts a wiki target to a URL href for the raw file: each
// segment is percent-encoded and, unlike wikiTargetHref, no trailing slash is
// appended. Used for image-embed click-through so the href points straight at
// the file (companion click-interception skips anything ending in "/").
func wikiFileHref(target string) string {
target = normalizeWikiTarget(target)
if target == "/" {
return "/"
}
var b strings.Builder
for _, seg := range strings.Split(strings.TrimPrefix(target, "/"), "/") {
b.WriteByte('/')
b.WriteString(url.PathEscape(seg))
}
return b.String()
}
// wikiTargetExists reports whether the on-disk path backing the target exists
// under root. Any existing path — file or folder — counts as resolved; only a
// missing path is treated as broken.
@@ -148,13 +165,20 @@ func (r *wikiLinkRenderer) render(w util.BufWriter, source []byte, node ast.Node
return ast.WalkContinue, nil
}
n := node.(*wikiLinkNode)
target := string(n.Target)
writeWikiLink(w, r.root, string(n.Target), string(n.Display))
return ast.WalkContinue, nil
}
// writeWikiLink renders a wiki-link anchor for target with an optional display
// override. An empty display falls back to the target's last segment; a target
// that does not resolve on disk gets the `broken` class. Shared by the wiki-link
// renderer and the embed renderer's link fallback.
func writeWikiLink(w util.BufWriter, root, target, display string) {
href := wikiTargetHref(target)
display := string(n.Display)
if display == "" {
display = wikiDefaultDisplay(target)
}
broken := !wikiTargetExists(r.root, target)
broken := !wikiTargetExists(root, target)
w.WriteString(`<a href="`)
w.WriteString(href)
@@ -165,7 +189,6 @@ func (r *wikiLinkRenderer) render(w util.BufWriter, source []byte, node ast.Node
w.WriteString(`>`)
w.Write(util.EscapeHTML([]byte(display)))
w.WriteString(`</a>`)
return ast.WalkContinue, nil
}
type wikiLinkExt struct{ root string }