Compare commits
16 Commits
ddfd29ea0e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f78ebe7d5d | |||
| d943fc5596 | |||
| e9061a221c | |||
| 5a5305dd42 | |||
| ab57377034 | |||
| 65983ec6be | |||
| 52bc694ffa | |||
| e37dde1e40 | |||
| 3abcc94eca | |||
| 8b9f47f793 | |||
| 85527cfde8 | |||
| 5f835a8a4e | |||
| 1071dbaa09 | |||
| da30bb8fc3 | |||
| a849474b26 | |||
| 49c6240416 |
@@ -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
|
|
||||||
|
|||||||
@@ -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
@@ -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,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"}}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+15
-17
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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"))
|
return yearURL + "#" + dayHeading, true
|
||||||
sections := splitSections(raw)
|
|
||||||
if _, found := findSectionIndex(sections, dayHeading); found {
|
|
||||||
return yearURL + "#" + dayHeading, true
|
|
||||||
}
|
|
||||||
// Missing day: route through the insert flow so today's section
|
|
||||||
// is spliced in at the right chronological position.
|
|
||||||
insertIdx := computeInsertIndex(sections, dayHeading)
|
|
||||||
return fmt.Sprintf("%s?edit&insert_before=%d&heading=%s",
|
|
||||||
yearURL, insertIdx, url.QueryEscape(dayHeading)), true
|
|
||||||
case "this-month":
|
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
@@ -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,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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