Compare commits
39 Commits
7fe0013a5c
..
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 | |||
| db17f94627 | |||
| 9ed6475775 |
@@ -1,130 +1,7 @@
|
|||||||
# CLAUDE.md
|
# 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**.
|
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.
|
||||||
No database, no CMS, no abstraction layer — every folder is a page, and `index.md`
|
|
||||||
in a folder is that page's content.
|
|
||||||
|
|
||||||
## Build & Deploy
|
I'm an experienced developer. Do not explain syntax, APIs, programming concepts, or implementation details unless explicitly asked.
|
||||||
|
|
||||||
```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
|
|
||||||
|
|||||||
+27
-3
@@ -116,9 +116,16 @@
|
|||||||
if (!state.available) return;
|
if (!state.available) return;
|
||||||
document.addEventListener('click', function (e) {
|
document.addEventListener('click', function (e) {
|
||||||
if (!e.target.closest) return;
|
if (!e.target.closest) return;
|
||||||
// Match both listing styles: table rows expose the file link inside
|
// Match every listing style: table rows expose the file link inside
|
||||||
// a .list-item row; thumbnail tiles are bare a.thumb-tile anchors.
|
// a .list-item row; thumbnail tiles are bare a.thumb-tile anchors;
|
||||||
var anchor = e.target.closest('.list-item a, a.thumb-tile');
|
// 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.
|
||||||
|
// 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;
|
if (!anchor) return;
|
||||||
var item = anchor.closest('.list-item');
|
var item = anchor.closest('.list-item');
|
||||||
// Only intercept the primary file link, and only for files (not folders).
|
// Only intercept the primary file link, and only for files (not folders).
|
||||||
@@ -150,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() {
|
function probeStatus() {
|
||||||
return companionGET('/status').then(function (r) {
|
return companionGET('/status').then(function (r) {
|
||||||
if (!r.ok) throw new Error('status ' + r.status);
|
if (!r.ok) throw new Error('status ' + r.status);
|
||||||
@@ -168,6 +191,7 @@
|
|||||||
updateFooterIcon();
|
updateFooterIcon();
|
||||||
wireFileLinks();
|
wireFileLinks();
|
||||||
wireRevealButton();
|
wireRevealButton();
|
||||||
|
wireFileRevealButtons();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,24 @@
|
|||||||
<div class="diary-cal panel panel-sidebar"
|
<div class="diary-cal panel panel-sidebar"
|
||||||
data-display-year="{{.DisplayYear}}"
|
data-display-year="{{.DisplayYear}}"
|
||||||
data-display-month="{{.DisplayMonth}}">
|
data-display-month="{{.DisplayMonth}}">
|
||||||
<div class="panel-header"><a href="{{.DiaryURL}}">Chronological</a></div>
|
<div class="panel-header row">
|
||||||
<div class="diary-cal-nav">
|
<a href="{{.DiaryURL}}">Chronological</a>
|
||||||
<div class="dropdown diary-cal-drop">
|
<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>
|
<button type="button" class="btn" data-action="cal-year-drop" aria-expanded="false" title="Jahr wählen">{{.DisplayYear}} ▾</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>
|
|
||||||
<div class="dropdown-menu align-right scrollable">
|
<div class="dropdown-menu align-right scrollable">
|
||||||
{{range .Years}}<a class="btn btn-block{{if .IsCurrent}} cal-current{{end}}" href="{{.URL}}">{{.Num}}</a>{{end}}
|
{{range .Years}}<a class="btn btn-block{{if .IsCurrent}} cal-current{{end}}" href="{{.URL}}">{{.Num}}</a>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{range .Months}}
|
{{range $i, $m := .Months}}
|
||||||
<table class="diary-cal-grid" data-cal-month="{{.Num}}"{{if ne .Num $.DisplayMonth}} hidden{{end}}>
|
{{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>
|
<thead>
|
||||||
<tr><th>Mo</th><th>Di</th><th>Mi</th><th>Do</th><th>Fr</th><th>Sa</th><th>So</th></tr>
|
<tr><th>Mo</th><th>Di</th><th>Mi</th><th>Do</th><th>Fr</th><th>Sa</th><th>So</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+30
-34
@@ -3,47 +3,43 @@
|
|||||||
if (!cal) return;
|
if (!cal) return;
|
||||||
cal.querySelectorAll(".dropdown > button").forEach(wireDropdown);
|
cal.querySelectorAll(".dropdown > button").forEach(wireDropdown);
|
||||||
|
|
||||||
var displayYear = parseInt(cal.dataset.displayYear, 10);
|
var displayMonth = parseInt(cal.dataset.displayMonth, 10);
|
||||||
var current = parseInt(cal.dataset.displayMonth, 10);
|
|
||||||
var monthLabel = cal.querySelector("[data-cal-month-link]");
|
|
||||||
var months = {};
|
var months = {};
|
||||||
cal.querySelectorAll("[data-cal-month]").forEach(function (t) {
|
cal.querySelectorAll("[data-cal-month]").forEach(function (t) {
|
||||||
months[parseInt(t.dataset.calMonth, 10)] = 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) {
|
// Centering only applies inside the persistent desktop rail (.sidebar). On
|
||||||
if (m === current) return;
|
// mobile the widget is re-parented into .overlay-body, where we leave the
|
||||||
if (!months[m]) return;
|
// grids stacked from January and do not scroll (design decision).
|
||||||
months[current].hidden = true;
|
function railContainer() {
|
||||||
months[m].hidden = false;
|
var el = cal.parentElement;
|
||||||
current = m;
|
while (el) {
|
||||||
if (monthLabel) {
|
if (el.classList) {
|
||||||
var label = jumpLinks[m];
|
if (el.classList.contains("overlay-body")) return null;
|
||||||
if (label) monthLabel.textContent = " " + label.textContent + " ";
|
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
|
// Scroll only the rail so the given month grid sits in its vertical center;
|
||||||
// so the calendar reflects what the user just navigated to.
|
// the main document/window position is untouched (priority: don't disturb
|
||||||
Object.keys(jumpLinks).forEach(function (key) {
|
// the reading position).
|
||||||
var a = jumpLinks[key];
|
function centerMonth(m) {
|
||||||
a.addEventListener("click", function () { show(parseInt(key, 10)); });
|
var grid = months[m];
|
||||||
});
|
if (!grid) return;
|
||||||
|
var container = railContainer();
|
||||||
// Any in-page anchor click (#YYYY-MM or #YYYY-MM-DD) updates the calendar
|
if (!container) return;
|
||||||
// so it tracks the user's focus through the year page.
|
var offset = grid.getBoundingClientRect().top -
|
||||||
function syncFromHash() {
|
container.getBoundingClientRect().top + container.scrollTop;
|
||||||
var h = window.location.hash;
|
container.scrollTop = offset - (container.clientHeight - grid.clientHeight) / 2;
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
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}}
|
{{if .Photos}}
|
||||||
<div class="photo-grid">
|
<div class="photo-grid">
|
||||||
{{range .Photos}}
|
{{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}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{{define "headerActions"}}
|
{{define "headerActions"}}
|
||||||
<a class="btn" href="{{.PostURL}}">CANCEL</a>
|
<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}}
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{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 = [
|
var tableKeymap = [
|
||||||
{ key: 'Shift-Enter', run: tableKey(T.insertRowBelow) },
|
{ key: 'Shift-Enter', run: tableKey(T.insertRowBelow) },
|
||||||
{ key: 'Shift-Delete', run: tableKey(T.deleteRow) },
|
{ 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({
|
var state = CM.EditorState.create({
|
||||||
doc: hidden.value,
|
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: [
|
extensions: [
|
||||||
CM.history(),
|
CM.history(),
|
||||||
CM.drawSelection(),
|
CM.drawSelection(),
|
||||||
@@ -140,7 +165,46 @@
|
|||||||
function syncContent() {
|
function syncContent() {
|
||||||
hidden.value = view.state.doc.toString();
|
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 ---
|
// --- Actions ---
|
||||||
|
|
||||||
|
|||||||
@@ -262,8 +262,94 @@ window.EditorTables = (function () {
|
|||||||
return formatTableText(newLines.join('\n'), Math.min(newCursor, newLines.join('\n').length));
|
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 {
|
return {
|
||||||
formatTableText: formatTableText,
|
formatTableText: formatTableText,
|
||||||
|
nextRowSameColumn: nextRowSameColumn,
|
||||||
|
nextCell: nextCell,
|
||||||
|
prevCell: prevCell,
|
||||||
setColumnAlignment: setColumnAlignment,
|
setColumnAlignment: setColumnAlignment,
|
||||||
insertColumn: insertColumn,
|
insertColumn: insertColumn,
|
||||||
deleteColumn: deleteColumn,
|
deleteColumn: deleteColumn,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'E':
|
case 'E':
|
||||||
e.preventDefault();
|
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';
|
window.location.href = window.location.pathname + '?edit';
|
||||||
break;
|
break;
|
||||||
case 'N':
|
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);
|
||||||
|
});
|
||||||
|
}());
|
||||||
+13
-5
@@ -10,16 +10,19 @@
|
|||||||
<link rel="stylesheet" href="/_/style.css" />
|
<link rel="stylesheet" href="/_/style.css" />
|
||||||
<script src="/_/modal.js"></script>
|
<script src="/_/modal.js"></script>
|
||||||
<script src="/_/global-shortcuts.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="/_/search-suggest.js" defer></script>
|
||||||
<script src="/_/tree-picker.js"></script>
|
<script src="/_/tree-picker.js"></script>
|
||||||
<script src="/_/companion.js" defer></script>
|
<script src="/_/companion.js" defer></script>
|
||||||
|
{{if not .EditMode}}<script src="/_/overlay.js" defer></script>
|
||||||
|
<script src="/_/tree-sidebar.js" defer></script>{{end}}
|
||||||
{{block "headScripts" .}}{{end}}
|
{{block "headScripts" .}}{{end}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<nav class="breadcrumb row">
|
<nav class="breadcrumb row">
|
||||||
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg></a>
|
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a>
|
||||||
{{if .ParentURL}}<a class="nav-up" href="{{.ParentURL}}" tabindex="-1" title="Up" aria-label="Up">Up <svg viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square"><path d="M8 13V3M3 8l5-5 5 5"/></svg></a>{{end}}
|
|
||||||
</nav>
|
</nav>
|
||||||
{{if not .EditMode}}
|
{{if not .EditMode}}
|
||||||
<form class="search-form" action="/" method="get">
|
<form class="search-form" action="/" method="get">
|
||||||
@@ -28,12 +31,14 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
<div class="header-actions row">{{block "headerActions" .}}{{end}}</div>
|
<div class="header-actions row">{{block "headerActions" .}}{{end}}</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="page-wrap">
|
<div class="shell">
|
||||||
|
{{if not .EditMode}}<aside class="tree-sidebar col">
|
||||||
|
<div class="tree-scroll"></div>
|
||||||
|
</aside>{{end}}
|
||||||
|
<div class="center">
|
||||||
<main>
|
<main>
|
||||||
{{block "content" .}}{{end}}
|
{{block "content" .}}{{end}}
|
||||||
</main>
|
</main>
|
||||||
<aside class="sidebar">{{block "sidebar" .}}{{end}}</aside>
|
|
||||||
</div>
|
|
||||||
<footer>
|
<footer>
|
||||||
<span class="muted">Request: {{.RenderMS}} ms</span>
|
<span class="muted">Request: {{.RenderMS}} ms</span>
|
||||||
{{block "footerExtras" .}}{{end}}
|
{{block "footerExtras" .}}{{end}}
|
||||||
@@ -42,6 +47,9 @@
|
|||||||
<div class="dropdown-menu align-right open-up companion-flyout"></div>
|
<div class="dropdown-menu align-right open-up companion-flyout"></div>
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
</div>
|
||||||
|
{{if not .EditMode}}<aside class="sidebar">{{block "sidebar" .}}{{end}}</aside>{{end}}
|
||||||
|
</div>
|
||||||
{{block "extras" .}}{{end}}
|
{{block "extras" .}}{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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.src = 'https://icons.duckduckgo.com/ip3/' + hostname + '.ico';
|
||||||
img.width = 16;
|
img.width = 16;
|
||||||
img.height = 16;
|
img.height = 16;
|
||||||
img.style.verticalAlign = 'middle';
|
img.style.verticalAlign = 'center';
|
||||||
img.style.marginRight = '3px';
|
img.style.marginRight = '3px';
|
||||||
a.prepend(img);
|
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 "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"}}
|
{{define "content"}}
|
||||||
{{if .Content}}
|
{{if .Content}}
|
||||||
<div class="content">{{.Content}}</div>
|
<div class="content">{{.Content}}</div>
|
||||||
@@ -13,7 +31,7 @@
|
|||||||
<div class="thumb-grid">
|
<div class="thumb-grid">
|
||||||
{{range .Entries}}
|
{{range .Entries}}
|
||||||
<a class="thumb-tile" href="{{.URL}}" title="{{.Name}}">
|
<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>
|
<span class="thumb-label truncate">{{.Name}}</span>
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -49,15 +67,4 @@
|
|||||||
<script src="/_/page/sidebar-fab.js"></script>
|
<script src="/_/page/sidebar-fab.js"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "sidebar"}}{{if .CanEdit}}<nav class="actions panel panel-sidebar">
|
{{define "sidebar"}}{{if .SidebarWidget}}{{.SidebarWidget}}{{end}}{{end}}
|
||||||
<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}}
|
|
||||||
|
|||||||
@@ -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 () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
var aside = document.querySelector("aside.sidebar");
|
var aside = document.querySelector("aside.sidebar");
|
||||||
if (!aside || !aside.children.length) return;
|
if (!aside || !aside.children.length) return;
|
||||||
|
|
||||||
var fab = document.createElement("button");
|
var fab = document.createElement("button");
|
||||||
fab.type = "button";
|
fab.type = "button";
|
||||||
fab.className = "btn btn-fab fab";
|
fab.className = "btn btn-fab fab fab-rail";
|
||||||
fab.title = "Menu";
|
fab.title = "Contents";
|
||||||
fab.setAttribute("aria-label", "Menu");
|
fab.setAttribute("aria-label", "Contents");
|
||||||
fab.setAttribute("aria-expanded", "false");
|
|
||||||
fab.textContent = "≡";
|
fab.textContent = "≡";
|
||||||
fab.addEventListener("click", function () {
|
fab.addEventListener("click", function () {
|
||||||
var open = aside.classList.toggle("is-open");
|
if (typeof openOverlay === "function") openOverlay(aside);
|
||||||
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");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
document.body.appendChild(fab);
|
document.body.appendChild(fab);
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-1
@@ -28,6 +28,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
});
|
});
|
||||||
nav.appendChild(list);
|
nav.appendChild(list);
|
||||||
|
|
||||||
|
// Stack the TOC on top of any server-rendered widget(s) already in the rail.
|
||||||
var rail = document.querySelector("aside.sidebar");
|
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 () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
wireDropdown(document.querySelector('[data-action="actions-drop"]'));
|
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
|
// Focus the search input on results pages so Tab steps directly into the
|
||||||
// first match — the input sits immediately before the results in DOM
|
// first match — the input sits immediately before the results in DOM
|
||||||
// order, so the natural tab sequence is input → first result → next, …
|
// order, so the natural tab sequence is input → first result → next, …
|
||||||
|
|||||||
+26
-5
@@ -4,17 +4,38 @@
|
|||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{if .Query}}
|
{{if .Query}}
|
||||||
{{if .Results}}
|
{{if .Exact}}
|
||||||
<p class="muted">{{len .Results}} match{{if ne (len .Results) 1}}es{{end}} for “{{.Query}}”</p>
|
<h2 class="search-section">Exact Match</h2>
|
||||||
<hr/>
|
{{range .Exact}}
|
||||||
{{range .Results}}
|
|
||||||
<article class="search-card">
|
<article class="search-card">
|
||||||
<a href="{{.URL}}">{{.Name}}</a>
|
<a href="{{.URL}}">{{.Name}}</a>
|
||||||
<div class="muted">/{{.Path}}</div>
|
<div class="muted">/{{.Path}}</div>
|
||||||
</article>
|
</article>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{else}}
|
{{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}}
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="empty">Enter a query above.</p>
|
<p class="empty">Enter a query above.</p>
|
||||||
|
|||||||
+296
-128
@@ -24,9 +24,14 @@
|
|||||||
--link-hover: #d6d24d;
|
--link-hover: #d6d24d;
|
||||||
--danger: #c40141;
|
--danger: #c40141;
|
||||||
--danger-hover: #d03467;
|
--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: 1px solid var(--secondary);
|
||||||
--border-dashed: 1px dashed 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-1: 0.25rem;
|
||||||
--space-2: 0.5rem;
|
--space-2: 0.5rem;
|
||||||
@@ -37,28 +42,35 @@
|
|||||||
--font-xs: 0.75rem;
|
--font-xs: 0.75rem;
|
||||||
--font-sm: 0.85rem;
|
--font-sm: 0.85rem;
|
||||||
|
|
||||||
/* Height of the sticky top header. Single source of truth for the
|
/* Width of the persistent left folder-tree rail (desktop). */
|
||||||
sidebar/TOC top offsets and anchor scroll-padding. Hardcoded (the
|
--tree-width: 15rem;
|
||||||
desktop header is a single-row grid); revisit if the header wraps. */
|
|
||||||
--header-h: 3.25rem;
|
/* 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.
|
/* Anchor / TOC jumps are handled by the .center scroll container's own
|
||||||
Harmless on mobile view mode (header not sticky there) — adds only a
|
scroll-padding-top (the header no longer overlaps content in the app-shell),
|
||||||
small gap above the target. */
|
so no html-level scroll padding is needed. */
|
||||||
html { scroll-padding-top: var(--header-h); }
|
|
||||||
|
|
||||||
/* === Base === */
|
/* === 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 {
|
body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
min-height: 100vh;
|
height: 100dvh;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: auto;
|
overflow: hidden;
|
||||||
font: 1rem "Iosevka Etoile", monospace;
|
font: 1rem "Iosevka Etoile", monospace;
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-rows: auto 1fr;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
@@ -83,41 +95,63 @@ hr { border: none; border-top: var(--border-dashed); margin: var(--space-4) 0; }
|
|||||||
.space-between { justify-content: space-between; }
|
.space-between { justify-content: space-between; }
|
||||||
.divider-dashed { border-bottom: var(--border-dashed); }
|
.divider-dashed { border-bottom: var(--border-dashed); }
|
||||||
|
|
||||||
/* === Page layout ===
|
/* === Page layout (app-shell) ===
|
||||||
Note: sticky positioning on .sidebar depends on no ancestor having
|
The shell is a full-height flex row below the header: left rail | center |
|
||||||
overflow: auto/hidden. If you add scroll containment above this, sticky
|
right rail. Each column owns its own vertical scroll (overflow-y:auto +
|
||||||
will silently break. */
|
min-height:0), so nothing scrolls the page as a whole. Edge-to-edge: no
|
||||||
.page-wrap {
|
centered max-width container. The right rail collapses out of the flex flow
|
||||||
display: grid;
|
when empty (:empty { display:none }), letting the center reclaim the width. */
|
||||||
grid-template-columns: minmax(0, 1fr) 14rem;
|
.shell {
|
||||||
gap: var(--space-5);
|
display: flex;
|
||||||
max-width: 1280px;
|
min-height: 0;
|
||||||
margin: 0 auto;
|
overflow: hidden;
|
||||||
padding: 0 var(--space-4);
|
}
|
||||||
width: 100%;
|
/* Center column: main content plus the footer beneath it, scrolling together.
|
||||||
flex: 1;
|
Content and footer are held to a comfortable reading width and centered
|
||||||
align-items: start;
|
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 ===
|
/* === Header / footer ===
|
||||||
Three-column grid (breadcrumbs left, search centre, actions right) so the
|
Header is the app-shell's fixed top row (grid row 1 of <body>): full width,
|
||||||
centre stays reserved even when search is hidden in editor mode. Mobile
|
never scrolls. Three-column grid (breadcrumbs left, search centre, actions
|
||||||
(≤1100px) collapses to a two-row layout — see responsive block below. */
|
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 {
|
header {
|
||||||
position: sticky;
|
position: relative;
|
||||||
top: 0;
|
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border-bottom: var(--border-dashed);
|
border-bottom: var(--border-dashed);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr minmax(0, auto) 1fr;
|
grid-template-columns: 1fr minmax(0, 50rem) 1fr;
|
||||||
grid-template-areas: "crumbs search actions";
|
grid-template-areas: "crumbs search actions";
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
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 {
|
footer {
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--reading-width);
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border-top: var(--border-dashed);
|
border-top: var(--border-dashed);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -128,13 +162,6 @@ footer {
|
|||||||
.breadcrumb { grid-area: crumbs; gap: var(--space-1); min-width: 0; }
|
.breadcrumb { grid-area: crumbs; gap: var(--space-1); min-width: 0; }
|
||||||
.header-actions { grid-area: actions; justify-content: flex-end; flex-wrap: wrap; }
|
.header-actions { grid-area: actions; justify-content: flex-end; flex-wrap: wrap; }
|
||||||
.logo { width: 1.1em; height: 1.1em; vertical-align: center; }
|
.logo { width: 1.1em; height: 1.1em; vertical-align: center; }
|
||||||
.nav-up {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--secondary);
|
|
||||||
padding: 0 var(--space-1);
|
|
||||||
}
|
|
||||||
.nav-up:hover { color: var(--primary-hover); }
|
|
||||||
|
|
||||||
/* === Panel ===
|
/* === Panel ===
|
||||||
Bordered container recipe shared by listings, sidebar widgets, the tree
|
Bordered container recipe shared by listings, sidebar widgets, the tree
|
||||||
@@ -281,6 +308,42 @@ main > h2 {
|
|||||||
text-decoration: line-through;
|
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 {
|
a.heading-anchor {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-right: 0.4em;
|
margin-right: 0.4em;
|
||||||
@@ -297,8 +360,13 @@ a.heading-anchor:hover { color: var(--primary-hover); }
|
|||||||
.data-table th,
|
.data-table th,
|
||||||
.data-table td { padding: 0.4rem var(--space-3); text-align: left; }
|
.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 + tr { border-top: var(--border); }
|
||||||
.data-table:not(.data-table-grid) tbody tr:hover,
|
|
||||||
.data-table tr.is-active { background: var(--bg-panel-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,
|
||||||
.data-table tbody tr.is-empty:hover {
|
.data-table tbody tr.is-empty:hover {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -378,6 +446,12 @@ a.heading-anchor:hover { color: var(--primary-hover); }
|
|||||||
background: var(--bg-panel-hover);
|
background: var(--bg-panel-hover);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scrollbar-width: none;
|
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::-webkit-scrollbar { display: none; }
|
||||||
.editor-toolbar > * { flex-shrink: 0; }
|
.editor-toolbar > * { flex-shrink: 0; }
|
||||||
@@ -390,14 +464,22 @@ a.heading-anchor:hover { color: var(--primary-hover); }
|
|||||||
|
|
||||||
/* === Edit form === */
|
/* === Edit form === */
|
||||||
.edit-form { display: flex; flex-direction: column; }
|
.edit-form { display: flex; flex-direction: column; }
|
||||||
/* The sidebar is always empty while editing, so the editor uses the full
|
/* No rails are rendered while editing, so the editor uses the full center
|
||||||
viewport: drop the reserved 14rem sidebar track and the centered max-width. */
|
column: drop the reading-width cap on main (and the footer) so the toolbar
|
||||||
body.edit-mode .page-wrap { grid-template-columns: minmax(0, 1fr); max-width: none; }
|
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)
|
/* 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
|
lives in the CM theme (editor-build/entry.js), keyed off the same :root
|
||||||
variables; this only sizes the container. */
|
variables; this only sizes the container. */
|
||||||
.editor-cm { min-height: 60vh; }
|
.editor-cm { min-height: 60vh; }
|
||||||
.editor-cm .cm-editor { height: 100%; }
|
.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 === */
|
||||||
.search-form {
|
.search-form {
|
||||||
@@ -406,28 +488,32 @@ body.edit-mode .page-wrap { grid-template-columns: minmax(0, 1fr); max-width: no
|
|||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
position: relative;
|
position: relative;
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
width: 24rem;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 40rem;
|
||||||
}
|
}
|
||||||
.search-input { font-size: 0.9rem; }
|
.search-input { font-size: 0.9rem; }
|
||||||
.search-card {
|
.search-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
padding-bottom: var(--space-4);
|
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
border-bottom: var(--border-dashed);
|
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
.search-card:last-child { border-bottom: none; }
|
|
||||||
.search-card a { color: var(--link); font-size: 1.1rem; }
|
.search-card a { color: var(--link); font-size: 1.1rem; }
|
||||||
.search-card a:hover { color: var(--link-hover); }
|
.search-card a:hover { color: var(--link-hover); }
|
||||||
|
.search-section {
|
||||||
|
padding-bottom: var(--space-2);
|
||||||
|
border-bottom: var(--border-dashed);
|
||||||
|
}
|
||||||
|
|
||||||
/* === Floating action button ===
|
/* === Floating action button ===
|
||||||
Standalone FAB buttons (page TOC) are mobile-only. Wrapped FABs (search
|
Standalone FAB buttons (the tree rail, the right rail) are mobile-only and
|
||||||
actions dropdown) stay visible on desktop. */
|
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; }
|
.fab { position: fixed; bottom: var(--space-4); right: var(--space-4); z-index: 50; }
|
||||||
button.fab { display: none; }
|
button.fab { display: none; }
|
||||||
|
.fab-rail { bottom: calc(var(--space-4) + 3rem + var(--space-2)); }
|
||||||
|
|
||||||
/* === Companion status === */
|
/* === Companion status === */
|
||||||
.companion-status { margin-left: auto; }
|
.companion-status { margin-left: auto; }
|
||||||
@@ -480,6 +566,24 @@ button.fab { display: none; }
|
|||||||
display: block;
|
display: block;
|
||||||
background: var(--bg) url("/_/icons/thumb-placeholder.svg") center/2rem no-repeat;
|
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 {
|
.thumb-icon {
|
||||||
height: 150px;
|
height: 150px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -498,43 +602,38 @@ button.fab { display: none; }
|
|||||||
::-webkit-scrollbar-thumb { background: var(--primary); }
|
::-webkit-scrollbar-thumb { background: var(--primary); }
|
||||||
::-webkit-scrollbar-thumb:hover { background: var(--primary-hover); }
|
::-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 {
|
.sidebar {
|
||||||
position: sticky;
|
width: 14rem;
|
||||||
/* Park below the sticky header. The space-2 buffer also absorbs the small
|
flex-shrink: 0;
|
||||||
difference between --header-h and the real rendered header height, so the
|
overflow-y: auto;
|
||||||
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));
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
padding-top: 1rem;
|
padding: var(--space-4) 0 var(--space-4) var(--space-2);
|
||||||
|
border-left: var(--border-dashed);
|
||||||
}
|
}
|
||||||
aside.sidebar:empty { display: none; }
|
aside.sidebar:empty { display: none; }
|
||||||
|
|
||||||
/* Density modifier for panels in the sidebar (smaller font, tighter padding).
|
/* Density modifier for panels in the sidebar (smaller font, tighter padding).
|
||||||
The .panel class applied alongside provides the border + background. */
|
Drops the .panel outline so the rail reads as a clean column separated from
|
||||||
.panel-sidebar { padding: var(--space-2) var(--space-3); font-size: var(--font-sm); }
|
main by the .sidebar dashed border, matching the left tree rail. Inside the
|
||||||
.actions { display: flex; flex-direction: column; gap: 0.15rem; }
|
mobile Overlay the widgets render as plain full-width content. */
|
||||||
|
.panel-sidebar {
|
||||||
/* === 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);
|
padding: var(--space-2) var(--space-3);
|
||||||
font-size: var(--font-sm);
|
font-size: var(--font-sm);
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
.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 ul { list-style: none; margin: 0; padding: 0; }
|
||||||
.toc li { margin: 0.15rem 0; }
|
.toc li { margin: 0.15rem 0; }
|
||||||
.toc a {
|
.toc a {
|
||||||
@@ -585,15 +684,59 @@ aside.sidebar:empty { display: none; }
|
|||||||
padding: 0.4rem 0.6rem;
|
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 === */
|
||||||
.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 {
|
.tree-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-1);
|
||||||
padding: 0.4rem var(--space-2);
|
padding: 0.25rem var(--space-1);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
min-height: 2rem;
|
|
||||||
}
|
}
|
||||||
.tree-row:hover, .tree-row.is-selected { background: var(--bg-panel-hover); }
|
.tree-row:hover, .tree-row.is-selected { background: var(--bg-panel-hover); }
|
||||||
.tree-row.is-selected {
|
.tree-row.is-selected {
|
||||||
@@ -602,17 +745,59 @@ aside.sidebar:empty { display: none; }
|
|||||||
}
|
}
|
||||||
.tree-row.is-disabled { color: var(--text-muted); cursor: default; }
|
.tree-row.is-disabled { color: var(--text-muted); cursor: default; }
|
||||||
.tree-row.is-disabled:hover { background: none; }
|
.tree-row.is-disabled:hover { background: none; }
|
||||||
.tree-chevron, .tree-marker { text-align: center; flex-shrink: 0; }
|
.tree-chevron { text-align: center; flex-shrink: 0; }
|
||||||
.tree-chevron { width: 1.25rem; color: var(--secondary); }
|
.tree-chevron { width: 1.25rem; color: var(--secondary); }
|
||||||
.tree-chevron.is-leaf { visibility: hidden; }
|
.tree-chevron.is-leaf { visibility: hidden; }
|
||||||
.tree-marker { width: var(--space-4); color: var(--text-muted); }
|
.tree-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.tree-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
/* Each nesting level gets a vertical guide rail down its left edge so depth
|
||||||
.tree-children { padding-left: var(--space-5); }
|
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 {
|
.tree-selected-path {
|
||||||
font-size: var(--font-sm);
|
font-size: var(--font-sm);
|
||||||
padding: var(--space-1) 0;
|
padding: var(--space-1) 0;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
/* Current-page row in the navigation rail. Mirrors .tree-row.is-selected so the
|
||||||
|
two tree surfaces stay visually consistent. */
|
||||||
|
.tree-row.is-active {
|
||||||
|
background: var(--bg-panel-hover);
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
|
padding-left: calc(var(--space-2) - 3px);
|
||||||
|
}
|
||||||
|
.tree-row.is-active > .tree-name { color: var(--primary-hover); }
|
||||||
|
|
||||||
|
/* === Tree sidebar (persistent left navigation rail) ===
|
||||||
|
Reuses the .tree-row / .tree-children / .tree-name / .tree-chevron modules.
|
||||||
|
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 {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/* === Movie info box === */
|
/* === Movie info box === */
|
||||||
.movie-info { margin: var(--space-3) 0; }
|
.movie-info { margin: var(--space-3) 0; }
|
||||||
@@ -632,23 +817,17 @@ aside.sidebar:empty { display: none; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* === Diary calendar === */
|
/* === Diary calendar === */
|
||||||
.diary-cal-nav {
|
/* Header row: "Chronological" on the left, the [year ▾] dropdown pushed to the
|
||||||
display: flex;
|
right. Composes with .row (flex + centering); space-between splits the two. */
|
||||||
align-items: center;
|
.diary-cal .panel-header { justify-content: space-between; }
|
||||||
justify-content: center;
|
.diary-cal-drop .dropdown-menu { min-width: 0; }
|
||||||
gap: 0.2rem;
|
/* Per-month caption above each grid, linking to that month's anchor. */
|
||||||
margin-bottom: 0.4rem;
|
.diary-cal-month {
|
||||||
font-size: var(--font-sm);
|
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); }
|
.diary-cal-month a { color: var(--link); }
|
||||||
/* Anchor the month/year dropdowns to the nav row instead of the ▾ button so
|
.diary-cal-month a:hover { color: var(--link-hover); }
|
||||||
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-grid {
|
.diary-cal-grid {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -694,43 +873,29 @@ aside.sidebar:empty { display: none; }
|
|||||||
|
|
||||||
/* === Responsive === */
|
/* === Responsive === */
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.page-wrap { grid-template-columns: 1fr; }
|
/* Rails are not laid out inline on mobile — their content is surfaced
|
||||||
/* Single-row mobile header: logo + Up icon are compact so search can
|
through the Overlay via the stacked FABs. The <aside> elements stay in
|
||||||
take the middle flex column, with actions on the right. */
|
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; }
|
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; }
|
.search-form { width: 100%; max-width: none; justify-self: stretch; }
|
||||||
/* Sidebar on mobile is a floating overlay toggled by the FAB. The aside
|
/* Reveal the mobile FAB stack (tree at the bottom, right rail above it).
|
||||||
itself is the scroll container; children render at natural height. */
|
The search actions dropdown FAB moves to the upper slot too so it never
|
||||||
.sidebar {
|
overlaps the tree FAB. */
|
||||||
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;
|
|
||||||
}
|
|
||||||
.sidebar.is-open { display: flex; }
|
|
||||||
button.fab { display: inline-flex; }
|
button.fab { display: inline-flex; }
|
||||||
|
.fab.dropdown { bottom: calc(var(--space-4) + 3rem + var(--space-2)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
header, footer { padding: var(--space-2) var(--space-3); }
|
header, footer { padding: var(--space-2) var(--space-3); }
|
||||||
main { padding: var(--space-4) var(--space-3); }
|
main { padding: var(--space-4) var(--space-3); }
|
||||||
|
.app-name {display: none;}
|
||||||
.editor-cm { min-height: 50vh; }
|
.editor-cm { min-height: 50vh; }
|
||||||
.sidebar { width: calc(100% - 1.5rem); }
|
/* Editing on mobile is full-bleed: drop the main inset so the toolbar and
|
||||||
/* Editing on mobile is full-bleed: drop the page/main inset so the toolbar
|
editor use the entire viewport width. */
|
||||||
and editor use the entire viewport width. */
|
|
||||||
body.edit-mode .page-wrap { padding: 0; gap: 0; }
|
|
||||||
body.edit-mode main { padding: 0; }
|
body.edit-mode main { padding: 0; }
|
||||||
/* Fingers, not cursors: give every toolbar control a ~44px tap target. */
|
/* Fingers, not cursors: give every toolbar control a ~44px tap target. */
|
||||||
.editor-toolbar { gap: var(--space-2); padding: var(--space-2); }
|
.editor-toolbar { gap: var(--space-2); padding: var(--space-2); }
|
||||||
@@ -751,6 +916,9 @@ aside.sidebar:empty { display: none; }
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 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;
|
bottom: 0;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -85,11 +85,6 @@
|
|||||||
}
|
}
|
||||||
row.appendChild(chevron);
|
row.appendChild(chevron);
|
||||||
|
|
||||||
var marker = document.createElement('span');
|
|
||||||
marker.className = 'tree-marker';
|
|
||||||
marker.textContent = kind === 'folder' ? '' : '\u00b7';
|
|
||||||
row.appendChild(marker);
|
|
||||||
|
|
||||||
var label = document.createElement('span');
|
var label = document.createElement('span');
|
||||||
label.className = 'tree-name';
|
label.className = 'tree-name';
|
||||||
label.textContent = name;
|
label.textContent = name;
|
||||||
@@ -199,9 +194,6 @@
|
|||||||
var rootChev = document.createElement('span');
|
var rootChev = document.createElement('span');
|
||||||
rootChev.className = 'tree-chevron is-leaf';
|
rootChev.className = 'tree-chevron is-leaf';
|
||||||
rootRow.appendChild(rootChev);
|
rootRow.appendChild(rootChev);
|
||||||
var rootMarker = document.createElement('span');
|
|
||||||
rootMarker.className = 'tree-marker';
|
|
||||||
rootRow.appendChild(rootMarker);
|
|
||||||
var rootLabel = document.createElement('span');
|
var rootLabel = document.createElement('span');
|
||||||
rootLabel.className = 'tree-name';
|
rootLabel.className = 'tree-name';
|
||||||
rootLabel.textContent = '/';
|
rootLabel.textContent = '/';
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// Persistent left navigation rail. Renders the wiki folder/file tree, expanded
|
||||||
|
// to the current page's ancestor chain, and lets the user navigate by clicking
|
||||||
|
// folders (links) or open files locally via the companion (a.tree-file, wired
|
||||||
|
// in companion.js). Shares the .tree-* CSS and the ?tree endpoint with
|
||||||
|
// tree-picker.js but does not reuse its row builder — that one is modal-select
|
||||||
|
// behavior, this one is navigation behavior. Hidden in edit mode (the server
|
||||||
|
// omits the container).
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
// 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) {
|
||||||
|
if (parent === '/' || parent === '') return '/' + name;
|
||||||
|
return parent.replace(/\/+$/, '') + '/' + name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeSegments(p) {
|
||||||
|
return p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderHref(p) {
|
||||||
|
if (p === '/' || p === '') return '/';
|
||||||
|
return '/' + encodeSegments(p) + '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileHref(p) {
|
||||||
|
return '/' + encodeSegments(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchFolder(path, expandTo) {
|
||||||
|
var url = folderHref(path) + '?tree=1';
|
||||||
|
if (expandTo) url += '&expandTo=' + encodeURIComponent(expandTo);
|
||||||
|
return fetch(url, { credentials: 'same-origin' }).then(function (r) {
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current page as a canonical, DECODED wiki path ("/" for root, no trailing
|
||||||
|
// slash otherwise). pathname is percent-encoded; the tree's entry names (and
|
||||||
|
// thus fullPath) are decoded, so we must decode here too or paths containing
|
||||||
|
// spaces / non-ASCII never match a row and the chain never expands.
|
||||||
|
var activePath = (function () {
|
||||||
|
var p = window.location.pathname.replace(/\/+$/, '');
|
||||||
|
if (p === '') return '/';
|
||||||
|
try { return decodeURIComponent(p); } catch (e) { return p; }
|
||||||
|
})();
|
||||||
|
var activeRow = null;
|
||||||
|
|
||||||
|
function disabledRow(text) {
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'tree-row is-disabled';
|
||||||
|
row.textContent = text;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderInto fills containerEl with rows for `entries` (children of
|
||||||
|
// parentPath). Folders carrying a `children` array (the pre-expanded
|
||||||
|
// ancestor chain from ?expandTo) are opened immediately and recurse.
|
||||||
|
function renderInto(containerEl, parentPath, entries) {
|
||||||
|
if (!entries || entries.length === 0) {
|
||||||
|
containerEl.appendChild(disabledRow('(empty)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entries.forEach(function (entry) {
|
||||||
|
var built = buildRow(parentPath, entry);
|
||||||
|
containerEl.appendChild(built.row);
|
||||||
|
if (built.preExpand) built.open();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRow(parentPath, entry) {
|
||||||
|
var fullPath = joinPath(parentPath, entry.name);
|
||||||
|
var isFolder = entry.kind === 'folder';
|
||||||
|
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'tree-row';
|
||||||
|
|
||||||
|
var chevron = document.createElement('span');
|
||||||
|
chevron.className = 'tree-chevron';
|
||||||
|
if (isFolder) chevron.textContent = '▸'; // ▸
|
||||||
|
else chevron.classList.add('is-leaf');
|
||||||
|
row.appendChild(chevron);
|
||||||
|
|
||||||
|
|
||||||
|
var link = document.createElement('a');
|
||||||
|
link.className = 'tree-name ' + (isFolder ? 'tree-folder' : 'tree-file');
|
||||||
|
link.textContent = entry.name;
|
||||||
|
link.href = isFolder ? folderHref(fullPath) : fileHref(fullPath);
|
||||||
|
row.appendChild(link);
|
||||||
|
|
||||||
|
if (isFolder && fullPath === activePath) {
|
||||||
|
row.classList.add('is-active');
|
||||||
|
activeRow = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
var preChildren = (isFolder && entry.children) ? entry.children : null;
|
||||||
|
var childrenEl = null;
|
||||||
|
var loaded = false;
|
||||||
|
var isOpen = false;
|
||||||
|
|
||||||
|
function ensureChildrenEl() {
|
||||||
|
if (!childrenEl) {
|
||||||
|
childrenEl = document.createElement('div');
|
||||||
|
childrenEl.className = 'tree-children';
|
||||||
|
}
|
||||||
|
return childrenEl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadChildren() {
|
||||||
|
var el = ensureChildrenEl();
|
||||||
|
el.textContent = '';
|
||||||
|
el.appendChild(disabledRow('…'));
|
||||||
|
fetchFolder(fullPath).then(function (resp) {
|
||||||
|
el.textContent = '';
|
||||||
|
renderInto(el, fullPath, resp.entries);
|
||||||
|
loaded = true;
|
||||||
|
}).catch(function () {
|
||||||
|
el.textContent = '';
|
||||||
|
var err = document.createElement('div');
|
||||||
|
err.className = 'tree-row';
|
||||||
|
err.textContent = '(failed — tap to retry)';
|
||||||
|
err.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
loadChildren();
|
||||||
|
});
|
||||||
|
el.appendChild(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (!isFolder || isOpen) return;
|
||||||
|
var el = ensureChildrenEl();
|
||||||
|
row.parentNode.insertBefore(el, row.nextSibling);
|
||||||
|
chevron.textContent = '▾'; // ▾
|
||||||
|
isOpen = true;
|
||||||
|
if (!loaded) {
|
||||||
|
if (preChildren) {
|
||||||
|
renderInto(el, fullPath, preChildren);
|
||||||
|
loaded = true;
|
||||||
|
} else {
|
||||||
|
loadChildren();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!isFolder || !isOpen) return;
|
||||||
|
if (childrenEl && childrenEl.parentNode) {
|
||||||
|
childrenEl.parentNode.removeChild(childrenEl);
|
||||||
|
}
|
||||||
|
chevron.textContent = '▸';
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clicking the name link navigates (folder) or opens the file; clicking
|
||||||
|
// anywhere else on the row toggles expansion for folders.
|
||||||
|
row.addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest('a.tree-name')) return;
|
||||||
|
if (!isFolder) return;
|
||||||
|
if (isOpen) close(); else open();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { row: row, open: open, preExpand: !!preChildren };
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
container.textContent = '';
|
||||||
|
var listing = document.createElement('div');
|
||||||
|
container.appendChild(listing);
|
||||||
|
listing.appendChild(disabledRow('…'));
|
||||||
|
|
||||||
|
fetchFolder('/', activePath === '/' ? '' : activePath).then(function (resp) {
|
||||||
|
listing.textContent = '';
|
||||||
|
renderInto(listing, '/', resp.entries);
|
||||||
|
if (activeRow) {
|
||||||
|
try { activeRow.scrollIntoView({ block: 'nearest' }); } catch (e) {}
|
||||||
|
}
|
||||||
|
}).catch(function () {
|
||||||
|
listing.textContent = '';
|
||||||
|
var err = document.createElement('div');
|
||||||
|
err.className = 'tree-row';
|
||||||
|
err.textContent = '(failed — tap to retry)';
|
||||||
|
err.addEventListener('click', function () { render(); });
|
||||||
|
listing.appendChild(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.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 () {
|
||||||
|
if (typeof openOverlay === 'function') openOverlay(container);
|
||||||
|
});
|
||||||
|
document.body.appendChild(fab);
|
||||||
|
}
|
||||||
|
|
||||||
|
render();
|
||||||
|
setupFab();
|
||||||
|
})();
|
||||||
@@ -57,7 +57,9 @@ func runOpenCommand(template, path string) error {
|
|||||||
if !sawPath {
|
if !sawPath {
|
||||||
tokens = append(tokens, path)
|
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
|
// 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"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "1"
|
const version = "2"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ type diaryHandler struct{}
|
|||||||
// page anchor (or to the year-file editor when ?edit is set).
|
// page anchor (or to the year-file editor when ?edit is set).
|
||||||
//
|
//
|
||||||
// 1. `today` / `this-month` / `this-year` shortcuts resolve directly to a
|
// 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
|
// 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).
|
// /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
|
// 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)
|
parentFS := filepath.Dir(fsPath)
|
||||||
parentURLPath := parentURL(urlPath)
|
parentURLPath := parentURL(urlPath)
|
||||||
_, diaryRootFS, diaryRootURL, ok := findDiaryContext(root, parentFS, parentURLPath)
|
_, _, diaryRootURL, ok := findDiaryContext(root, parentFS, parentURLPath)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -71,16 +72,7 @@ func (d *diaryHandler) dateShortcutRedirect(root, fsPath, urlPath string) (strin
|
|||||||
switch base {
|
switch base {
|
||||||
case "today":
|
case "today":
|
||||||
dayHeading := fmt.Sprintf("%s-%s-%s", year, month, day)
|
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
|
return yearURL + "#" + dayHeading, true
|
||||||
}
|
|
||||||
// Missing day: route through the insert flow so today's section
|
|
||||||
// is spliced in at the right chronological position.
|
|
||||||
insertIdx := computeInsertIndex(sections, dayHeading)
|
|
||||||
return fmt.Sprintf("%s?edit&insert_before=%d&heading=%s",
|
|
||||||
yearURL, insertIdx, url.QueryEscape(dayHeading)), true
|
|
||||||
case "this-month":
|
case "this-month":
|
||||||
return yearURL + "#" + fmt.Sprintf("%s-%s", year, month), true
|
return yearURL + "#" + fmt.Sprintf("%s-%s", year, month), true
|
||||||
case "this-year":
|
case "this-year":
|
||||||
@@ -343,8 +335,8 @@ type calYear struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// calMonthGrid carries everything the template needs to render one month's
|
// calMonthGrid carries everything the template needs to render one month's
|
||||||
// grid plus the dropdown / heading entry that targets it. The calendar
|
// grid plus the caption that links to it. The calendar widget renders all 12
|
||||||
// widget ships all 12 in the initial HTML; JS swaps which one is visible.
|
// stacked vertically; JS centers the current month in the rail on load.
|
||||||
type calMonthGrid struct {
|
type calMonthGrid struct {
|
||||||
Num int
|
Num int
|
||||||
Name string
|
Name string
|
||||||
@@ -355,9 +347,7 @@ type calMonthGrid struct {
|
|||||||
type calendarData struct {
|
type calendarData struct {
|
||||||
DisplayYear int
|
DisplayYear int
|
||||||
DisplayMonth int
|
DisplayMonth int
|
||||||
DisplayMonthName string // pre-resolved so the template doesn't need arithmetic
|
|
||||||
DiaryURL string
|
DiaryURL string
|
||||||
YearURL string
|
|
||||||
Months []calMonthGrid
|
Months []calMonthGrid
|
||||||
Years []calYear
|
Years []calYear
|
||||||
}
|
}
|
||||||
@@ -491,9 +481,7 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int)
|
|||||||
data := calendarData{
|
data := calendarData{
|
||||||
DisplayYear: displayYear,
|
DisplayYear: displayYear,
|
||||||
DisplayMonth: displayMonth,
|
DisplayMonth: displayMonth,
|
||||||
DisplayMonthName: months[displayMonth-1].Name,
|
|
||||||
DiaryURL: diaryRootURL,
|
DiaryURL: diaryRootURL,
|
||||||
YearURL: yearURL,
|
|
||||||
Months: months,
|
Months: months,
|
||||||
Years: years,
|
Years: years,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
return hex.EncodeToString(sum[:])[:12]
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
// tmplFuncs is shared by every layout-based template: the edit template appends
|
||||||
pageTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
|
// editorBundleVersion to its CodeMirror <script> src to cache-bust the bundle.
|
||||||
editTmpl = template.Must(template.New("edit").Funcs(template.FuncMap{
|
var tmplFuncs = template.FuncMap{
|
||||||
"editorBundleVersion": func() string { return editorBundleVersion },
|
"editorBundleVersion": func() string { return editorBundleVersion },
|
||||||
}).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
|
"fileIcon": fileIcon,
|
||||||
searchTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
pageTmpl = template.Must(template.New("page").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/page/main.html"))
|
||||||
|
editTmpl = template.Must(template.New("edit").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/editor/main.html"))
|
||||||
|
searchTmpl = template.Must(template.New("search").Funcs(tmplFuncs).ParseFS(assets, "assets/layout.html", "assets/search/main.html"))
|
||||||
)
|
)
|
||||||
|
|
||||||
// specialPage is the result returned by a pageTypeHandler.
|
// 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.
|
// so the first search after a cold start still returns correct results.
|
||||||
go func() {
|
go func() {
|
||||||
folderIndex.buildMu.Lock()
|
folderIndex.buildMu.Lock()
|
||||||
entries := buildFolderIndex(root)
|
folders, files := buildIndexes(root)
|
||||||
|
now := time.Now()
|
||||||
folderIndex.Lock()
|
folderIndex.Lock()
|
||||||
folderIndex.entries = entries
|
folderIndex.entries = folders
|
||||||
folderIndex.builtAt = time.Now()
|
folderIndex.builtAt = now
|
||||||
folderIndex.Unlock()
|
folderIndex.Unlock()
|
||||||
|
fileIndex.Lock()
|
||||||
|
fileIndex.entries = files
|
||||||
|
fileIndex.builtAt = now
|
||||||
|
fileIndex.Unlock()
|
||||||
folderIndex.buildMu.Unlock()
|
folderIndex.buildMu.Unlock()
|
||||||
close(folderIndex.ready)
|
close(folderIndex.ready)
|
||||||
|
close(fileIndex.ready)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if *reindexInterval > 0 {
|
if *reindexInterval > 0 {
|
||||||
@@ -318,13 +329,8 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
rawContent = "# " + pageTitle(urlPath) + "\n\n"
|
rawContent = "# " + pageTitle(urlPath) + "\n\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
parent := ""
|
|
||||||
if urlPath != "/" {
|
|
||||||
parent = parentURL(urlPath)
|
|
||||||
}
|
|
||||||
data := pageData{
|
data := pageData{
|
||||||
Title: title,
|
Title: title,
|
||||||
ParentURL: parent,
|
|
||||||
CanEdit: true,
|
CanEdit: true,
|
||||||
EditMode: editMode,
|
EditMode: editMode,
|
||||||
IsRoot: urlPath == "/",
|
IsRoot: urlPath == "/",
|
||||||
@@ -418,9 +424,14 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
}
|
}
|
||||||
rawMD, _ := os.ReadFile(indexPath)
|
rawMD, _ := os.ReadFile(indexPath)
|
||||||
sections := splitSections(rawMD)
|
sections := splitSections(rawMD)
|
||||||
if sectionIndex < len(sections) {
|
// Out of range means the file changed under the editor (or the index
|
||||||
sections[sectionIndex] = []byte(content)
|
// 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))
|
content = string(joinSections(sections))
|
||||||
// Section index ≥ 1 is a heading-anchored section. Redirect to its
|
// Section index ≥ 1 is a heading-anchored section. Redirect to its
|
||||||
// anchor so the user lands on the section they just saved, even if
|
// anchor so the user lands on the section they just saved, even if
|
||||||
@@ -433,12 +444,15 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A save must never remove a page. An empty POST — a truncated mobile
|
||||||
|
// request, a lost `section` field, an editor that came up blank — is
|
||||||
|
// indistinguishable from "clear this page", and deleting index.md on that
|
||||||
|
// signal loses the whole file even though the user only edited one section.
|
||||||
|
// Removing a page is the explicit ?delete action's job (moves.go).
|
||||||
if strings.TrimSpace(content) == "" {
|
if strings.TrimSpace(content) == "" {
|
||||||
if err := os.Remove(indexPath); err != nil && !os.IsNotExist(err) {
|
http.Error(w, "refusing to save empty content — use DELETE to remove this page", http.StatusBadRequest)
|
||||||
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Stat first so we know whether MkdirAll actually created the folder
|
// Stat first so we know whether MkdirAll actually created the folder
|
||||||
// — if it did, the search index needs a new entry.
|
// — if it did, the search index needs a new entry.
|
||||||
_, statErr := os.Stat(fsPath)
|
_, statErr := os.Stat(fsPath)
|
||||||
@@ -456,6 +470,14 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
folderIndexAdd(filepath.ToSlash(rel))
|
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)
|
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(escapeLinkLabel(title))
|
||||||
b.WriteString("](")
|
b.WriteString("](")
|
||||||
b.WriteString(rawURL)
|
b.WriteString(rawURL)
|
||||||
b.WriteString(")</br>")
|
b.WriteString(")\n")
|
||||||
b.WriteString(ts.Format("2006-01-02 15:04"))
|
b.WriteString(ts.Format("2006-01-02 15:04"))
|
||||||
b.WriteString("</br>")
|
b.WriteString("\n")
|
||||||
if comment != "" {
|
if comment != "" {
|
||||||
b.WriteString(" ")
|
b.WriteString(" ")
|
||||||
b.WriteString(comment)
|
b.WriteString(comment)
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ var md goldmark.Markdown
|
|||||||
// targets against the filesystem.
|
// targets against the filesystem.
|
||||||
func initMarkdown(root string) {
|
func initMarkdown(root string) {
|
||||||
md = goldmark.New(
|
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.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
|
// ThumbURL is set for thumbnailable files; the thumbnail view renders an
|
||||||
// <img> when it is non-empty and falls back to Icon otherwise.
|
// <img> when it is non-empty and falls back to Icon otherwise.
|
||||||
ThumbURL string
|
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
|
// modTime/size carry the raw sort keys; the template only reads the
|
||||||
// formatted Meta string.
|
// formatted Meta string.
|
||||||
modTime time.Time
|
modTime time.Time
|
||||||
@@ -44,7 +47,6 @@ type entry struct {
|
|||||||
|
|
||||||
type pageData struct {
|
type pageData struct {
|
||||||
Title string
|
Title string
|
||||||
ParentURL string
|
|
||||||
CanEdit bool
|
CanEdit bool
|
||||||
EditMode bool
|
EditMode bool
|
||||||
IsRoot bool
|
IsRoot bool
|
||||||
@@ -218,6 +220,7 @@ func listEntries(fsPath, urlPath, sortKey, order string) []entry {
|
|||||||
}
|
}
|
||||||
if hasThumbnail(name) {
|
if hasThumbnail(name) {
|
||||||
f.ThumbURL = thumbURL(path.Join(urlPath, url.PathEscape(name)), 300)
|
f.ThumbURL = thumbURL(path.Join(urlPath, url.PathEscape(name)), 300)
|
||||||
|
f.IsVideo = isVideoFile(name)
|
||||||
}
|
}
|
||||||
files = append(files, f)
|
files = append(files, f)
|
||||||
}
|
}
|
||||||
@@ -229,6 +232,13 @@ func listEntries(fsPath, urlPath, sortKey, order string) []entry {
|
|||||||
sortEntries(folders, sortName, order)
|
sortEntries(folders, sortName, order)
|
||||||
sortEntries(files, sortKey, 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
|
// `..` row mirrors the header Up button so the listing itself is
|
||||||
// navigable without reaching for the header on mobile. Prepended after
|
// navigable without reaching for the header on mobile. Prepended after
|
||||||
// sort so it always sits at the top regardless of folder names.
|
// sort so it always sits at the top regardless of folder names.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -13,32 +14,65 @@ import (
|
|||||||
"unicode"
|
"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 {
|
type searchResult struct {
|
||||||
Name string
|
Name string
|
||||||
URL string
|
URL string
|
||||||
Path string
|
Path string
|
||||||
Score int
|
Score int
|
||||||
|
// Meta is the formatted "size · date" line for file results; empty for
|
||||||
|
// page results.
|
||||||
|
Meta string
|
||||||
}
|
}
|
||||||
|
|
||||||
type searchPageData struct {
|
type searchPageData struct {
|
||||||
Title string
|
Title string
|
||||||
ParentURL string
|
|
||||||
EditMode bool
|
EditMode bool
|
||||||
Query string
|
Query string
|
||||||
Results []searchResult
|
// Exact holds page(s) whose name equals the query exactly, promoted to
|
||||||
|
// their own section above the fuzzy matches.
|
||||||
|
Exact []searchResult
|
||||||
|
Pages []searchResult
|
||||||
|
Files []searchResult
|
||||||
|
// PageTotal/FileTotal are the true match totals; Pages/Files are capped at
|
||||||
|
// searchSectionCap for rendering.
|
||||||
|
PageTotal int
|
||||||
|
FileTotal int
|
||||||
IndexBuiltAt time.Time
|
IndexBuiltAt time.Time
|
||||||
RenderMS int64
|
RenderMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// folderEntry is a single indexed directory: its forward-slash relative path
|
// indexEntry is the shared scoreable core of both folder and file index
|
||||||
// plus pre-tokenized basename so the per-query scoring loop avoids redoing
|
// entries: a forward-slash relative path plus its pre-tokenized basename so
|
||||||
// the lowercasing and tokenization on every keystroke.
|
// the per-query scoring loop avoids redoing the lowercasing and tokenization
|
||||||
type folderEntry struct {
|
// on every request.
|
||||||
|
type indexEntry struct {
|
||||||
Path string
|
Path string
|
||||||
NameLower string
|
NameLower string
|
||||||
NameTokens []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
|
// folderIndex holds the in-memory directory index used by search. Writers
|
||||||
// always replace the entries slice wholesale so a reader that snapshots the
|
// always replace the entries slice wholesale so a reader that snapshots the
|
||||||
// header under RLock can score without holding the lock.
|
// header under RLock can score without holding the lock.
|
||||||
@@ -50,15 +84,38 @@ var folderIndex struct {
|
|||||||
ready chan 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() {
|
func init() {
|
||||||
folderIndex.ready = make(chan struct{})
|
folderIndex.ready = make(chan struct{})
|
||||||
|
fileIndex.ready = make(chan struct{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSearch renders the search results page for the query in
|
// handleSearch renders the search results page for the query in
|
||||||
// r.URL.Query().Get("q"). Only invoked when path is "/" and "q" is present.
|
// r.URL.Query().Get("q"). Only invoked when path is "/" and "q" is present.
|
||||||
func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
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"
|
title := "Search"
|
||||||
if query != "" {
|
if query != "" {
|
||||||
@@ -66,9 +123,12 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
data := searchPageData{
|
data := searchPageData{
|
||||||
Title: title,
|
Title: title,
|
||||||
ParentURL: "/",
|
|
||||||
Query: query,
|
Query: query,
|
||||||
Results: results,
|
Exact: exact,
|
||||||
|
Pages: capResults(pages),
|
||||||
|
Files: capResults(files),
|
||||||
|
PageTotal: len(pages),
|
||||||
|
FileTotal: len(files),
|
||||||
IndexBuiltAt: builtAt,
|
IndexBuiltAt: builtAt,
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
@@ -78,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
|
// searchWiki scores the cached folder index against query. Blocks on the
|
||||||
// initial build so the very first request after startup serves correct
|
// initial build so the very first request after startup serves correct
|
||||||
// results rather than an empty list. Returns the snapshot's builtAt so the
|
// results rather than an empty list. Returns the snapshot's builtAt so the
|
||||||
@@ -92,45 +201,65 @@ func searchWiki(query string) ([]searchResult, time.Time) {
|
|||||||
if query == "" {
|
if query == "" {
|
||||||
return nil, builtAt
|
return nil, builtAt
|
||||||
}
|
}
|
||||||
qLower := strings.ToLower(query)
|
scored := scoreEntries(entries, query, true, func(e folderEntry) indexEntry { return e })
|
||||||
qTokens := tokenize(qLower)
|
results := make([]searchResult, 0, len(scored))
|
||||||
if len(qTokens) == 0 {
|
for _, s := range scored {
|
||||||
return nil, builtAt
|
|
||||||
}
|
|
||||||
|
|
||||||
var results []searchResult
|
|
||||||
for _, e := range entries {
|
|
||||||
score := scoreName(e.NameLower, e.NameTokens, qLower, qTokens)
|
|
||||||
if score == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
results = append(results, searchResult{
|
results = append(results, searchResult{
|
||||||
Name: filepath.Base(e.Path),
|
Name: filepath.Base(s.entry.Path),
|
||||||
URL: "/" + e.Path + "/",
|
URL: "/" + s.entry.Path + "/",
|
||||||
Path: e.Path,
|
Path: s.entry.Path,
|
||||||
Score: score,
|
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
|
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
|
// scoreName ranks how well nameLower matches the query. Whole-name exact
|
||||||
// match dominates; otherwise score is the sum of each token's best match
|
// 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.
|
// against the words in the name. nameTokens is precomputed by the index. fuzzy
|
||||||
func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []string) int {
|
// 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 {
|
if nameLower == qLower {
|
||||||
return 1000
|
return exactNameScore
|
||||||
}
|
}
|
||||||
score := 0
|
score := 0
|
||||||
for _, qt := range qTokens {
|
for _, qt := range qTokens {
|
||||||
@@ -149,7 +278,7 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
|
|||||||
if best < 20 {
|
if best < 20 {
|
||||||
best = 20
|
best = 20
|
||||||
}
|
}
|
||||||
case levenshtein(w, qt) <= 2:
|
case fuzzy && levenshtein(w, qt) <= 2:
|
||||||
if best < 5 {
|
if best < 5 {
|
||||||
best = 5
|
best = 5
|
||||||
}
|
}
|
||||||
@@ -216,12 +345,15 @@ func (h *handler) handleReindex(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildFolderIndex walks root and returns a fresh slice of folder entries.
|
// buildIndexes walks root once and returns fresh folder and file entries. The
|
||||||
// Hidden directories (`.git`, `.thumbs`, …) are pruned; the root itself is
|
// single pass avoids a second full traversal on the ARMv7 NAS. Hidden
|
||||||
// excluded since it cannot be a search match.
|
// directories (`.git`, `.thumbs`, …) are pruned and hidden files skipped; the
|
||||||
func buildFolderIndex(root string) []folderEntry {
|
// root itself and every `index.md` (page content, not a browsable file) are
|
||||||
|
// excluded.
|
||||||
|
func buildIndexes(root string) ([]folderEntry, []fileEntry) {
|
||||||
walkRoot := resolveWalkRoot(root)
|
walkRoot := resolveWalkRoot(root)
|
||||||
var entries []folderEntry
|
var folders []folderEntry
|
||||||
|
var files []fileEntry
|
||||||
_ = filepath.WalkDir(walkRoot, func(fsPath string, d fs.DirEntry, err error) error {
|
_ = filepath.WalkDir(walkRoot, func(fsPath string, d fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -229,46 +361,78 @@ func buildFolderIndex(root string) []folderEntry {
|
|||||||
if skip, walkErr := hiddenSkip(fsPath, walkRoot, d); skip {
|
if skip, walkErr := hiddenSkip(fsPath, walkRoot, d); skip {
|
||||||
return walkErr
|
return walkErr
|
||||||
}
|
}
|
||||||
if !d.IsDir() || fsPath == walkRoot {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
rel, relErr := filepath.Rel(walkRoot, fsPath)
|
rel, relErr := filepath.Rel(walkRoot, fsPath)
|
||||||
if relErr != nil {
|
if relErr != nil {
|
||||||
return 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 nil
|
||||||
})
|
})
|
||||||
return entries
|
return folders, files
|
||||||
}
|
}
|
||||||
|
|
||||||
// newFolderEntry builds a folderEntry from a forward-slash relative path,
|
// newFolderEntry builds a folderEntry from a forward-slash relative path,
|
||||||
// computing the lowercased basename and its tokens once so search scoring
|
// computing the lowercased basename and its tokens once so search scoring
|
||||||
// doesn't have to redo it per query.
|
// doesn't have to redo it per query.
|
||||||
func newFolderEntry(relPath string) folderEntry {
|
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
|
name := relPath
|
||||||
if i := strings.LastIndex(relPath, "/"); i >= 0 {
|
if i := strings.LastIndex(relPath, "/"); i >= 0 {
|
||||||
name = relPath[i+1:]
|
name = relPath[i+1:]
|
||||||
}
|
}
|
||||||
nameLower := strings.ToLower(name)
|
nameLower := strings.ToLower(name)
|
||||||
return folderEntry{
|
return indexEntry{
|
||||||
Path: relPath,
|
Path: relPath,
|
||||||
NameLower: nameLower,
|
NameLower: nameLower,
|
||||||
NameTokens: tokenize(nameLower),
|
NameTokens: tokenize(nameLower),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// rebuildFolderIndex walks root and replaces the index entries atomically.
|
// rebuildFolderIndex walks root once and atomically replaces both the folder
|
||||||
// buildMu serializes overlapping rebuilds (manual + ticker + startup) so
|
// and file indexes. buildMu serializes overlapping rebuilds (manual + ticker +
|
||||||
// the WalkDir cost is paid once even under contention.
|
// startup) so the WalkDir cost is paid once even under contention.
|
||||||
func rebuildFolderIndex(root string) {
|
func rebuildFolderIndex(root string) {
|
||||||
folderIndex.buildMu.Lock()
|
folderIndex.buildMu.Lock()
|
||||||
defer folderIndex.buildMu.Unlock()
|
defer folderIndex.buildMu.Unlock()
|
||||||
entries := buildFolderIndex(root)
|
folders, files := buildIndexes(root)
|
||||||
|
now := time.Now()
|
||||||
folderIndex.Lock()
|
folderIndex.Lock()
|
||||||
folderIndex.entries = entries
|
folderIndex.entries = folders
|
||||||
folderIndex.builtAt = time.Now()
|
folderIndex.builtAt = now
|
||||||
folderIndex.Unlock()
|
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.
|
// 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
|
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
|
var thumbnailers []Thumbnailer
|
||||||
|
|
||||||
// thumbCacheDir is set from the -cache flag at startup.
|
// thumbCacheDir is set from the -cache flag at startup.
|
||||||
@@ -116,12 +124,40 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
digest, data, err := sourceDigest(srcFS, srcInfo)
|
// Path thumbnailers (video/ffmpeg) take the source path directly and key
|
||||||
|
// the cache on path+mtime+size, so the file is never read into memory.
|
||||||
|
// Content thumbnailers (images) stay content-addressed so renames reuse
|
||||||
|
// the cache entry. Either way, generation runs through the closure below.
|
||||||
|
var (
|
||||||
|
digest string
|
||||||
|
write func(dst io.Writer) error
|
||||||
|
)
|
||||||
|
if pt, ok := t.(PathThumbnailer); ok {
|
||||||
|
digest = pathDigest(srcFS, srcInfo)
|
||||||
|
write = func(dst io.Writer) error { return pt.GenerateFromPath(srcFS, dst, width) }
|
||||||
|
} else {
|
||||||
|
d, data, err := sourceDigest(srcFS, srcInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("thumb digest %s: %v", rel, err)
|
log.Printf("thumb digest %s: %v", rel, err)
|
||||||
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
|
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
|
||||||
return
|
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))
|
cacheFS := filepath.Join(thumbCacheDir, digest[:2], fmt.Sprintf("%s.%d.jpg", digest, width))
|
||||||
if _, err := os.Stat(cacheFS); err == nil {
|
if _, err := os.Stat(cacheFS); err == nil {
|
||||||
@@ -138,21 +174,7 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var src io.Reader
|
if err := generateThumb(cacheFS, write); err != nil {
|
||||||
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 {
|
|
||||||
log.Printf("thumb %s: %v", rel, err)
|
log.Printf("thumb %s: %v", rel, err)
|
||||||
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
|
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -160,6 +182,14 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
|
|||||||
serveThumb(w, r, cacheFS)
|
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.
|
// 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,
|
// 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
|
// 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)
|
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 {
|
if err := os.MkdirAll(filepath.Dir(cacheFS), 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -200,7 +233,7 @@ func generateThumb(t Thumbnailer, src io.Reader, cacheFS string, width int) erro
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
tmpName := tmp.Name()
|
tmpName := tmp.Name()
|
||||||
if err := t.Generate(src, tmp, width); err != nil {
|
if err := write(tmp); err != nil {
|
||||||
tmp.Close()
|
tmp.Close()
|
||||||
os.Remove(tmpName)
|
os.Remove(tmpName)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -7,12 +7,22 @@ import (
|
|||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
thumbnailers = append(thumbnailers, &imageThumbnailer{})
|
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{}
|
type imageThumbnailer struct{}
|
||||||
|
|
||||||
func (it *imageThumbnailer) CanHandle(ext string) bool {
|
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()
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -11,6 +12,9 @@ import (
|
|||||||
type treeEntry struct {
|
type treeEntry struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
|
// Children is populated only along the expandTo chain (see handleTree);
|
||||||
|
// omitted otherwise so the flat picker listing keeps its original shape.
|
||||||
|
Children []treeEntry `json:"children,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type treeResponse struct {
|
type treeResponse struct {
|
||||||
@@ -42,6 +46,14 @@ func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// expandTo asks for a nested listing: each folder along the ancestor chain
|
||||||
|
// carries its own children, recursively, down to the target. Used by the
|
||||||
|
// tree sidebar to render the current page's chain in one request. The flat
|
||||||
|
// picker omits expandTo and is unaffected.
|
||||||
|
if expandTo := r.URL.Query().Get("expandTo"); expandTo != "" {
|
||||||
|
expandTreeChain(fsPath, entries, treePathSegments(expandTo))
|
||||||
|
}
|
||||||
|
|
||||||
resp := treeResponse{Path: canonicalTreePath(urlPath), Entries: entries}
|
resp := treeResponse{Path: canonicalTreePath(urlPath), Entries: entries}
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
_ = json.NewEncoder(w).Encode(resp)
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
@@ -83,3 +95,39 @@ func listTreeEntries(fsPath string) ([]treeEntry, error) {
|
|||||||
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
|
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
|
||||||
return append(folders, files...), nil
|
return append(folders, files...), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// treePathSegments splits a wiki path into its non-empty segments.
|
||||||
|
func treePathSegments(p string) []string {
|
||||||
|
var segs []string
|
||||||
|
for _, s := range strings.Split(p, "/") {
|
||||||
|
if s != "" {
|
||||||
|
segs = append(segs, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return segs
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandTreeChain walks segs, matching each against a folder in entries by
|
||||||
|
// name, loading that folder's children in place, and recursing. The walk stops
|
||||||
|
// at the deepest matching segment, so a stale or deleted path simply expands as
|
||||||
|
// far as it still exists. Segments only ever match real directory names from
|
||||||
|
// listTreeEntries (no "." or ".." entries), so this cannot traverse outside the
|
||||||
|
// listed tree.
|
||||||
|
func expandTreeChain(fsPath string, entries []treeEntry, segs []string) {
|
||||||
|
if len(segs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range entries {
|
||||||
|
if entries[i].Kind != "folder" || entries[i].Name != segs[0] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
childFs := filepath.Join(fsPath, segs[0])
|
||||||
|
kids, err := listTreeEntries(childFs)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries[i].Children = kids
|
||||||
|
expandTreeChain(childFs, entries[i].Children, segs[1:])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+27
-4
@@ -115,6 +115,23 @@ func wikiTargetHref(target string) string {
|
|||||||
return b.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
|
// wikiTargetExists reports whether the on-disk path backing the target exists
|
||||||
// under root. Any existing path — file or folder — counts as resolved; only a
|
// under root. Any existing path — file or folder — counts as resolved; only a
|
||||||
// missing path is treated as broken.
|
// 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
|
return ast.WalkContinue, nil
|
||||||
}
|
}
|
||||||
n := node.(*wikiLinkNode)
|
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)
|
href := wikiTargetHref(target)
|
||||||
display := string(n.Display)
|
|
||||||
if display == "" {
|
if display == "" {
|
||||||
display = wikiDefaultDisplay(target)
|
display = wikiDefaultDisplay(target)
|
||||||
}
|
}
|
||||||
broken := !wikiTargetExists(r.root, target)
|
broken := !wikiTargetExists(root, target)
|
||||||
|
|
||||||
w.WriteString(`<a href="`)
|
w.WriteString(`<a href="`)
|
||||||
w.WriteString(href)
|
w.WriteString(href)
|
||||||
@@ -165,7 +189,6 @@ func (r *wikiLinkRenderer) render(w util.BufWriter, source []byte, node ast.Node
|
|||||||
w.WriteString(`>`)
|
w.WriteString(`>`)
|
||||||
w.Write(util.EscapeHTML([]byte(display)))
|
w.Write(util.EscapeHTML([]byte(display)))
|
||||||
w.WriteString(`</a>`)
|
w.WriteString(`</a>`)
|
||||||
return ast.WalkContinue, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type wikiLinkExt struct{ root string }
|
type wikiLinkExt struct{ root string }
|
||||||
|
|||||||
Reference in New Issue
Block a user