Compare commits

..

16 Commits

Author SHA1 Message Date
luxick f78ebe7d5d Remove auto new tab for external links 2026-08-05 18:26:09 +02:00
luxick d943fc5596 Update CLAUDE.md 2026-08-05 18:25:52 +02:00
luxick e9061a221c Hide console on windows
The companion does no longer show a terminal window pop up
2026-08-03 19:29:27 +02:00
luxick 5a5305dd42 Update CLAUDE.md 2026-08-03 19:28:03 +02:00
luxick ab57377034 Fix toolbar on mobile. 2026-07-24 16:14:20 +02:00
luxick 65983ec6be Missing changes 2026-07-24 16:10:44 +02:00
luxick 52bc694ffa Allow image embedding 2026-07-24 16:10:29 +02:00
luxick e37dde1e40 preserve scroll position when entering page editor 2026-07-24 15:24:40 +02:00
luxick 3abcc94eca Improve desktop editing experience 2026-07-24 15:16:02 +02:00
luxick 8b9f47f793 Adjust saving button 2026-07-24 12:10:16 +02:00
luxick 85527cfde8 Improve table editing 2026-07-24 11:38:15 +02:00
luxick 5f835a8a4e Omit Files section when no files are present 2026-07-24 11:26:23 +02:00
luxick 1071dbaa09 "Today" link no longer goes directly to editor 2026-07-24 11:21:40 +02:00
luxick da30bb8fc3 Fix back-to-cancel behavior of editor 2026-07-24 10:55:29 +02:00
luxick a849474b26 Remove obsolete parts from overlay 2026-07-23 18:06:12 +02:00
luxick 49c6240416 Remove TODO.txt integration 2026-07-23 17:38:13 +02:00
28 changed files with 618 additions and 853 deletions
+3 -132
View File
@@ -1,136 +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).
## 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
- The editor is a *mode* of a page, not a destination. `history-nav.js` turns any
same-path `?edit` link (and the editor's CANCEL link back out) into
`location.replace`, and SAVE POSTs via fetch and then rewrites the entry with
the saved page. Net effect: an edit session never occupies a history entry of
its own. Links to a *different* page's editor (new page / new child) still push.
The save POST answers `204` + `X-Target` when the request carries
`X-Save-Mode: replace`, because the target may hold a `#section` anchor only
the server can compute and fetch drops fragments from followed redirects.
- 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
-12
View File
@@ -18,8 +18,6 @@ Minimal self-hosted personal wiki. Folders are pages.
- **Quick-add bookmarklet** save the current browser tab to a predetermined wiki page (e.g. `/Topics/Bookmarks/`) with one click. See the [Quick-Add Bookmarklet](#quick-add-bookmarklet) section. - **Quick-add bookmarklet** save the current browser tab to a predetermined wiki page (e.g. `/Topics/Bookmarks/`) with one click. See the [Quick-Add Bookmarklet](#quick-add-bookmarklet) section.
- **Todo list** a `todo.txt` in the wiki root appears as an always-editable, syntax-highlighted widget above the folder tree. See the [Todo List](#todo-list) section.
## Build ## Build
```bash ```bash
@@ -108,16 +106,6 @@ They resolve against the year page rather than separate per-day URLs:
Legacy `YYYY/MM/` and `YYYY/MM/DD/` URLs (no longer the canonical form) redirect to the matching anchor on the year page. Legacy `YYYY/MM/` and `YYYY/MM/DD/` URLs (no longer the canonical form) redirect to the matching anchor on the year page.
## Todo List
A `todo.txt` in the **wiki root** renders as an editable, syntax-highlighted
widget above the folder tree, in [todo.txt](https://github.com/todotxt/todo.txt)
format (`x ` done, `(A)` priority, `+project` / `@context`, dates). No file, no
widget — create it out-of-band to opt in. Edits autosave (no save button), and
open tabs on other devices auto-refresh when the file changes. A toolbar acts on
the current line: `[done]` toggles the `x <date> ` prefix, `[pri]` cycles
priority, `[date]` inserts today, `[del]` deletes the line.
## Quick-Add Bookmarklet ## Quick-Add Bookmarklet
Replace `wiki.host` with your wiki host and `/Topics/Bookmarks/` with the destination page (one bookmarklet per target): Replace `wiki.host` with your wiki host and `/Topics/Bookmarks/` with the destination page (one bookmarklet per target):
+3 -1
View File
@@ -123,7 +123,9 @@
// folder anchors there end in "/" and fall through to navigation. // folder anchors there end in "/" and fall through to navigation.
// Search file-result anchors join in too: page results end in "/" // Search file-result anchors join in too: page results end in "/"
// and fall through to navigation, file results don't and open locally. // and fall through to navigation, file results don't and open locally.
var anchor = e.target.closest('.list-item a, a.thumb-tile, .photo-grid a, aside.tree-sidebar a.tree-file, .search-card a'); // 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).
+1 -1
View File
@@ -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"}}
+25
View File
@@ -23,13 +23,38 @@
}; };
} }
// Enter/Tab cell navigation must yield to an open autocomplete popup so those
// keys still accept a suggestion (e.g. a [[wiki-link]] typed inside a cell).
// The table keymap sits at Prec.highest, so without this guard it would
// swallow the key before the completion keymap ever sees it. Detect the popup
// via its tooltip element rather than exporting completionStatus from the
// vendored bundle (which would force a bundle rebuild).
function completionOpen() {
return !!document.querySelector('.cm-tooltip-autocomplete');
}
function tableNavKey(fn) {
var handler = tableKey(fn);
return function (view) {
if (completionOpen()) return false;
return handler(view);
};
}
var tableKeymap = [ 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(),
+86
View File
@@ -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,
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -4,8 +4,9 @@
switch (e.key) { switch (e.key) {
case 'E': case 'E':
e.preventDefault(); e.preventDefault();
// replace, not assign — same reasoning as history-nav.js. // assign, not replace — opening the editor pushes a history
window.location.replace(window.location.pathname + '?edit'); // entry so Back cancels the edit (see history-nav.js).
window.location.href = window.location.pathname + '?edit';
break; break;
case 'N': case 'N':
e.preventDefault(); e.preventDefault();
+16 -12
View File
@@ -1,14 +1,14 @@
// Keeps the page editor out of the browser history. // 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 for the page you are already on is a mode switch, not a // Opening the editor pushes a normal history entry, so Back exits the editor
// new destination, so it replaces the current history entry instead of pushing // and returns to the page (the primary "back to cancel" gesture on mobile).
// one; CANCEL replaces it right back, and SAVE does the same via postSave in
// editor/main.js. Without this, page -> edit -> save leaves
// [prev, page, editor, page'] behind and Back walks through the editor and a
// stale pre-save snapshot of the page before reaching prev.
// //
// Links to a *different* page's editor (new page, new child) still push — the // Leaving the editor by CANCEL is the one transition we rewrite: the CANCEL
// page you started from has to stay in history. // 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 () {
function isEdit(loc) { function isEdit(loc) {
return new URLSearchParams(loc.search).has('edit'); return new URLSearchParams(loc.search).has('edit');
@@ -20,12 +20,16 @@
var a = e.target.closest ? e.target.closest('a[href]') : null; var a = e.target.closest ? e.target.closest('a[href]') : null;
if (!a || a.target || a.hasAttribute('download')) return; 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); var url = new URL(a.href, window.location.href);
if (url.origin !== window.location.origin) return; if (url.origin !== window.location.origin) return;
if (url.pathname !== window.location.pathname) return; if (url.pathname !== window.location.pathname) return;
// Same page: only editor entry/exit is a mode switch. Plain anchor // Leaving the editor to another page (e.g. a wikilink) keeps its normal
// links share the pathname too and must keep their normal behaviour. // push; only the same-page exit (CANCEL) is collapsed.
if (!isEdit(url) && !isEdit(window.location)) return; if (isEdit(url)) return;
e.preventDefault(); e.preventDefault();
window.location.replace(url.href); window.location.replace(url.href);
+3 -4
View File
@@ -11,15 +11,15 @@
<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="/_/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> {{if not .EditMode}}<script src="/_/overlay.js" defer></script>
<script src="/_/tree-sidebar.js" defer></script> <script src="/_/tree-sidebar.js" defer></script>{{end}}
<script src="/_/todo-rail.js" defer></script>{{end}}
{{block "headScripts" .}}{{end}} {{block "headScripts" .}}{{end}}
</head> </head>
<body data-editor-version="{{editorBundleVersion}}"> <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><span class="app-name"> datascape</span></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>
@@ -33,7 +33,6 @@
</header> </header>
<div class="shell"> <div class="shell">
{{if not .EditMode}}<aside class="tree-sidebar col"> {{if not .EditMode}}<aside class="tree-sidebar col">
<div class="todo-rail" data-todo-rail hidden></div>
<div class="tree-scroll"></div> <div class="tree-scroll"></div>
</aside>{{end}} </aside>{{end}}
<div class="center"> <div class="center">
+5 -17
View File
@@ -4,10 +4,10 @@
// low-level focus/Escape/bfcache patterns from modal.js rather than that // low-level focus/Escape/bfcache patterns from modal.js rather than that
// component's panel chrome. // component's panel chrome.
// //
// openOverlay(node, opts) MOVES `node` (an existing rail <aside>) into the // openOverlay(node) MOVES `node` (an existing rail <aside>) into the overlay
// overlay body and restores it to its original DOM position on close, so the // body and restores it to its original DOM position on close, so the rail keeps
// rail keeps its state (expanded tree folders, rendered TOC). Only one overlay // its state (expanded tree folders, rendered TOC). Only one overlay is open at
// is open at a time. // a time.
(function () { (function () {
var overlay = null; var overlay = null;
var bodyEl = null; var bodyEl = null;
@@ -16,7 +16,6 @@
var placeholder = null; // marks where to restore it var placeholder = null; // marks where to restore it
var prevFocus = null; var prevFocus = null;
var onKeydown = null; var onKeydown = null;
var onCloseCb = null; // opts.onClose of the currently hosted node
function build() { function build() {
overlay = document.createElement('div'); overlay = document.createElement('div');
@@ -51,13 +50,11 @@
overlay.appendChild(bodyEl); overlay.appendChild(bodyEl);
} }
function open(node, opts) { function open(node) {
if (overlay && overlay.parentNode) close(); if (overlay && overlay.parentNode) close();
if (!overlay) build(); if (!overlay) build();
opts = opts || {};
prevFocus = document.activeElement; prevFocus = document.activeElement;
onCloseCb = typeof opts.onClose === 'function' ? opts.onClose : null;
hosted = node; hosted = node;
placeholder = document.createComment('overlay-slot'); placeholder = document.createComment('overlay-slot');
node.parentNode.insertBefore(placeholder, node); node.parentNode.insertBefore(placeholder, node);
@@ -72,9 +69,6 @@
document.addEventListener('keydown', onKeydown); document.addEventListener('keydown', onKeydown);
setTimeout(function () { closeBtn.focus(); }, 0); setTimeout(function () { closeBtn.focus(); }, 0);
if (typeof opts.onOpen === 'function') opts.onOpen();
return { close: close };
} }
function close() { function close() {
@@ -98,12 +92,6 @@
if (toRestore && toRestore.focus) { if (toRestore && toRestore.focus) {
try { toRestore.focus(); } catch (e) {} try { toRestore.focus(); } catch (e) {}
} }
// Fires after the node is back in its original spot, so the callback
// sees the final DOM (e.g. the todo rail flushing an autosave).
var cb = onCloseCb;
onCloseCb = null;
if (cb) cb();
} }
// Belt-and-suspenders bfcache guard: force the overlay closed (restoring // Belt-and-suspenders bfcache guard: force the overlay closed (restoring
+61
View File
@@ -0,0 +1,61 @@
// Roughly preserve reading position when entering the editor.
//
// While reading a page we record how far the center column is scrolled (as a
// fraction of its scrollable height, keyed by path). When the same path opens
// in the editor we restore that fraction, so you land near where you were
// reading instead of at the top. It is fraction-based — the rendered page and
// the raw-markdown editor have different heights — so the match is deliberately
// approximate. This only moves the viewport; the caret default (end of doc) is
// unchanged, so a click still decides where you actually edit.
(function () {
var center = document.querySelector('.center');
if (!center) return;
var key = 'scrollsync:' + location.pathname;
// Only restore a freshly-recorded position: a stale entry from earlier in
// the tab session would jump a direct ?edit open to the wrong place.
var MAX_AGE_MS = 5 * 60 * 1000;
var params = new URLSearchParams(location.search);
var inEditor = params.has('edit');
// Section / insert edits load only a slice of the document into the editor,
// so a whole-page reading fraction does not map onto them — restore only for
// a full-page ?edit.
var fullPageEdit = inEditor && !params.has('section') && !params.has('insert_before');
function maxScroll() { return Math.max(0, center.scrollHeight - center.clientHeight); }
if (inEditor) {
if (!fullPageEdit) return;
var raw = sessionStorage.getItem(key);
if (!raw) return;
var data;
try { data = JSON.parse(raw); } catch (e) { return; }
if (!data || typeof data.f !== 'number' || Date.now() - (data.t || 0) > MAX_AGE_MS) return;
// Wait for CodeMirror to mount and the column to reach full height, then
// map the fraction onto it. A double rAF after load lets CM's initial
// layout and focus scroll settle so ours wins.
window.addEventListener('load', function () {
requestAnimationFrame(function () {
requestAnimationFrame(function () {
center.scrollTop = data.f * maxScroll();
});
});
});
return;
}
// Reading mode: keep the latest scroll fraction recorded (throttled to one
// write per frame) so it is ready the moment the editor opens.
var pending = false;
center.addEventListener('scroll', function () {
if (pending) return;
pending = true;
requestAnimationFrame(function () {
pending = false;
var m = maxScroll();
try {
sessionStorage.setItem(key, JSON.stringify({ f: m > 0 ? center.scrollTop / m : 0, t: Date.now() }));
} catch (e) {}
});
}, { passive: true });
})();
+56 -71
View File
@@ -308,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;
@@ -410,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; }
@@ -432,6 +474,12 @@ body.edit-mode footer { max-width: none; }
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 {
@@ -466,9 +514,6 @@ body.edit-mode footer { max-width: none; }
.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)); } .fab-rail { bottom: calc(var(--space-4) + 3rem + var(--space-2)); }
/* Todo FAB: third slot, above the tree and right-rail FABs. Only created when
todo.txt exists (todo-rail.js). */
.fab-todo { bottom: calc(var(--space-4) + 2 * (3rem + var(--space-2))); }
/* === Companion status === */ /* === Companion status === */
.companion-status { margin-left: auto; } .companion-status { margin-left: auto; }
@@ -677,11 +722,6 @@ aside.sidebar:empty { display: none; }
padding: 0; padding: 0;
overflow: visible; overflow: visible;
} }
/* The todo widget fills the overlay: drop the rail divider and lift the
internal height cap so the whole file is editable on mobile. */
.overlay-body .todo-rail { border-bottom: none; padding-bottom: 0; }
.overlay-body .todo-rail .todo-rail-view,
.overlay-body .todo-rail .cm-editor { max-height: none; }
/* Right rail keeps its column spacing (TOC above widgets) inside the overlay; /* Right rail keeps its column spacing (TOC above widgets) inside the overlay;
the base .sidebar already sets flex-direction + gap. */ the base .sidebar already sets flex-direction + gap. */
.overlay-body .sidebar { display: flex; } .overlay-body .sidebar { display: flex; }
@@ -733,9 +773,9 @@ aside.sidebar:empty { display: none; }
/* === Tree sidebar (persistent left navigation rail) === /* === Tree sidebar (persistent left navigation rail) ===
Reuses the .tree-row / .tree-children / .tree-name / .tree-chevron modules. Reuses the .tree-row / .tree-children / .tree-name / .tree-chevron modules.
Desktop: a full-height flex column in the app-shell (composes with .col) Desktop: a full-height flex column in the app-shell (composes with .col)
holding the todo widget pinned on top and the tree's own scroll region holding the tree's own scroll region. Mobile: not laid out inline — its
below. Mobile: not laid out inline — its content is surfaced through the content is surfaced through the Overlay via the stacked FABs (see
Overlay via the stacked FABs (see responsive). */ responsive). */
/* No right padding on the aside: the scrolling children below reach the /* No right padding on the aside: the scrolling children below reach the
border-right so their scrollbars sit flush against the divider line (they border-right so their scrollbars sit flush against the divider line (they
pad their own content off the scrollbar instead). */ pad their own content off the scrollbar instead). */
@@ -746,9 +786,8 @@ aside.sidebar:empty { display: none; }
padding: var(--space-4) 0 var(--space-4) var(--space-4); padding: var(--space-4) 0 var(--space-4) var(--space-4);
border-right: var(--border-dashed); border-right: var(--border-dashed);
} }
/* The tree's scroll region: only the tree overflows, the todo widget above /* The tree's scroll region: the tree overflows here. Also the node the tree FAB
keeps its own internal scroll. Also the node the tree FAB moves into the moves into the Overlay, so font sizing lives here rather than on the aside. */
Overlay, so font sizing lives here rather than on the aside. */
.tree-scroll { .tree-scroll {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
@@ -760,63 +799,6 @@ aside.sidebar:empty { display: none; }
user-select: none; user-select: none;
} }
/* === Todo rail (root todo.txt widget) ===
Pinned above the folder tree (todo-rail.js). Hidden via the [hidden]
attribute until GET /_todo confirms the file exists. A long file scrolls
internally rather than pushing the tree off screen; the height cap is
lifted inside the Overlay. */
.todo-rail {
flex-shrink: 0;
padding-bottom: var(--space-3);
border-bottom: var(--border-dashed);
}
.todo-rail-head {
font-size: var(--font-xs);
margin-bottom: var(--space-2);
padding-right: var(--space-2);
}
/* Compact action toolbar in the widget header. Buttons are the app's standard
bracketed .btn/.btn-tool; the row just holds them together and lets them wrap
in the narrow rail rather than overflow. */
.todo-toolbar { flex-wrap: wrap; }
/* Roomier tap targets when the widget is surfaced full-screen on mobile. */
.overlay-body .todo-toolbar .btn {
font-size: var(--font-sm);
padding: var(--space-1) var(--space-2);
}
.todo-unsaved { color: var(--primary-hover); font-size: var(--font-xs); }
/* The scrolling children run edge-to-edge (the aside has no right padding) so
their scrollbars are flush with the divider; padding-right keeps their text
clear of the scrollbar. */
.todo-rail-view {
font-family: "Iosevka Slab", monospace;
font-size: var(--font-xs);
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
max-height: 40vh;
overflow-y: auto;
padding-right: var(--space-2);
cursor: text;
}
.todo-rail .cm-editor { max-height: 40vh; }
.todo-rail .cm-content { padding-right: var(--space-2); }
/* Hanging indent per task. Each todo (one logical line) starts flush-left and
its wrapped continuation lines are indented, so a new task is distinguishable
from a wrapped line even when it has no leading priority/date/x marker. The
indent is character-relative (ch) because it aligns to the monospace text,
not to the layout's spacing scale. */
.todo-rail .cm-line {
padding-left: 2ch;
text-indent: -2ch;
}
.todo-rail .todo-fallback {
width: 100%;
min-height: 10rem;
font-family: "Iosevka Slab", monospace;
font-size: var(--font-xs);
}
/* === Movie info box === */ /* === Movie info box === */
.movie-info { margin: var(--space-3) 0; } .movie-info { margin: var(--space-3) 0; }
.movie-info::after { content: ""; display: block; clear: both; } .movie-info::after { content: ""; display: block; clear: both; }
@@ -934,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;
-350
View File
@@ -1,350 +0,0 @@
// Root todo.txt rail widget. Surfaces wiki-root/todo.txt at the top of the
// left navigation rail (above the folder tree) and keeps it editable in place:
// a plain read view paints first (fast, no dependency), then a minimal
// CodeMirror instance (todo.txt highlighting from the bundle) always mounts —
// lazily, off the critical path, so the bundle fetch never delays first paint.
// Autosave is debounced + saves on blur via POST /_todo. While the tab is
// visible the widget polls /_todo (conditional GET, ETag) and reloads when
// another device changed the file — but only while the local buffer is clean,
// so an active typist is never interrupted (last-write-wins on a real clash).
// If the file is absent (GET /_todo → 404) the widget renders nothing and no
// FAB is created; the file is only ever created out-of-band. Mobile: a
// dedicated todo FAB opens the widget in the full-viewport Overlay
// (overlay.js), saving on dismiss.
(function () {
var container = document.querySelector('[data-todo-rail]');
if (!container) return;
var TODO_URL = '/_todo';
var DEBOUNCE_MS = 800;
var RETRY_MS = 5000;
var POLL_MS = 15000; // external-change check cadence (visible tab only)
var view = null; // CM EditorView once mounted
var fallback = null; // <textarea> fallback if the bundle fails to load
var readView = null; // placeholder shown until the editor mounts
var bodyEl = null; // hosts readView, then the editor
var marker = null; // "unsaved" indicator (shown on save failure)
var lastSaved = ''; // last text confirmed written to disk
var saveTimer = null;
var retryTimer = null;
var saving = false;
var loadingBundle = false;
var wantFocus = false; // user reached for the editor before it mounted
var knownETag = null; // ETag of the content currently in the buffer
var pendingAction = null; // toolbar action clicked before the editor mounted
fetch(TODO_URL, { credentials: 'same-origin', cache: 'no-store' }).then(function (r) {
if (r.status === 404) return null; // no todo.txt — no widget
if (!r.ok) throw new Error('HTTP ' + r.status);
knownETag = r.headers.get('ETag');
return r.text();
}).then(function (text) {
if (typeof text === 'string') { init(text); startPolling(); }
}).catch(function () { /* fetch failed — leave the widget hidden */ });
function init(text) {
lastSaved = text;
var head = document.createElement('div');
head.className = 'row space-between todo-rail-head';
// Toolbar replaces the old static "todo.txt" label. Each button acts on
// the current line of the editor via runAction; the labels render
// bracketed ([done] [pri] …) to match the app's button aesthetic.
var toolbar = document.createElement('div');
toolbar.className = 'row gap-1 todo-toolbar';
toolbar.appendChild(toolButton('pri', 'Cycle priority (A → B → C → D → none)', actions.priority));
toolbar.appendChild(toolButton('sort', 'Sort by priority, then context, then text', actions.sort));
toolbar.appendChild(toolButton('del', 'Delete this line', actions.del, 'danger'));
marker = document.createElement('span');
marker.className = 'todo-unsaved';
marker.textContent = 'unsaved';
marker.hidden = true;
head.appendChild(toolbar);
head.appendChild(marker);
bodyEl = document.createElement('div');
readView = document.createElement('div');
readView.className = 'todo-rail-view';
readView.tabIndex = 0;
readView.textContent = text;
// If the user reaches for the placeholder (click or keyboard tab both
// focus it — tabIndex 0) before the idle upgrade fires, mount the editor
// now and let it take focus so they can type without waiting.
readView.addEventListener('focus', focusUpgrade);
bodyEl.appendChild(readView);
container.appendChild(head);
container.appendChild(bodyEl);
container.hidden = false;
setupFab();
// Always upgrade to the live editor, but only once the browser is idle
// so the CM bundle fetch never competes with first paint. The read view
// is the placeholder shown until then.
scheduleUpgrade();
}
function focusUpgrade() {
wantFocus = true;
upgrade();
}
function scheduleUpgrade() {
if (window.requestIdleCallback) {
requestIdleCallback(function () { upgrade(); }, { timeout: 2000 });
} else {
setTimeout(upgrade, 200);
}
}
// Inject the vendored CM bundle (cache-busted with the same content hash the
// edit template uses, exposed on <body>), then mount the editor. If the
// bundle cannot load (offline/VPN blip), fall back to a plain textarea so
// editing and saving still work unstyled.
function upgrade() {
if (view || fallback || loadingBundle) return;
if (window.CM) { mountEditor(); return; }
loadingBundle = true;
var v = document.body.getAttribute('data-editor-version') || '';
var s = document.createElement('script');
s.src = '/_/editor/vendor/codemirror.bundle.js' + (v ? '?v=' + v : '');
s.onload = function () { loadingBundle = false; mountEditor(); };
s.onerror = function () { loadingBundle = false; mountFallback(); };
document.head.appendChild(s);
}
function mountEditor() {
var state = CM.EditorState.create({
doc: readView.textContent,
extensions: [
CM.history(),
CM.drawSelection(),
CM.EditorView.lineWrapping,
CM.todoLanguage,
CM.syntaxHighlighting(CM.todoHighlightStyle),
CM.todoTheme,
CM.keymap.of([].concat(CM.defaultKeymap, CM.historyKeymap)),
CM.EditorView.updateListener.of(function (u) {
if (u.docChanged) scheduleSave();
}),
CM.EditorView.domEventHandlers({ blur: function () { saveNow(); } }),
],
});
bodyEl.textContent = '';
view = new CM.EditorView({ state: state, parent: bodyEl });
// Only steal focus if the user actually reached for the editor — the
// passive idle mount must not grab focus or scroll the page.
if (wantFocus) view.focus();
// A toolbar button clicked during the mount runs now that the view exists.
if (pendingAction) { var p = pendingAction; pendingAction = null; p(view); }
}
function mountFallback() {
pendingAction = null; // the plain textarea can't run CM-based actions
fallback = document.createElement('textarea');
fallback.className = 'input todo-fallback';
fallback.value = readView.textContent;
fallback.addEventListener('input', scheduleSave);
fallback.addEventListener('blur', saveNow);
bodyEl.textContent = '';
bodyEl.appendChild(fallback);
if (wantFocus) fallback.focus();
}
// --- Toolbar ---------------------------------------------------------
function mainLine(v) { return v.state.doc.lineAt(v.state.selection.main.head); }
// Each action operates on the current line of the CM view. Kept deliberately
// simple (string edits at the line start / cursor) — no task parsing, per
// the todo.txt widget's highlighting-only remit.
var actions = {
// Cycle the leading priority: none → (A) → (B) → (C) → (D) → none.
priority: function (v) {
var line = mainLine(v);
var m = /^\(([A-Z])\) /.exec(line.text);
var next = !m ? '(A) ' : m[1] === 'A' ? '(B) ' : m[1] === 'B' ? '(C) '
: m[1] === 'C' ? '(D) ' : '';
v.dispatch({ changes: { from: line.from, to: line.from + (m ? m[0].length : 0), insert: next } });
},
// Sort the whole list. A plain case-insensitive line sort yields the
// desired priority → context → text order for free: "(A) " sorts ahead
// of everything (the "(" leads), so prioritised lines rise to the top in
// A/B/C/D order; the "@context" written right after the priority breaks
// ties, and the remaining text breaks those. Completed "x …" lines fall
// to the bottom (x sorts late). Blank lines are dropped so they don't
// float to the top.
sort: function (v) {
var doc = v.state.doc.toString();
var trailingNL = /\n$/.test(doc);
var lines = doc.split('\n').filter(function (l) { return l.trim() !== ''; });
lines.sort(function (a, b) {
var la = a.toLowerCase(), lb = b.toLowerCase();
if (la !== lb) return la < lb ? -1 : 1;
return a < b ? -1 : a > b ? 1 : 0;
});
var out = lines.join('\n') + (trailingNL ? '\n' : '');
if (out === doc) return;
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: out } });
},
del: function (v) { CM.deleteLine(v); },
};
function runAction(fn) {
if (view) { fn(view); view.focus(); return; }
if (fallback) return; // unstyled fallback has no CM view to act on
// Editor still mounting — remember the action and mount it now, focused.
pendingAction = fn;
wantFocus = true;
upgrade();
}
function toolButton(label, title, fn, extraClass) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'btn btn-tool' + (extraClass ? ' ' + extraClass : '');
b.textContent = label;
b.title = title;
// Keep the editor focused (and the mobile keyboard up) when tapping the
// toolbar, mirroring the page editor — preventDefault blocks the focus
// shift on mousedown; the click still fires.
b.addEventListener('mousedown', function (e) { e.preventDefault(); });
b.addEventListener('click', function () { runAction(fn); });
return b;
}
function currentText() {
if (view) return view.state.doc.toString();
if (fallback) return fallback.value;
return readView.textContent;
}
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(save, DEBOUNCE_MS);
}
function saveNow() {
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
save();
}
// Last write wins, no conflict detection (accepted). On failure: show the
// "unsaved" marker, keep the buffer untouched, retry silently.
function save() {
saveTimer = null;
var text = currentText();
if (text === lastSaved) return;
if (saving) { scheduleSave(); return; } // serialize; re-check afterwards
saving = true;
fetch(TODO_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'content=' + encodeURIComponent(text),
}).then(function (r) {
saving = false;
if (!r.ok) throw new Error('HTTP ' + r.status);
lastSaved = text;
// Adopt the server's new ETag so our own write polls back as 304
// instead of reloading itself as an "external" change.
knownETag = r.headers.get('ETag') || knownETag;
marker.hidden = true;
if (currentText() !== text) scheduleSave(); // typed while saving
}).catch(function () {
saving = false;
marker.hidden = false;
if (retryTimer) clearTimeout(retryTimer);
retryTimer = setTimeout(save, RETRY_MS);
});
}
// Replace the buffer with content another device wrote. Only ever called
// when the local buffer is clean (see poll), so nothing unsaved is lost.
function applyExternal(text, etag) {
knownETag = etag;
lastSaved = text;
if (view) {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } });
} else if (fallback) {
fallback.value = text;
} else if (readView) {
readView.textContent = text; // editor not mounted yet — reseed the placeholder
}
marker.hidden = true;
}
// Conditional GET: 304 means unchanged (cheap), 200 means another device
// wrote the file. Skip while hidden, while our own save is in flight, or
// while the buffer is dirty — a real concurrent clash resolves last-write-
// wins when this device saves, and the other device polls the result.
function poll() {
if (document.visibilityState !== 'visible') return;
if (saving || saveTimer) return;
if (currentText() !== lastSaved) return;
fetch(TODO_URL, {
credentials: 'same-origin',
cache: 'no-store',
headers: knownETag ? { 'If-None-Match': knownETag } : {},
}).then(function (r) {
if (r.status === 304 || !r.ok) return; // unchanged, or a transient error/404
var etag = r.headers.get('ETag');
return r.text().then(function (text) {
// Re-check: the user may have started typing during the fetch.
if (!saving && !saveTimer && currentText() === lastSaved) {
applyExternal(text, etag);
}
});
}).catch(function () { /* offline blip — retry next cycle */ });
}
function startPolling() {
setInterval(poll, POLL_MS);
// Coming back to the tab/window is the moment a device switch matters
// most, so refresh immediately instead of waiting for the next tick.
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') poll();
});
window.addEventListener('focus', poll);
}
// Closing the tab mid-debounce would drop the last jot; sendBeacon
// survives page teardown where fetch does not.
window.addEventListener('pagehide', function () {
var text = currentText();
if (text === lastSaved || !navigator.sendBeacon) return;
var body = new Blob(['content=' + encodeURIComponent(text)],
{ type: 'application/x-www-form-urlencoded' });
navigator.sendBeacon(TODO_URL, body);
});
// Mobile: dedicated todo FAB (above the tree FAB in the stacked group,
// created only when todo.txt exists) opens the widget in the full-viewport
// Overlay; dismissing the overlay flushes any pending save.
function setupFab() {
var fab = document.createElement('button');
fab.type = 'button';
fab.className = 'btn btn-fab fab fab-todo';
fab.title = 'Todo';
fab.setAttribute('aria-label', 'Todo');
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="M2 2h12v12H2z"/><path d="M5 8.5l2 2L11 5.5"/></svg>';
fab.addEventListener('click', function () {
if (typeof openOverlay !== 'function') return;
openOverlay(container, { onClose: saveNow });
wantFocus = true;
if (view) {
// The editor may have mounted while the rail was display:none
// (measured at zero size); re-measure now that it is visible.
view.requestMeasure();
view.focus();
} else {
upgrade(); // idle mount hasn't run yet — force it, focused
}
});
document.body.appendChild(fab);
}
})();
+5 -7
View File
@@ -7,8 +7,7 @@
// omits the container). // omits the container).
(function () { (function () {
// The aside is shared with the todo widget (todo-rail.js); this script owns // This script owns the .tree-scroll child of the aside, not the aside itself.
// only the .tree-scroll child so neither feature clobbers the other.
var container = document.querySelector('aside.tree-sidebar .tree-scroll'); var container = document.querySelector('aside.tree-sidebar .tree-scroll');
if (!container) return; if (!container) return;
@@ -190,11 +189,10 @@
} }
// Mobile: the tree FAB sits at the bottom of the stacked FAB group and // Mobile: the tree FAB sits at the bottom of the stacked FAB group and
// opens the tree's scroll container (not the whole aside — the todo widget // opens the tree's scroll container in the full-viewport Overlay
// has its own FAB/overlay) in the full-viewport Overlay (overlay.js). The // (overlay.js). The container always exists when not editing, so — per the
// container always exists when not editing, so — per the layout spec — // layout spec — the FAB always renders; the overlay auto-closes when a
// the FAB always renders; the overlay auto-closes when a folder/file link // folder/file link inside it navigates. Hidden on desktop by .fab CSS.
// inside it navigates. Hidden on desktop by .fab CSS.
function setupFab() { function setupFab() {
var fab = document.createElement('button'); var fab = document.createElement('button');
fab.type = 'button'; fab.type = 'button';
+3 -1
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
//go:build !windows
package main
import "os/exec"
// Allows running commands without showing a terminal window on startup when not running on windows
func hideConsole(*exec.Cmd) {}
+18
View File
@@ -0,0 +1,18 @@
package main
import (
"os/exec"
"syscall"
)
// This is the windows CREATE_NO_WINDOW syscall.
// We have no golang.org/x/sys dependency, so this will suffice
const createNoWindow = 0x08000000
// Allows running commands without showing a terminal window on startup on windows
func hideConsole(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: createNoWindow,
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"path/filepath" "path/filepath"
) )
const version = "1" const version = "2"
func main() { func main() {
flag.Parse() flag.Parse()
+3 -11
View File
@@ -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&section=N when ?edit is set). // /diary/<root>/YYYY/#YYYY-MM (or to ?edit&section=N when ?edit is set).
// 3. A virtual day URL (/diary/<root>/YYYY/MM/DD/) redirects to // 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":
+1 -61
View File
@@ -8,7 +8,7 @@ import { EditorState, EditorSelection, Compartment, Prec } from "@codemirror/sta
import { EditorView, keymap, drawSelection } from "@codemirror/view"; import { EditorView, keymap, drawSelection } from "@codemirror/view";
import { history, historyKeymap, defaultKeymap, indentWithTab, undo, redo, deleteLine } from "@codemirror/commands"; import { history, historyKeymap, defaultKeymap, indentWithTab, undo, redo, deleteLine } from "@codemirror/commands";
import { markdown, markdownLanguage, markdownKeymap } from "@codemirror/lang-markdown"; import { markdown, markdownLanguage, markdownKeymap } from "@codemirror/lang-markdown";
import { syntaxHighlighting, HighlightStyle, indentOnInput, StreamLanguage } from "@codemirror/language"; import { syntaxHighlighting, HighlightStyle, indentOnInput } from "@codemirror/language";
import { import {
autocompletion, autocompletion,
closeBrackets, closeBrackets,
@@ -77,63 +77,6 @@ const highlightStyle = HighlightStyle.define([
{ tag: [tags.processingInstruction, tags.meta], color: "var(--text-muted)" }, { tag: [tags.processingInstruction, tags.meta], color: "var(--text-muted)" },
]); ]);
// todo.txt language for the left-rail todo widget (assets/todo-rail.js).
// Line-oriented: `x ` done-prefix strikes the whole line; `(A)` priority,
// `+project`, `@context` and ISO dates get their own colors. No parsing into
// task objects — highlighting only.
const todoLanguage = StreamLanguage.define({
token(stream) {
if (stream.sol() && stream.match(/^x\s.*/)) return "todoDone";
if (stream.sol() && stream.match(/^\([A-Z]\)(?=\s|$)/)) return "todoPriority";
// +project / @context / dates only count at word starts (start of line
// or after whitespace), per the todo.txt format.
const atWordStart = stream.start === 0 || /\s/.test(stream.string.charAt(stream.start - 1));
if (atWordStart) {
if (stream.match(/^\d{4}-\d{2}-\d{2}(?=\s|$)/)) return "todoDate";
if (stream.match(/^\+\S+/)) return "todoProject";
if (stream.match(/^@\S+/)) return "todoContext";
}
// Consume whitespace OR one plain word — never both, or the word after
// a space would be swallowed before it can be matched above.
if (!stream.eatSpace()) {
stream.next();
stream.eatWhile(/\S/);
}
return null;
},
tokenTable: {
todoDone: tags.strikethrough,
todoPriority: tags.keyword,
todoProject: tags.typeName,
todoContext: tags.atom,
todoDate: tags.meta,
},
});
const todoHighlightStyle = HighlightStyle.define([
{ tag: tags.strikethrough, textDecoration: "line-through", color: "var(--text-muted)" },
{ tag: tags.keyword, color: "var(--secondary)", fontWeight: "bold" },
{ tag: tags.typeName, color: "var(--link)" },
{ tag: tags.atom, color: "var(--primary-hover)" },
{ tag: tags.meta, color: "var(--text-muted)" },
]);
// Minimal chrome for the rail editor — the full editor `theme` above forces a
// 60vh min-height and page-editor padding that don't fit a sidebar widget.
// Sizing/height caps live in style.css under .todo-rail.
const todoTheme = EditorView.theme(
{
"&": { backgroundColor: "transparent", color: "var(--text)", fontSize: "var(--font-xs)" },
"&.cm-focused": { outline: "none" },
".cm-scroller": { fontFamily: '"Iosevka Slab", monospace', lineHeight: "1.5" },
".cm-content": { caretColor: "var(--text)" },
".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--text)" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
{ backgroundColor: "var(--bg-panel-hover)" },
},
{ dark: true }
);
window.CM = { window.CM = {
EditorState, EditorState,
EditorSelection, EditorSelection,
@@ -161,7 +104,4 @@ window.CM = {
startCompletion, startCompletion,
theme, theme,
highlightStyle, highlightStyle,
todoLanguage,
todoHighlightStyle,
todoTheme,
}; };
+254
View File
@@ -0,0 +1,254 @@
package main
import (
"bytes"
"path"
"regexp"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
// wikiEmbedRe matches an ![[...]] token anchored at the current inline reader.
// The inner blob forbids brackets and newlines but allows ':' so the whole
// target::caption::align payload travels as one capture — the caption and
// alignment tail is split apart in Go (parseEmbedFields). This mirrors
// wikiLinkPattern's shape so the move rewriter needs no embed-specific changes.
var wikiEmbedRe = regexp.MustCompile(`^!\[\[([^\[\]\n]+)\]\]`)
const (
alignLeft = "left"
alignRight = "right"
alignCenter = "center"
)
// wikiEmbedNode is the AST node produced by wikiEmbedParser.
type wikiEmbedNode struct {
ast.BaseInline
Target string
Caption string
Align string
}
var kindWikiEmbed = ast.NewNodeKind("WikiEmbed")
func (n *wikiEmbedNode) Kind() ast.NodeKind { return kindWikiEmbed }
func (n *wikiEmbedNode) Dump(source []byte, level int) {
ast.DumpHelper(n, source, level, map[string]string{
"Target": n.Target,
"Caption": n.Caption,
"Align": n.Align,
}, nil)
}
// alignKeyword reports whether s (trimmed) is one of the alignment keywords.
func alignKeyword(s string) (string, bool) {
switch strings.TrimSpace(s) {
case alignLeft:
return alignLeft, true
case alignRight:
return alignRight, true
case alignCenter:
return alignCenter, true
}
return "", false
}
// parseEmbedFields splits the inner ![[...]] blob into target, caption, and
// alignment per the embed syntax rules. The target is the field before the
// first "::". The trailing "::"-fields are interpreted as:
// - none: no caption, default alignment (right)
// - one keyword field: that alignment, no caption
// - one non-keyword field: that caption, default alignment
// - two or more: the last field is the alignment slot (default when it is not
// a keyword) and the earlier fields re-join with "::" as the caption, so a
// caption may contain a literal "::" as long as an alignment field trails it.
func parseEmbedFields(inner string) (target, caption, align string) {
fields := strings.Split(inner, "::")
target = strings.TrimSpace(fields[0])
tail := fields[1:]
align = alignRight
switch len(tail) {
case 0:
// target only
case 1:
if kw, ok := alignKeyword(tail[0]); ok {
align = kw
} else {
caption = strings.TrimSpace(tail[0])
}
default:
if kw, ok := alignKeyword(tail[len(tail)-1]); ok {
align = kw
}
caption = strings.TrimSpace(strings.Join(tail[:len(tail)-1], "::"))
}
return target, caption, align
}
type wikiEmbedParser struct{}
func (p *wikiEmbedParser) Trigger() []byte { return []byte{'!'} }
func (p *wikiEmbedParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
line, _ := block.PeekLine()
// Require the exact "![[" opener; a bare '!' (or a Markdown image "![](…)")
// falls through to goldmark's default image parser at priority 200.
if len(line) < 5 || line[0] != '!' || line[1] != '[' || line[2] != '[' {
return nil
}
m := wikiEmbedRe.FindSubmatchIndex(line)
if m == nil {
return nil
}
target, caption, align := parseEmbedFields(string(line[m[2]:m[3]]))
if !isValidWikiTarget([]byte(target)) {
return nil
}
block.Advance(m[1])
return &wikiEmbedNode{Target: target, Caption: caption, Align: align}
}
// embedBlockTransformer lifts an embed that sits alone in a paragraph up to
// block level. Goldmark wraps inline content in <p>, but the embed renders a
// <figure> (block), and a <figure> inside a <p> is auto-closed by the browser —
// which strands empty <p> elements (they still carry .content paragraph margins)
// and breaks the float layout once several embeds share a page. Dissolving the
// wrapping paragraph makes each embed render as a clean block sibling with no
// stray <p>.
type embedBlockTransformer struct{ root string }
func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
source := reader.Source()
var paras []*ast.Paragraph
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if p, ok := n.(*ast.Paragraph); ok && t.paragraphFiguresOnly(p, source) {
paras = append(paras, p)
}
}
return ast.WalkContinue, nil
})
for _, p := range paras {
parent := p.Parent()
if parent == nil {
continue
}
// InsertBefore isolates the embed from p first, so hoist each embed to a
// block sibling ahead of the (soon removed) paragraph, then drop p.
for c := p.FirstChild(); c != nil; {
next := c.NextSibling()
if _, ok := c.(*wikiEmbedNode); ok {
parent.InsertBefore(parent, p, c)
}
c = next
}
parent.RemoveChild(parent, p)
}
}
// paragraphFiguresOnly reports whether p holds at least one figure-rendering
// embed and nothing else visible — only such embeds and whitespace/line-break
// text. Only these are safe to dissolve: a paragraph carrying prose, or an embed
// that degrades to an inline link (missing / non-image target), stays wrapped so
// the fallback anchor keeps its paragraph.
func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []byte) bool {
hasFigure := false
for c := p.FirstChild(); c != nil; c = c.NextSibling() {
switch n := c.(type) {
case *wikiEmbedNode:
if !embedIsImage(t.root, n.Target) {
return false
}
hasFigure = true
case *ast.Text:
if len(bytes.TrimSpace(n.Segment.Value(source))) != 0 {
return false
}
default:
return false
}
}
return hasFigure
}
// embedIsImage reports whether target resolves to an existing image file, i.e.
// the embed will render as a <figure> rather than degrade to a link fallback.
func embedIsImage(root, target string) bool {
name := path.Base(normalizeWikiTarget(target))
return wikiTargetExists(root, target) && isImageFile(name)
}
type wikiEmbedRenderer struct {
root string
}
func (r *wikiEmbedRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(kindWikiEmbed, r.render)
}
func (r *wikiEmbedRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*wikiEmbedNode)
// Embed only when the target exists and is an image we can thumbnail.
// Anything else — a missing target, or an existing non-image file — degrades
// to the same anchor a plain [[wikilink]] would render (broken or working).
if !embedIsImage(r.root, n.Target) {
writeWikiLink(w, r.root, n.Target, "")
return ast.WalkContinue, nil
}
fileHref := wikiFileHref(n.Target)
alt := n.Caption
if alt == "" {
alt = path.Base(normalizeWikiTarget(n.Target))
}
w.WriteString(`<figure class="embed embed-`)
w.WriteString(n.Align)
w.WriteString(`"><a class="embed-link" href="`)
w.WriteString(fileHref)
w.WriteString(`"><img src="`)
w.WriteString(thumbURL(fileHref, 300))
w.WriteString(`" alt="`)
w.Write(util.EscapeHTML([]byte(alt)))
w.WriteString(`"></a>`)
if n.Caption != "" {
w.WriteString(`<figcaption>`)
w.Write(util.EscapeHTML([]byte(n.Caption)))
w.WriteString(`</figcaption>`)
}
w.WriteString(`</figure>`)
return ast.WalkContinue, nil
}
type wikiEmbedExt struct{ root string }
// newWikiEmbedExt returns a goldmark extension that turns ![[...]] tokens into
// image embeds resolved against root.
func newWikiEmbedExt(root string) goldmark.Extender {
return &wikiEmbedExt{root: root}
}
func (e *wikiEmbedExt) Extend(m goldmark.Markdown) {
// Priority 199 — one higher than the default image parser (200) so ![[...]]
// is consumed as an embed before the default `!` parser sees it.
m.Parser().AddOptions(parser.WithInlineParsers(
util.Prioritized(&wikiEmbedParser{}, 199),
))
m.Parser().AddOptions(parser.WithASTTransformers(
util.Prioritized(embedBlockTransformer{root: e.root}, 100),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&wikiEmbedRenderer{root: e.root}, 500),
))
}
-57
View File
@@ -1,57 +0,0 @@
package main
import (
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
type extLinksTransformer struct{}
func (extLinksTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
link, ok := n.(*ast.Link)
if !ok {
return ast.WalkContinue, nil
}
if isExternalURL(string(link.Destination)) {
link.SetAttribute([]byte("target"), []byte("_blank"))
link.SetAttribute([]byte("rel"), []byte("noopener noreferrer"))
}
return ast.WalkContinue, nil
})
}
func isExternalURL(dest string) bool {
if strings.HasPrefix(dest, "//") {
return true
}
i := strings.Index(dest, ":")
if i <= 0 {
return false
}
for _, c := range dest[:i] {
if !(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') &&
!(c >= '0' && c <= '9') && c != '+' && c != '-' && c != '.' {
return false
}
}
return true
}
type extLinksExt struct{}
func newExtLinksExt() goldmark.Extender { return &extLinksExt{} }
func (e *extLinksExt) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithASTTransformers(
util.Prioritized(extLinksTransformer{}, 999),
))
}
+2 -4
View File
@@ -36,9 +36,8 @@ func hashAsset(name string) string {
return hex.EncodeToString(sum[:])[:12] return hex.EncodeToString(sum[:])[:12]
} }
// tmplFuncs is shared by every layout-based template: layout.html renders the // tmplFuncs is shared by every layout-based template: the edit template appends
// bundle version as a data attribute on <body> so global JS (todo-rail.js) can // editorBundleVersion to its CodeMirror <script> src to cache-bust the bundle.
// cache-bust its lazy bundle load the same way the edit template does.
var tmplFuncs = template.FuncMap{ var tmplFuncs = template.FuncMap{
"editorBundleVersion": func() string { return editorBundleVersion }, "editorBundleVersion": func() string { return editorBundleVersion },
"fileIcon": fileIcon, "fileIcon": fileIcon,
@@ -125,7 +124,6 @@ func main() {
http.HandleFunc("/_logout", h.handleLogout) http.HandleFunc("/_logout", h.handleLogout)
http.HandleFunc("/_reindex", h.handleReindex) http.HandleFunc("/_reindex", h.handleReindex)
http.HandleFunc("/_search", h.handleSearchSuggest) http.HandleFunc("/_search", h.handleSearchSuggest)
http.HandleFunc("/_todo", h.handleTodo)
http.HandleFunc("/quickadd", h.handleQuickAdd) http.HandleFunc("/quickadd", h.handleQuickAdd)
http.Handle("/", h) http.Handle("/", h)
+8 -1
View File
@@ -24,7 +24,7 @@ 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(), html.WithHardWraps()), goldmark.WithRendererOptions(html.WithUnsafe(), html.WithHardWraps()),
) )
@@ -232,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.
+10
View File
@@ -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 {
-86
View File
@@ -1,86 +0,0 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"os"
"path/filepath"
)
// Root todo.txt rail widget (assets/todo-rail.js). /_todo reads and writes the
// single wiki-root todo.txt — the path is fixed, so there is no traversal
// surface. GET returns the raw text (404 when the file is absent, which the
// widget treats as "render nothing"); POST replaces the whole file atomically.
// Unlike the index.md save path, empty content does NOT delete the file: the
// widget has no in-UI way to recreate it, so an empty save keeps an empty file.
//
// Both GET and POST carry an ETag (a content hash) so the widget can poll for
// external edits: it revalidates GET with If-None-Match and reloads only when
// another device actually changed the bytes. The POST response echoes the new
// ETag so a device's own save comes back 304 rather than reloading itself.
func (h *handler) todoPath() string {
return filepath.Join(h.root, "todo.txt")
}
// todoETag is a strong validator derived from the file content — not mtime, so
// a save of identical bytes (or a touch) does not look like a change.
func todoETag(data []byte) string {
sum := sha256.Sum256(data)
return `"` + hex.EncodeToString(sum[:])[:16] + `"`
}
func (h *handler) handleTodo(w http.ResponseWriter, r *http.Request) {
if !h.checkAuth(w, r) {
return
}
switch r.Method {
case http.MethodGet:
h.handleTodoGet(w, r)
case http.MethodPost:
h.handleTodoPost(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *handler) handleTodoGet(w http.ResponseWriter, r *http.Request) {
data, err := os.ReadFile(h.todoPath())
if err != nil {
if os.IsNotExist(err) {
http.NotFound(w, r)
return
}
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
return
}
// The rail re-fetches on every page load and polls for external edits; a
// heuristically cached copy would show stale todos. Revalidation is driven
// by the ETag (If-None-Match) rather than the browser's own HTTP cache.
etag := todoETag(data)
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "no-store")
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write(data)
}
func (h *handler) handleTodoPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
content := r.FormValue("content")
if err := writeFileAtomic(h.todoPath(), []byte(content), 0644); err != nil {
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return
}
// Echo the new ETag so the saving client can adopt it and skip reloading
// its own write on the next poll.
w.Header().Set("ETag", todoETag([]byte(content)))
w.WriteHeader(http.StatusNoContent)
}
+27 -4
View File
@@ -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 }