Compare commits
37 Commits
db17f94627
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f78ebe7d5d | |||
| d943fc5596 | |||
| e9061a221c | |||
| 5a5305dd42 | |||
| ab57377034 | |||
| 65983ec6be | |||
| 52bc694ffa | |||
| e37dde1e40 | |||
| 3abcc94eca | |||
| 8b9f47f793 | |||
| 85527cfde8 | |||
| 5f835a8a4e | |||
| 1071dbaa09 | |||
| da30bb8fc3 | |||
| a849474b26 | |||
| 49c6240416 | |||
| ddfd29ea0e | |||
| 3f2beafd94 | |||
| 28d22c040f | |||
| 34f750beff | |||
| 5303645173 | |||
| d0325fdec5 | |||
| 22059a0b06 | |||
| aa95d04d9e | |||
| 08be8c68fe | |||
| a407a2eeaa | |||
| 75d6c4d430 | |||
| 5e72b073b8 | |||
| 8c9b448bdc | |||
| ecaf3f4e12 | |||
| 1055c110f4 | |||
| 5d069683c4 | |||
| 9f6a611dff | |||
| fa22244709 | |||
| d92c2f008d | |||
| 7346050b51 | |||
| 1619240905 |
@@ -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
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
})();
|
||||
|
||||
@@ -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,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
@@ -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 ---
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}());
|
||||
+22
-15
@@ -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,21 +31,25 @@
|
||||
{{end}}
|
||||
<div class="header-actions row">{{block "headerActions" .}}{{end}}</div>
|
||||
</header>
|
||||
<div class="page-wrap">
|
||||
{{if not .EditMode}}<aside class="tree-sidebar"></aside>{{end}}
|
||||
<main>
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
<aside class="sidebar">{{block "sidebar" .}}{{end}}</aside>
|
||||
<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>
|
||||
<footer>
|
||||
<span class="muted">Request: {{.RenderMS}} ms</span>
|
||||
{{block "footerExtras" .}}{{end}}
|
||||
<span class="dropdown companion-status" data-companion-status hidden>
|
||||
<button type="button" class="btn btn-small companion-icon" data-action="companion-toggle" title="Companion status" aria-label="Companion status">○</button>
|
||||
<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>
|
||||
<footer>
|
||||
<span class="muted">Request: {{.RenderMS}} ms</span>
|
||||
{{block "footerExtras" .}}{{end}}
|
||||
<span class="dropdown companion-status" data-companion-status hidden>
|
||||
<button type="button" class="btn btn-small companion-icon" data-action="companion-toggle" title="Companion status" aria-label="Companion status">○</button>
|
||||
<div class="dropdown-menu align-right open-up companion-flyout"></div>
|
||||
</span>
|
||||
</footer>
|
||||
{{block "extras" .}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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}}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
})();
|
||||
@@ -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
@@ -4,17 +4,38 @@
|
||||
|
||||
{{define "content"}}
|
||||
{{if .Query}}
|
||||
{{if .Results}}
|
||||
<p class="muted">{{len .Results}} match{{if ne (len .Results) 1}}es{{end}} for “{{.Query}}”</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 “{{.Query}}”.</p>
|
||||
<p class="muted">No page named “{{.Query}}” — <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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const version = "1"
|
||||
const version = "2"
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
@@ -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§ion=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
|
||||
return yearURL + "#" + 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
|
||||
@@ -353,13 +345,11 @@ 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
|
||||
DisplayYear int
|
||||
DisplayMonth int
|
||||
DiaryURL string
|
||||
Months []calMonthGrid
|
||||
Years []calYear
|
||||
}
|
||||
|
||||
var diaryCalTmpl = template.Must(template.ParseFS(assets, "assets/diary/calendar.html"))
|
||||
@@ -489,13 +479,11 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int)
|
||||
sort.Slice(years, func(i, j int) bool { return years[i].Num > years[j].Num })
|
||||
|
||||
data := calendarData{
|
||||
DisplayYear: displayYear,
|
||||
DisplayMonth: displayMonth,
|
||||
DisplayMonthName: months[displayMonth-1].Name,
|
||||
DiaryURL: diaryRootURL,
|
||||
YearURL: yearURL,
|
||||
Months: months,
|
||||
Years: years,
|
||||
DisplayYear: displayYear,
|
||||
DisplayMonth: displayMonth,
|
||||
DiaryURL: diaryRootURL,
|
||||
Months: months,
|
||||
Years: years,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -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 "")
|
||||
// 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
@@ -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),
|
||||
))
|
||||
}
|
||||
@@ -36,12 +36,17 @@ func hashAsset(name string) string {
|
||||
return hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
|
||||
// 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 },
|
||||
"fileIcon": fileIcon,
|
||||
}
|
||||
|
||||
var (
|
||||
pageTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
|
||||
editTmpl = template.Must(template.New("edit").Funcs(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"))
|
||||
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,30 +444,41 @@ 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)
|
||||
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)
|
||||
newlyCreated := os.IsNotExist(statErr)
|
||||
if err := os.MkdirAll(fsPath, 0755); err != nil {
|
||||
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(indexPath, []byte(content), 0644); err != nil {
|
||||
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if newlyCreated {
|
||||
if rel, err := filepath.Rel(h.root, fsPath); err == nil {
|
||||
folderIndexAdd(filepath.ToSlash(rel))
|
||||
}
|
||||
http.Error(w, "refusing to save empty content — use DELETE to remove this page", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// 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)
|
||||
newlyCreated := os.IsNotExist(statErr)
|
||||
if err := os.MkdirAll(fsPath, 0755); err != nil {
|
||||
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(indexPath, []byte(content), 0644); err != nil {
|
||||
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if newlyCreated {
|
||||
if rel, err := filepath.Rel(h.root, fsPath); err == nil {
|
||||
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
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
Title string
|
||||
EditMode bool
|
||||
Query string
|
||||
// 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.
|
||||
|
||||
@@ -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,11 +124,39 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
digest, data, err := sourceDigest(srcFS, srcInfo)
|
||||
if err != nil {
|
||||
log.Printf("thumb digest %s: %v", rel, err)
|
||||
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
|
||||
return
|
||||
// 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))
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user