Compare commits
20 Commits
e157b38e21
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c85ea3bb0c | |||
| 8081c2ebd8 | |||
| 02fa19272d | |||
| b3ca714597 | |||
| c7d5db7af7 | |||
| ed8cb79b0b | |||
| c30c4d3a0d | |||
| ab22952e3d | |||
| 316551d263 | |||
| 19017bf136 | |||
| 95ca30509c | |||
| 0e8e3b2636 | |||
| 613ada1f84 | |||
| 2121bb686e | |||
| b8caab7a71 | |||
| 2f08e15e3b | |||
| 568039a39b | |||
| ad4f58324a | |||
| 22a4d2d18b | |||
| 2635dce918 |
1
.gitignore
vendored
@@ -3,3 +3,4 @@ wiki/
|
||||
|
||||
# Binaries
|
||||
datascape
|
||||
*.exe
|
||||
|
||||
87
AGENTS.md
@@ -1,87 +0,0 @@
|
||||
# AGENTS.md — Personal Wiki (go-wiki)
|
||||
|
||||
## Project Philosophy
|
||||
This is a minimal personal wiki where the **folder structure is the wiki**.
|
||||
There is no database, no CMS, no abstraction layer between the user and their files.
|
||||
Every decision should reinforce this — if a proposed solution adds indirection between
|
||||
the filesystem and what the user sees, question it.
|
||||
|
||||
## Core Concept
|
||||
- Every folder *is* a page
|
||||
- `index.md` in a folder is that page's content
|
||||
- All related files (PDFs, images, CAD files, etc.) live in the same folder as `index.md`
|
||||
- Image links in `index.md` like `` work because siblings are served at the same path
|
||||
- There are no "attachments" — files are just files in a folder
|
||||
|
||||
## Target Environment
|
||||
- Runs on a QNAP TS-431P3 NAS (Annapurna Labs AL-314, ARMv7 32-bit, `linux/arm`)
|
||||
- All files live on the NAS and are mounted/accessed locally by the binary
|
||||
- Users access via browser over Wireguard VPN from Windows, Linux, and Android
|
||||
- Must cross-compile cleanly: `GOARCH=arm GOOS=linux GOARM=7 go build`
|
||||
|
||||
## Tech Constraints
|
||||
- **Language:** Go
|
||||
- **Output:** Single static binary, no installer, no runtime dependencies
|
||||
- **Markdown:** `goldmark` for server-side rendering — no other markdown libraries
|
||||
- **Assets:** Embedded via `embed.FS` — no external asset serving or CDN
|
||||
- **HTTP:** stdlib `net/http` only — no web framework
|
||||
- **Dependencies:** Keep to an absolute minimum. If stdlib can do it, use stdlib.
|
||||
|
||||
## HTTP Interface
|
||||
The entire API surface should stay minimal:
|
||||
|
||||
| Method | Path | Behaviour |
|
||||
|--------|------|-----------|
|
||||
| GET | `/{path}` | If folder: render `index.md` + list contents. If file: serve raw. |
|
||||
| GET | `/{path}?edit` | Mobile-friendly editor with `index.md` content in a textarea |
|
||||
| POST | `/{path}` | Write updated `index.md` to disk |
|
||||
|
||||
Do not add endpoints beyond these without a concrete stated need.
|
||||
|
||||
## UI Principles
|
||||
- Mobile-first — the primary editing device is Android over Wireguard
|
||||
- No JavaScript frameworks — vanilla JS only, and only when necessary
|
||||
- No build pipeline for frontend assets — what is embedded is what is served
|
||||
- Readable on small screens without zooming
|
||||
- Fast on a low-power ARM CPU — no heavy rendering, no large payloads
|
||||
|
||||
## Frontend Conventions
|
||||
|
||||
**JS file scoping:** each feature gets its own file. Global app behaviour goes in
|
||||
`global-shortcuts.js`. Feature-specific logic gets its own file (e.g. `editor.js`).
|
||||
Do not inline JS in the template or consolidate unrelated features into one file.
|
||||
|
||||
**Keyboard shortcuts:** `ALT+SHIFT` is the established modifier for all application
|
||||
shortcuts — it avoids collisions with browser and OS bindings. Do not use other
|
||||
modifiers for new shortcuts.
|
||||
|
||||
**Editor toolbar:** buttons use `data-action` (maps to a JS action function) and
|
||||
`data-key` (the `ALT+SHIFT+KEY` shortcut letter). Adding a `data-key` to a button
|
||||
automatically registers its shortcut — no extra wiring needed.
|
||||
|
||||
## Auth
|
||||
- Basic auth is sufficient — this is a personal tool on a private VPN
|
||||
- Do not over-engineer access control
|
||||
|
||||
## What to Avoid
|
||||
- No database of any kind
|
||||
- No indexing or caching layer unless explicitly requested and justified
|
||||
- No parallel folder structures (the DokuWiki anti-pattern: `pages/` mirrored by `media/`)
|
||||
- No frameworks (web, ORM, DI, etc.)
|
||||
- No build steps for frontend assets
|
||||
- Do not suggest Docker unless the user asks — a plain binary is preferred
|
||||
|
||||
## Development Order
|
||||
When building new features, follow this priority order:
|
||||
1. Correctness on the filesystem (never corrupt or lose user files)
|
||||
2. Mobile usability
|
||||
3. Simplicity of implementation
|
||||
4. Performance
|
||||
|
||||
## Out of Scope (for now)
|
||||
These are explicitly deferred — do not implement or scaffold unless asked:
|
||||
- Full-text search
|
||||
- File upload via browser
|
||||
- Version history / git integration
|
||||
- Multi-user support
|
||||
- Tagging or metadata beyond `index.md` content
|
||||
86
CLAUDE.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Project Overview
|
||||
|
||||
`datascape` is a minimal personal wiki where **the folder structure is the wiki**.
|
||||
No database, no CMS, no abstraction layer — every folder is a page, and `index.md`
|
||||
in a folder is that page's content.
|
||||
|
||||
## Build & Deploy
|
||||
|
||||
```bash
|
||||
# Local build (host architecture)
|
||||
go build .
|
||||
|
||||
# Deploy to NAS
|
||||
make deploy
|
||||
```
|
||||
|
||||
## 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` | Mobile-friendly editor with `index.md` content in a textarea |
|
||||
| POST | `/{path}` | Write `index.md` to disk; creates the folder if it does not exist yet |
|
||||
|
||||
Non-existent paths without a trailing slash redirect to the slash form (GET only — POSTs
|
||||
are not redirected because `path.Clean` strips the trailing slash from `PostURL` and the
|
||||
content would be lost).
|
||||
|
||||
Do not add new endpoints without a concrete stated need.
|
||||
|
||||
## Code Structure
|
||||
|
||||
When adding a new special folder type, create a new `.go` file. Do not add type-specific logic to `main.go` or `render.go`.
|
||||
|
||||
Prefer separate, human-readable `.html` files over inlined HTML strings in Go. Embed them via `embed.FS` if needed.
|
||||
|
||||
|
||||
## Architecture Rules
|
||||
|
||||
- **Single binary** — no installer, no runtime dependencies, no Docker
|
||||
- **Go stdlib `net/http`** only — no web framework
|
||||
- **`goldmark`** for Markdown rendering — no other Markdown libraries
|
||||
- **`embed.FS`** for all assets — no external serving, no CDN
|
||||
- **No database** of any kind
|
||||
- **No indexing or caching** unless explicitly requested and justified
|
||||
- Keep dependencies to an absolute minimum; if stdlib can do it, use stdlib
|
||||
|
||||
## Frontend Rules
|
||||
|
||||
- Vanilla JS only — no frameworks, no build pipeline
|
||||
- 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
|
||||
- Prefer generic, descriptive CSS classes (`btn`, `btn-small`, `muted`, `danger`) over element-specific names (`save-button`, `cancel-button`, `form-name-input`). Use a modifier + base class pattern (`btn btn-small`) rather than one-off classes that duplicate shared styles.
|
||||
- Where possible, re-use existing CSS classes
|
||||
|
||||
## 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 long-form dates: German locale, e.g. `Mittwoch, 1. April 2026` — use `formatGermanDate` in `diary.go`; Go's `time.Format` is English-only so locale names are kept in maps keyed by `time.Weekday` / `time.Month`
|
||||
|
||||
## 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
|
||||
37
README.md
@@ -25,6 +25,39 @@ GOOS=linux GOARCH=arm go build -o datascape .
|
||||
|--------|-----|
|
||||
| Browse | Navigate folders at `/` |
|
||||
| Read | Any folder with `index.md` renders it as HTML |
|
||||
| Edit | Append `?edit` to any folder URL, or click **Edit** |
|
||||
| Save | POST from the edit form writes `index.md` to disk |
|
||||
| Edit | Append `?edit` to any folder URL, or click **[EDIT]** (Alt+Shift+E) |
|
||||
| Save | POST from the edit form writes `index.md` to disk; folder is created if needed |
|
||||
| New page | Click **[NEW]** (Alt+Shift+N), enter a name — opens the new page in edit mode |
|
||||
| Files | Drop PDFs, images, etc. next to `index.md` — they appear in the listing |
|
||||
|
||||
Navigating to a URL that does not exist shows an empty page with a **[CREATE]** prompt.
|
||||
|
||||
## Special Folder Types
|
||||
|
||||
A folder can opt into special rendering by adding a `.page-settings` file:
|
||||
|
||||
```
|
||||
type = diary
|
||||
```
|
||||
|
||||
### Diary
|
||||
|
||||
Designed for a chronological photo diary. Expected structure:
|
||||
|
||||
```
|
||||
FolderName/
|
||||
.page-settings ← type = diary
|
||||
YYYY/
|
||||
YYYY-MM-DD Desc.jpg ← photos named with date prefix
|
||||
MM/
|
||||
DD/
|
||||
index.md ← diary entry for that day
|
||||
```
|
||||
|
||||
| View | What renders |
|
||||
|------|-------------|
|
||||
| Year (`YYYY/`) | Section per month with link and photo count |
|
||||
| Month (`MM/`) | Section per day with entry content and photo grid |
|
||||
| Day (`DD/`) | Entry content and photo grid |
|
||||
|
||||
Days with photos but no `index.md` still appear in the month view and can be created by clicking their heading link.
|
||||
|
||||
12
assets/content.js
Normal file
@@ -0,0 +1,12 @@
|
||||
(function () {
|
||||
document.querySelectorAll('.content a[href^="http"]').forEach(function (a) {
|
||||
var hostname = new URL(a.href).hostname;
|
||||
var img = document.createElement('img');
|
||||
img.src = 'https://icons.duckduckgo.com/ip3/' + hostname + '.ico';
|
||||
img.width = 16;
|
||||
img.height = 16;
|
||||
img.style.verticalAlign = 'middle';
|
||||
img.style.marginRight = '3px';
|
||||
a.prepend(img);
|
||||
});
|
||||
})();
|
||||
7
assets/diary/diary-day.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{{if .Photos}}
|
||||
<div class="diary-photo-grid">
|
||||
{{range .Photos}}
|
||||
<a href="{{.URL}}" target="_blank"><img src="{{.URL}}" alt="{{.Name}}" loading="lazy"></a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
16
assets/diary/diary-month.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{{range .Days}}
|
||||
<div class="diary-section">
|
||||
<h2>
|
||||
{{if .URL}}<a href="{{.URL}}">{{.Heading}}</a>{{else}}{{.Heading}}{{end}}
|
||||
{{if .EditURL}}<a href="{{.EditURL}}" class="btn btn-small">edit</a>{{end}}
|
||||
</h2>
|
||||
{{if .Content}}<div class="content">{{.Content}}</div>{{end}}
|
||||
{{if .Photos}}
|
||||
<div class="diary-photo-grid">
|
||||
{{range .Photos}}
|
||||
<a href="{{.URL}}" target="_blank"><img src="{{.URL}}" alt="{{.Name}}" loading="lazy"></a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
8
assets/diary/diary-year.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{{range .Months}}
|
||||
<div class="diary-section">
|
||||
<h2 class="diary-heading">
|
||||
<a href="{{.URL}}">{{.Name}}</a>
|
||||
{{if .PhotoCount}}<span class="diary-photo-count">({{.PhotoCount}} photos)</span>{{end}}
|
||||
</h2>
|
||||
</div>
|
||||
{{end}}
|
||||
140
assets/editor.js
@@ -2,6 +2,10 @@
|
||||
var textarea = document.getElementById('editor');
|
||||
if (!textarea) return;
|
||||
|
||||
var form = textarea.closest('form');
|
||||
|
||||
// --- DOM helpers ---
|
||||
|
||||
function wrap(before, after, placeholder) {
|
||||
var start = textarea.selectionStart;
|
||||
var end = textarea.selectionEnd;
|
||||
@@ -26,7 +30,34 @@
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
var form = textarea.closest('form');
|
||||
function insertAtCursor(s) {
|
||||
var start = textarea.selectionStart;
|
||||
var end = textarea.selectionEnd;
|
||||
textarea.value = textarea.value.slice(0, start) + s + textarea.value.slice(end);
|
||||
textarea.selectionStart = textarea.selectionEnd = start + s.length;
|
||||
textarea.dispatchEvent(new Event('input'));
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
function applyResult(result) {
|
||||
textarea.value = result.text;
|
||||
textarea.selectionStart = textarea.selectionEnd = result.cursor;
|
||||
textarea.dispatchEvent(new Event('input'));
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
function applyTableOp(fn, arg) {
|
||||
var result = arg !== undefined
|
||||
? fn(textarea.value, textarea.selectionStart, arg)
|
||||
: fn(textarea.value, textarea.selectionStart);
|
||||
if (result) applyResult(result);
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
var T = EditorTables;
|
||||
var L = EditorLists;
|
||||
var D = EditorDates;
|
||||
|
||||
var actions = {
|
||||
save: function () { form.submit(); },
|
||||
@@ -42,8 +73,20 @@
|
||||
ul: function () { linePrefix('- '); },
|
||||
ol: function () { linePrefix('1. '); },
|
||||
hr: function () { wrap('\n\n---\n\n', '', ''); },
|
||||
fmttable: function () { applyTableOp(T.formatTableText); },
|
||||
tblalignleft: function () { applyTableOp(T.setColumnAlignment, 'left'); },
|
||||
tblaligncenter: function () { applyTableOp(T.setColumnAlignment, 'center'); },
|
||||
tblalignright: function () { applyTableOp(T.setColumnAlignment, 'right'); },
|
||||
tblinsertcol: function () { applyTableOp(T.insertColumn); },
|
||||
tbldeletecol: function () { applyTableOp(T.deleteColumn); },
|
||||
tblinsertrow: function () { applyTableOp(T.insertRow); },
|
||||
tbldeleterow: function () { applyTableOp(T.deleteRow); },
|
||||
dateiso: function () { insertAtCursor(D.isoDate()); },
|
||||
datelong: function () { insertAtCursor(D.longDate()); },
|
||||
};
|
||||
|
||||
// --- Keyboard shortcut registration ---
|
||||
|
||||
var keyMap = {};
|
||||
document.querySelectorAll('[data-action]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
@@ -55,6 +98,10 @@
|
||||
}
|
||||
});
|
||||
|
||||
keyMap['T'] = actions.fmttable;
|
||||
keyMap['D'] = actions.dateiso;
|
||||
keyMap['W'] = actions.datelong;
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (!e.altKey || !e.shiftKey) return;
|
||||
var action = keyMap[e.key];
|
||||
@@ -63,4 +110,95 @@
|
||||
action();
|
||||
}
|
||||
});
|
||||
|
||||
// --- Textarea key handling ---
|
||||
|
||||
textarea.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Delete' && e.shiftKey) {
|
||||
var result = T.deleteRow(textarea.value, textarea.selectionStart)
|
||||
|| L.deleteOrderedLine(textarea.value, textarea.selectionStart);
|
||||
if (!result) return;
|
||||
e.preventDefault();
|
||||
applyResult(result);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && e.shiftKey) {
|
||||
var result = T.insertRowBelow(textarea.value, textarea.selectionStart);
|
||||
if (!result) return;
|
||||
e.preventDefault();
|
||||
applyResult(result);
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Enter') return;
|
||||
var result = L.handleEnterKey(textarea.value, textarea.selectionStart);
|
||||
if (!result) return;
|
||||
e.preventDefault();
|
||||
applyResult(result);
|
||||
});
|
||||
|
||||
// --- Dropdown helper ---
|
||||
|
||||
var openMenus = [];
|
||||
|
||||
function makeDropdown(triggerBtn, items) {
|
||||
var menu = document.createElement('div');
|
||||
menu.className = 'toolbar-dropdown-menu';
|
||||
items.forEach(function (item) {
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-tool toolbar-dropdown-item';
|
||||
btn.textContent = item.label;
|
||||
btn.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault();
|
||||
actions[item.action]();
|
||||
menu.classList.remove('is-open');
|
||||
});
|
||||
menu.appendChild(btn);
|
||||
});
|
||||
triggerBtn.appendChild(menu);
|
||||
openMenus.push(menu);
|
||||
|
||||
triggerBtn.addEventListener('click', function (e) {
|
||||
if (e.target !== triggerBtn) return;
|
||||
var wasOpen = menu.classList.contains('is-open');
|
||||
openMenus.forEach(function (m) { m.classList.remove('is-open'); });
|
||||
if (!wasOpen) menu.classList.add('is-open');
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var insideAny = openMenus.some(function (m) {
|
||||
return m.parentElement && m.parentElement.contains(e.target);
|
||||
});
|
||||
if (!insideAny) openMenus.forEach(function (m) { m.classList.remove('is-open'); });
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') openMenus.forEach(function (m) { m.classList.remove('is-open'); });
|
||||
});
|
||||
|
||||
// --- Table dropdown ---
|
||||
|
||||
var tblDropBtn = document.querySelector('[data-action="tbldrop"]');
|
||||
if (tblDropBtn) {
|
||||
makeDropdown(tblDropBtn, [
|
||||
{ label: 'Format table', action: 'fmttable' },
|
||||
{ label: 'Align left', action: 'tblalignleft' },
|
||||
{ label: 'Align center', action: 'tblaligncenter' },
|
||||
{ label: 'Align right', action: 'tblalignright' },
|
||||
{ label: 'Insert column', action: 'tblinsertcol' },
|
||||
{ label: 'Delete column', action: 'tbldeletecol' },
|
||||
{ label: 'Insert row', action: 'tblinsertrow' },
|
||||
{ label: 'Delete row', action: 'tbldeleterow' },
|
||||
]);
|
||||
}
|
||||
|
||||
// --- Date dropdown ---
|
||||
|
||||
var dateDropBtn = document.querySelector('[data-action="datedrop"]');
|
||||
if (dateDropBtn) {
|
||||
makeDropdown(dateDropBtn, [
|
||||
{ label: 'YYYY-MM-DD', action: 'dateiso' },
|
||||
{ label: 'DE Long', action: 'datelong' },
|
||||
]);
|
||||
}
|
||||
})();
|
||||
|
||||
17
assets/editor/dates.js
Normal file
@@ -0,0 +1,17 @@
|
||||
window.EditorDates = (function () {
|
||||
|
||||
function pad2(n) { return n < 10 ? '0' + n : '' + n; }
|
||||
|
||||
function isoDate() {
|
||||
var d = new Date();
|
||||
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
|
||||
}
|
||||
|
||||
function longDate() {
|
||||
return new Date().toLocaleDateString('de-DE', {
|
||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
return { isoDate: isoDate, longDate: longDate };
|
||||
})();
|
||||
86
assets/editor/lists.js
Normal file
@@ -0,0 +1,86 @@
|
||||
window.EditorLists = (function () {
|
||||
|
||||
function detectListPrefix(lineText) {
|
||||
var m;
|
||||
m = lineText.match(/^(\s*)(- \[[ x]\] )/);
|
||||
if (m) return { indent: m[1], prefix: m[2], type: 'task' };
|
||||
m = lineText.match(/^(\s*)([-*+] )/);
|
||||
if (m) return { indent: m[1], prefix: m[2], type: 'unordered' };
|
||||
m = lineText.match(/^(\s*)(\d+)\. /);
|
||||
if (m) return { indent: m[1], prefix: m[2] + '. ', type: 'ordered', num: parseInt(m[2], 10) };
|
||||
m = lineText.match(/^(\s*)(> )/);
|
||||
if (m) return { indent: m[1], prefix: m[2], type: 'blockquote' };
|
||||
return null;
|
||||
}
|
||||
|
||||
function continuationPrefix(info) {
|
||||
if (info.type === 'task') return info.indent + '- [ ] ';
|
||||
if (info.type === 'ordered') return info.indent + (info.num + 1) + '. ';
|
||||
return info.indent + info.prefix;
|
||||
}
|
||||
|
||||
function renumberOrderedList(text, fromLineIndex, indent, startNum) {
|
||||
var lines = text.split('\n');
|
||||
var re = new RegExp('^' + indent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(\\d+)\\. ');
|
||||
var num = startNum !== undefined ? startNum : null;
|
||||
for (var i = fromLineIndex; i < lines.length; i++) {
|
||||
var m = lines[i].match(re);
|
||||
if (!m) break;
|
||||
if (num === null) num = parseInt(m[1], 10);
|
||||
var newNumStr = String(num);
|
||||
if (m[1] !== newNumStr) {
|
||||
lines[i] = indent + newNumStr + '. ' + lines[i].slice(m[0].length);
|
||||
}
|
||||
num++;
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function handleEnterKey(text, cursorPos) {
|
||||
var before = text.slice(0, cursorPos);
|
||||
var after = text.slice(cursorPos);
|
||||
var lineStart = before.lastIndexOf('\n') + 1;
|
||||
var lineEnd = text.indexOf('\n', cursorPos);
|
||||
if (lineEnd === -1) lineEnd = text.length;
|
||||
var fullLine = text.slice(lineStart, lineEnd);
|
||||
var info = detectListPrefix(fullLine);
|
||||
if (!info) return null;
|
||||
|
||||
var contentAfterPrefix = fullLine.slice(info.indent.length + info.prefix.length);
|
||||
if (contentAfterPrefix.trim() === '') {
|
||||
var newText = text.slice(0, lineStart) + '\n' + after;
|
||||
var newCursor = lineStart + 1;
|
||||
if (info.type === 'ordered') {
|
||||
var lineIndex = text.slice(0, lineStart).split('\n').length;
|
||||
newText = renumberOrderedList(newText, lineIndex, info.indent);
|
||||
}
|
||||
return { text: newText, cursor: newCursor };
|
||||
}
|
||||
|
||||
var cont = continuationPrefix(info);
|
||||
var newText = before + '\n' + cont + after;
|
||||
var newCursor = cursorPos + 1 + cont.length;
|
||||
if (info.type === 'ordered') {
|
||||
var insertedLineIndex = before.split('\n').length;
|
||||
newText = renumberOrderedList(newText, insertedLineIndex, info.indent);
|
||||
}
|
||||
return { text: newText, cursor: newCursor };
|
||||
}
|
||||
|
||||
function deleteOrderedLine(text, cursorPos) {
|
||||
var lineStart = text.lastIndexOf('\n', cursorPos - 1) + 1;
|
||||
var lineEnd = text.indexOf('\n', cursorPos);
|
||||
if (lineEnd === -1) lineEnd = text.length;
|
||||
var fullLine = text.slice(lineStart, lineEnd);
|
||||
var info = detectListPrefix(fullLine);
|
||||
if (!info || info.type !== 'ordered') return null;
|
||||
|
||||
var newText = text.slice(0, lineStart) + text.slice(lineEnd === text.length ? lineEnd : lineEnd + 1);
|
||||
var newCursor = lineStart;
|
||||
var fromLineIndex = text.slice(0, lineStart).split('\n').length - 1;
|
||||
newText = renumberOrderedList(newText, fromLineIndex, info.indent, info.num);
|
||||
return { text: newText, cursor: Math.min(newCursor, newText.length) };
|
||||
}
|
||||
|
||||
return { handleEnterKey: handleEnterKey, deleteOrderedLine: deleteOrderedLine };
|
||||
})();
|
||||
274
assets/editor/tables.js
Normal file
@@ -0,0 +1,274 @@
|
||||
window.EditorTables = (function () {
|
||||
|
||||
function repeat(ch, n) {
|
||||
var s = '';
|
||||
for (var i = 0; i < n; i++) s += ch;
|
||||
return s;
|
||||
}
|
||||
|
||||
function padLeft(s, w) {
|
||||
while (s.length < w) s = ' ' + s;
|
||||
return s;
|
||||
}
|
||||
|
||||
function padRight(s, w) {
|
||||
while (s.length < w) s += ' ';
|
||||
return s;
|
||||
}
|
||||
|
||||
function padCenter(s, w) {
|
||||
while (s.length < w) {
|
||||
s = s + ' ';
|
||||
if (s.length < w) s = ' ' + s;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function parseTableRow(line) {
|
||||
var trimmed = line.trim();
|
||||
if (trimmed.charAt(0) === '|') trimmed = trimmed.slice(1);
|
||||
if (trimmed.charAt(trimmed.length - 1) === '|') trimmed = trimmed.slice(0, -1);
|
||||
return trimmed.split('|').map(function (c) { return c.trim(); });
|
||||
}
|
||||
|
||||
function isSeparatorRow(cells) {
|
||||
return cells.every(function (c) { return /^:?-+:?$/.test(c); });
|
||||
}
|
||||
|
||||
function parseAlignment(cell) {
|
||||
var left = cell.charAt(0) === ':';
|
||||
var right = cell.charAt(cell.length - 1) === ':';
|
||||
if (left && right) return 'center';
|
||||
if (left) return 'left';
|
||||
if (right) return 'right';
|
||||
return null;
|
||||
}
|
||||
|
||||
function makeSepCell(width, align) {
|
||||
if (align === 'left') return ':' + repeat('-', width - 1);
|
||||
if (align === 'center') return ':' + repeat('-', width - 2) + ':';
|
||||
if (align === 'right') return repeat('-', width - 1) + ':';
|
||||
return repeat('-', width);
|
||||
}
|
||||
|
||||
function findTableRange(text, cursorPos) {
|
||||
var lines = text.split('\n');
|
||||
var charCount = 0;
|
||||
var cursorLine = 0;
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (charCount + lines[i].length >= cursorPos && charCount <= cursorPos) {
|
||||
cursorLine = i;
|
||||
}
|
||||
charCount += lines[i].length + 1;
|
||||
}
|
||||
if (!/^\|.*\|$/.test(lines[cursorLine].trim())) return null;
|
||||
var start = cursorLine;
|
||||
while (start > 0 && /^\|.*\|$/.test(lines[start - 1].trim())) start--;
|
||||
var end = cursorLine;
|
||||
while (end < lines.length - 1 && /^\|.*\|$/.test(lines[end + 1].trim())) end++;
|
||||
if (end - start < 1) return null;
|
||||
return { start: start, end: end, lines: lines, cursorLine: cursorLine };
|
||||
}
|
||||
|
||||
function formatTableText(text, cursorPos) {
|
||||
var range = findTableRange(text, cursorPos);
|
||||
if (!range) return null;
|
||||
|
||||
var rows = [];
|
||||
var sepIndex = -1;
|
||||
var alignments = [];
|
||||
for (var i = range.start; i <= range.end; i++) {
|
||||
var cells = parseTableRow(range.lines[i]);
|
||||
if (sepIndex === -1 && isSeparatorRow(cells)) {
|
||||
sepIndex = rows.length;
|
||||
alignments = cells.map(function (c) { return parseAlignment(c); });
|
||||
}
|
||||
rows.push(cells);
|
||||
}
|
||||
if (sepIndex === -1) return null;
|
||||
|
||||
var colCount = 0;
|
||||
rows.forEach(function (r) { if (r.length > colCount) colCount = r.length; });
|
||||
while (alignments.length < colCount) alignments.push(null);
|
||||
|
||||
rows = rows.map(function (r) {
|
||||
while (r.length < colCount) r.push('');
|
||||
return r;
|
||||
});
|
||||
|
||||
var widths = [];
|
||||
for (var c = 0; c < colCount; c++) {
|
||||
var max = 3;
|
||||
for (var r = 0; r < rows.length; r++) {
|
||||
if (r === sepIndex) continue;
|
||||
if (rows[r][c].length > max) max = rows[r][c].length;
|
||||
}
|
||||
widths.push(max);
|
||||
}
|
||||
|
||||
var formatted = rows.map(function (row, ri) {
|
||||
var cells = row.map(function (cell, ci) {
|
||||
var w = widths[ci];
|
||||
if (ri === sepIndex) return makeSepCell(w, alignments[ci]);
|
||||
var align = alignments[ci];
|
||||
if (align === 'right') return padLeft(cell, w);
|
||||
if (align === 'center') return padCenter(cell, w);
|
||||
return padRight(cell, w);
|
||||
});
|
||||
return '| ' + cells.join(' | ') + ' |';
|
||||
});
|
||||
|
||||
var beforeTable = range.lines.slice(0, range.start).join('\n');
|
||||
var afterTable = range.lines.slice(range.end + 1).join('\n');
|
||||
var parts = [];
|
||||
if (beforeTable) parts.push(beforeTable);
|
||||
parts.push(formatted.join('\n'));
|
||||
if (afterTable) parts.push(afterTable);
|
||||
var newText = parts.join('\n');
|
||||
|
||||
var oldBeforeLen = 0;
|
||||
for (var i = 0; i < range.start; i++) oldBeforeLen += range.lines[i].length + 1;
|
||||
var cursorInTable = cursorPos - oldBeforeLen;
|
||||
var newTableText = formatted.join('\n');
|
||||
var newCursor = (beforeTable ? beforeTable.length + 1 : 0) + Math.min(cursorInTable, newTableText.length);
|
||||
|
||||
return { text: newText, cursor: newCursor };
|
||||
}
|
||||
|
||||
function getCursorColumn(text, cursorPos) {
|
||||
var range = findTableRange(text, cursorPos);
|
||||
if (!range) return null;
|
||||
var lines = text.split('\n');
|
||||
var charCount = 0;
|
||||
for (var i = 0; i < range.cursorLine; i++) charCount += lines[i].length + 1;
|
||||
var lineOffset = cursorPos - charCount;
|
||||
var line = lines[range.cursorLine];
|
||||
var col = -1;
|
||||
for (var c = 0; c < line.length; c++) {
|
||||
if (line.charAt(c) === '|') {
|
||||
col++;
|
||||
if (c >= lineOffset) return Math.max(col - 1, 0);
|
||||
}
|
||||
}
|
||||
return Math.max(col, 0);
|
||||
}
|
||||
|
||||
function setColumnAlignment(text, cursorPos, align) {
|
||||
var range = findTableRange(text, cursorPos);
|
||||
if (!range) return null;
|
||||
var colIdx = getCursorColumn(text, cursorPos);
|
||||
if (colIdx === null) return null;
|
||||
|
||||
var rows = [];
|
||||
var sepIndex = -1;
|
||||
for (var i = range.start; i <= range.end; i++) {
|
||||
var cells = parseTableRow(range.lines[i]);
|
||||
if (sepIndex === -1 && isSeparatorRow(cells)) sepIndex = rows.length;
|
||||
rows.push(cells);
|
||||
}
|
||||
if (sepIndex === -1 || colIdx >= rows[sepIndex].length) return null;
|
||||
|
||||
var cell = rows[sepIndex][colIdx].replace(/:/g, '-');
|
||||
if (align === 'left') cell = ':' + cell.slice(1);
|
||||
else if (align === 'center') cell = ':' + cell.slice(1, -1) + ':';
|
||||
else if (align === 'right') cell = cell.slice(0, -1) + ':';
|
||||
rows[sepIndex][colIdx] = cell;
|
||||
|
||||
var newLines = range.lines.slice();
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
newLines[range.start + i] = '| ' + rows[i].join(' | ') + ' |';
|
||||
}
|
||||
return formatTableText(newLines.join('\n'), cursorPos);
|
||||
}
|
||||
|
||||
function insertColumn(text, cursorPos) {
|
||||
var range = findTableRange(text, cursorPos);
|
||||
if (!range) return null;
|
||||
var colIdx = getCursorColumn(text, cursorPos);
|
||||
if (colIdx === null) return null;
|
||||
|
||||
var newLines = range.lines.slice();
|
||||
var sepIndex = -1;
|
||||
for (var i = range.start; i <= range.end; i++) {
|
||||
var cells = parseTableRow(range.lines[i]);
|
||||
var isSep = sepIndex === -1 && isSeparatorRow(cells);
|
||||
if (isSep) sepIndex = i;
|
||||
cells.splice(colIdx, 0, isSep ? '---' : '');
|
||||
newLines[i] = '| ' + cells.join(' | ') + ' |';
|
||||
}
|
||||
return formatTableText(newLines.join('\n'), cursorPos);
|
||||
}
|
||||
|
||||
function deleteColumn(text, cursorPos) {
|
||||
var range = findTableRange(text, cursorPos);
|
||||
if (!range) return null;
|
||||
var colIdx = getCursorColumn(text, cursorPos);
|
||||
if (colIdx === null) return null;
|
||||
|
||||
var newLines = range.lines.slice();
|
||||
for (var i = range.start; i <= range.end; i++) {
|
||||
var cells = parseTableRow(range.lines[i]);
|
||||
if (cells.length <= 1) return null;
|
||||
cells.splice(colIdx, 1);
|
||||
newLines[i] = '| ' + cells.join(' | ') + ' |';
|
||||
}
|
||||
return formatTableText(newLines.join('\n'), cursorPos);
|
||||
}
|
||||
|
||||
function insertRow(text, cursorPos) {
|
||||
var range = findTableRange(text, cursorPos);
|
||||
if (!range) return null;
|
||||
var colCount = 0;
|
||||
for (var i = range.start; i <= range.end; i++) {
|
||||
var cells = parseTableRow(range.lines[i]);
|
||||
if (cells.length > colCount) colCount = cells.length;
|
||||
}
|
||||
var emptyCells = [];
|
||||
for (var c = 0; c < colCount; c++) emptyCells.push('');
|
||||
var newLines = range.lines.slice();
|
||||
newLines.splice(range.cursorLine + 1, 0, '| ' + emptyCells.join(' | ') + ' |');
|
||||
return formatTableText(newLines.join('\n'), cursorPos);
|
||||
}
|
||||
|
||||
function insertRowBelow(text, cursorPos) {
|
||||
var range = findTableRange(text, cursorPos);
|
||||
if (!range) return null;
|
||||
var result = insertRow(text, cursorPos);
|
||||
if (!result) return null;
|
||||
var lines = result.text.split('\n');
|
||||
var cursor = 0;
|
||||
for (var i = 0; i <= range.cursorLine; i++) cursor += lines[i].length + 1;
|
||||
cursor += 2; // skip leading '| '
|
||||
return { text: result.text, cursor: cursor };
|
||||
}
|
||||
|
||||
function deleteRow(text, cursorPos) {
|
||||
var range = findTableRange(text, cursorPos);
|
||||
if (!range) return null;
|
||||
var sepIndex = -1;
|
||||
for (var i = range.start; i <= range.end; i++) {
|
||||
if (isSeparatorRow(parseTableRow(range.lines[i]))) { sepIndex = i; break; }
|
||||
}
|
||||
if (range.cursorLine === range.start || range.cursorLine === sepIndex) return null;
|
||||
|
||||
var newLines = range.lines.slice();
|
||||
newLines.splice(range.cursorLine, 1);
|
||||
var newCursor = cursorPos;
|
||||
if (range.cursorLine < newLines.length) {
|
||||
var charCount = 0;
|
||||
for (var i = 0; i < range.cursorLine; i++) charCount += newLines[i].length + 1;
|
||||
newCursor = charCount;
|
||||
}
|
||||
return formatTableText(newLines.join('\n'), Math.min(newCursor, newLines.join('\n').length));
|
||||
}
|
||||
|
||||
return {
|
||||
formatTableText: formatTableText,
|
||||
setColumnAlignment: setColumnAlignment,
|
||||
insertColumn: insertColumn,
|
||||
deleteColumn: deleteColumn,
|
||||
insertRow: insertRow,
|
||||
insertRowBelow: insertRowBelow,
|
||||
deleteRow: deleteRow,
|
||||
};
|
||||
})();
|
||||
BIN
assets/fonts/IosevkaEtoile.woff2
Normal file
BIN
assets/fonts/IosevkaSlab.woff2
Normal file
@@ -1,3 +1,10 @@
|
||||
function newPage() {
|
||||
const name = prompt('New page name:');
|
||||
if (!name || !name.trim()) return;
|
||||
const slug = name.trim().replace(/\s+/g, '-');
|
||||
window.location.href = window.location.pathname + slug + '/?edit';
|
||||
}
|
||||
|
||||
(function () {
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (!e.altKey || !e.shiftKey) return;
|
||||
@@ -6,6 +13,10 @@
|
||||
e.preventDefault();
|
||||
window.location.href = window.location.pathname + '?edit';
|
||||
break;
|
||||
case 'N':
|
||||
e.preventDefault();
|
||||
newPage();
|
||||
break;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
7
assets/icons/archive.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
|
||||
<rect x="1" y="1" width="14" height="4"/>
|
||||
<path d="M1 5v10h14V5M7 3h2"/>
|
||||
<rect x="7" y="6" width="2" height="2" fill="currentColor" stroke="none"/>
|
||||
<rect x="7" y="9" width="2" height="1" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 435 B |
7
assets/icons/audio.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="currentColor" stroke="none" shape-rendering="crispEdges">
|
||||
<path d="M2 6h3l4-3v10l-4-3H2z"/>
|
||||
<rect x="11" y="5" width="2" height="1"/>
|
||||
<rect x="11" y="7" width="3" height="1"/>
|
||||
<rect x="11" y="9" width="2" height="1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 329 B |
4
assets/icons/doc.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
|
||||
<path d="M3 1h7l4 4v10H3zM10 1v4h4M5 8h6M5 11h4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 257 B |
4
assets/icons/folder.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
|
||||
<path d="M1 6h14v8H1zm0 0V4h5l1 2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 243 B |
4
assets/icons/generic.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
|
||||
<path d="M3 1h7l4 4v10H3zM10 1v4h4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 244 B |
6
assets/icons/image.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
|
||||
<rect x="1" y="2" width="14" height="12"/>
|
||||
<path d="M1 11l4-4 3 3 2-2 5 5"/>
|
||||
<rect x="10" y="4" width="2" height="2" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 363 B |
5
assets/icons/video.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
|
||||
<rect x="1" y="3" width="14" height="10"/>
|
||||
<path d="M6 6v4l5-2z" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 309 B |
@@ -11,42 +11,62 @@
|
||||
<body>
|
||||
<header>
|
||||
<nav class="breadcrumb">
|
||||
<a href="/">~</a>
|
||||
<a href="/"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg></a>
|
||||
{{range .Crumbs}}<span class="sep">/</span
|
||||
><a href="{{.URL}}">{{.Name}}</a>{{end}}
|
||||
</nav>
|
||||
{{if .CanEdit}}<a class="edit-btn" href="?edit" title="Edit (E)">EDIT</a>{{end}}
|
||||
{{if .EditMode}}
|
||||
<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>
|
||||
{{else if .CanEdit}}
|
||||
<button class="btn" onclick="newPage()" title="New page (N)">NEW</button>
|
||||
<a class="btn" href="?edit" title="Edit page (E)">EDIT</a>
|
||||
{{end}}
|
||||
</header>
|
||||
<main>
|
||||
{{if .EditMode}}
|
||||
<form class="edit-form" method="POST" action="{{.PostURL}}">
|
||||
<form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}">
|
||||
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
|
||||
<div class="editor-toolbar">
|
||||
<button type="button" class="btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
|
||||
<button type="button" class="btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
|
||||
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
|
||||
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn-tool" data-action="h1" data-key="1" title="Heading 1 (1)">#</button>
|
||||
<button type="button" class="btn-tool" data-action="h2" data-key="2" title="Heading 2 (2)">##</button>
|
||||
<button type="button" class="btn-tool" data-action="h3" data-key="3" title="Heading 3 (3)">###</button>
|
||||
<button type="button" class="btn btn-tool" data-action="h1" data-key="1" title="Heading 1 (1)">#</button>
|
||||
<button type="button" class="btn btn-tool" data-action="h2" data-key="2" title="Heading 2 (2)">##</button>
|
||||
<button type="button" class="btn btn-tool" data-action="h3" data-key="3" title="Heading 3 (3)">###</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
|
||||
<button type="button" class="btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
|
||||
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
|
||||
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn-tool" data-action="link" data-key="L" title="Link (L)">[]</button>
|
||||
<button type="button" class="btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">></button>
|
||||
<button type="button" class="btn-tool" data-action="ul" data-key="U" title="Unordered list (U)">-</button>
|
||||
<button type="button" class="btn-tool" data-action="ol" data-key="O" title="Ordered list (O)">1.</button>
|
||||
<button type="button" class="btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
|
||||
<button type="button" class="btn btn-tool" data-action="link" data-key="L" title="Link (L)">[]</button>
|
||||
<button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">></button>
|
||||
<button type="button" class="btn btn-tool" data-action="ul" data-key="U" title="Unordered list (U)">-</button>
|
||||
<button type="button" class="btn btn-tool" data-action="ol" data-key="O" title="Ordered list (O)">1.</button>
|
||||
<button type="button" class="btn btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool toolbar-dropdown" data-action="tbldrop" title="Table (T)">T▾</button>
|
||||
<button type="button" class="btn btn-tool toolbar-dropdown" data-action="datedrop" title="Insert date (D/W)">D▾</button>
|
||||
</div>
|
||||
<textarea name="content" id="editor" autofocus>{{.RawContent}}</textarea>
|
||||
<div class="form-actions">
|
||||
<a class="btn-cancel" href="{{.PostURL}}">CANCEL</a>
|
||||
<button class="btn-save" type="submit" data-action="save" data-key="S" title="Save (S)">SAVE</button>
|
||||
</div>
|
||||
</form>
|
||||
<script src="/_/editor/lists.js"></script>
|
||||
<script src="/_/editor/tables.js"></script>
|
||||
<script src="/_/editor/dates.js"></script>
|
||||
<script src="/_/editor.js"></script>
|
||||
{{else}} {{if .Content}}
|
||||
{{else}}
|
||||
{{if .Content}}
|
||||
<div class="content">{{.Content}}</div>
|
||||
{{end}} {{if .Entries}}
|
||||
{{end}}
|
||||
{{if .SpecialContent}}
|
||||
<div class="diary">{{.SpecialContent}}</div>
|
||||
{{end}}
|
||||
{{if or .Content .SpecialContent}}
|
||||
<script src="/_/content.js"></script>
|
||||
{{end}}
|
||||
{{if .Content}}
|
||||
<script src="/_/sections.js"></script>
|
||||
{{end}}
|
||||
{{if .Entries}}
|
||||
<div class="listing">
|
||||
<div class="listing-header">Contents</div>
|
||||
{{range .Entries}}
|
||||
@@ -58,8 +78,11 @@
|
||||
{{end}}
|
||||
</div>
|
||||
{{else if not .Content}}
|
||||
{{if not .SpecialContent}}
|
||||
<p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p>
|
||||
{{end}} {{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
16
assets/sections.js
Normal file
@@ -0,0 +1,16 @@
|
||||
(function () {
|
||||
var content = document.querySelector('.content');
|
||||
if (!content) return;
|
||||
var headings = content.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
if (!headings.length) return;
|
||||
|
||||
// Section 0 is pre-heading content, editable via full-page edit.
|
||||
// Sections 1..N each start at a heading; that is the index sent to the server.
|
||||
headings.forEach(function (h, i) {
|
||||
var a = document.createElement('a');
|
||||
a.href = '?edit§ion=' + (i + 1);
|
||||
a.className = 'btn btn-small section-edit';
|
||||
a.textContent = 'edit';
|
||||
h.appendChild(a);
|
||||
});
|
||||
}());
|
||||
313
assets/style.css
@@ -1,37 +1,38 @@
|
||||
/* === CRT Base === */
|
||||
/* === Fonts === */
|
||||
@font-face {
|
||||
font-family: "Iosevka Etoile";
|
||||
src: url("/_/fonts/IosevkaEtoile.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Iosevka Slab";
|
||||
src: url("/_/fonts/IosevkaSlab.woff2") format("woff2");
|
||||
}
|
||||
|
||||
/* === Theme === */
|
||||
:root {
|
||||
--bg: #2e2e2e;
|
||||
--bg-panel: #434343;
|
||||
--bg-panel-hover: #585858;
|
||||
--text: #e6e6e6;
|
||||
--text-muted: #cfcfcf;
|
||||
--primary: #87458a;
|
||||
--primary-hover: #d64d95;
|
||||
--secondary: #c48401;
|
||||
--link: #01b6c4;
|
||||
--link-hover: #d6d24d;
|
||||
}
|
||||
|
||||
/* === Base === */
|
||||
body {
|
||||
background-color: black;
|
||||
background-image: radial-gradient(rgba(0, 150, 0, 0.75), black 120%);
|
||||
background-attachment: fixed;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--bg);
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
color: white;
|
||||
color: var(--text);
|
||||
font:
|
||||
1rem Inconsolata,
|
||||
1rem "Iosevka Etoile",
|
||||
monospace;
|
||||
text-shadow: 0 0 5px #c8c8c8;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0.15),
|
||||
rgba(0, 0, 0, 0.15) 1px,
|
||||
transparent 1px,
|
||||
transparent 2px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -40,26 +41,26 @@ body::after {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: #0080ff;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* === Links === */
|
||||
a {
|
||||
color: #ffb300;
|
||||
text-shadow: 0 0 5px #b37800;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
color: #ffd54f;
|
||||
text-shadow: 0 0 8px #ffb300;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.content a {
|
||||
color: var(--link);
|
||||
}
|
||||
.content a:hover {
|
||||
color: var(--link-hover);
|
||||
}
|
||||
|
||||
/* === Header === */
|
||||
header {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px dashed #0a0;
|
||||
border-bottom: 1px dashed var(--secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
@@ -74,29 +75,51 @@ header {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sep {
|
||||
color: #060;
|
||||
.logo {
|
||||
width: 1.1em;
|
||||
height: 1.1em;
|
||||
vertical-align: center;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
.sep {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ffb300;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.edit-btn::before {
|
||||
.btn::before {
|
||||
content: "[";
|
||||
color: var(--secondary);
|
||||
}
|
||||
.edit-btn::after {
|
||||
.btn::after {
|
||||
content: "]";
|
||||
color: var(--secondary);
|
||||
}
|
||||
.edit-btn:hover {
|
||||
color: #ffd54f;
|
||||
text-shadow: 0 0 8px #ffb300;
|
||||
.btn:hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* === Button modifiers === */
|
||||
/* For inline buttons */
|
||||
.btn-small {
|
||||
font-size: 0.65rem;
|
||||
font-weight: normal;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* For toolbars */
|
||||
.btn-tool {
|
||||
font-size: 0.85rem;
|
||||
padding: 0 0.15rem;
|
||||
}
|
||||
|
||||
/* === Main === */
|
||||
@@ -117,13 +140,13 @@ main {
|
||||
.content h4,
|
||||
.content h5,
|
||||
.content h6 {
|
||||
color: white;
|
||||
color: var(--text);
|
||||
margin: 1.25rem 0 0.5rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.content h1 {
|
||||
font-size: 1.75rem;
|
||||
border-bottom: 1px dashed #0a0;
|
||||
border-bottom: 1px dashed var(--secondary);
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
.content h2 {
|
||||
@@ -143,20 +166,20 @@ main {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.content blockquote {
|
||||
border-left: 3px solid #0a0;
|
||||
border-left: 3px solid var(--secondary);
|
||||
padding: 0.25rem 1rem;
|
||||
color: #888;
|
||||
color: var(--text-muted);
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.content code {
|
||||
font-family: Inconsolata, monospace;
|
||||
font-family: "Iosevka Etoile", monospace;
|
||||
font-size: 0.875em;
|
||||
background: #001a00;
|
||||
background: var(--bg-panel);
|
||||
padding: 0.1em 0.35em;
|
||||
}
|
||||
.content pre {
|
||||
background: #001a00;
|
||||
border: 1px solid #0a0;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--secondary);
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
margin: 0.75rem 0;
|
||||
@@ -173,17 +196,17 @@ main {
|
||||
}
|
||||
.content th,
|
||||
.content td {
|
||||
border: 1px solid #0a0;
|
||||
border: 1px solid var(--secondary);
|
||||
padding: 0.4rem 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
.content th {
|
||||
background: #001a00;
|
||||
color: white;
|
||||
background: var(--bg-panel);
|
||||
color: var(--text);
|
||||
}
|
||||
.content hr {
|
||||
border: none;
|
||||
border-top: 1px dashed #0a0;
|
||||
border-top: 1px dashed var(--secondary);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
.content img {
|
||||
@@ -192,45 +215,43 @@ main {
|
||||
|
||||
/* === File listing === */
|
||||
.listing {
|
||||
border: 1px solid #0a0;
|
||||
border: 1px solid var(--secondary);
|
||||
}
|
||||
.listing-header {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: white;
|
||||
border-bottom: 1px solid #0a0;
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid var(--secondary);
|
||||
}
|
||||
.listing-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 1rem;
|
||||
border-top: 1px solid #060;
|
||||
border-top: 1px solid var(--secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.listing-item:hover {
|
||||
background: #001a00;
|
||||
background: var(--bg-panel-hover);
|
||||
}
|
||||
.listing-item .icon {
|
||||
width: 1.25rem;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
text-shadow: none;
|
||||
}
|
||||
.listing-item a {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #ffb300;
|
||||
color: inherit;
|
||||
}
|
||||
.listing-item .meta {
|
||||
color: #888;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* === Editor toolbar === */
|
||||
@@ -238,43 +259,48 @@ main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid #0a0;
|
||||
border: 1px solid var(--secondary);
|
||||
border-bottom: none;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: #001a00;
|
||||
}
|
||||
|
||||
.btn-tool {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #0f0;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
padding: 0 0.15rem;
|
||||
text-shadow: 0 0 4px #0a0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-tool::before {
|
||||
content: "[";
|
||||
color: #060;
|
||||
}
|
||||
.btn-tool::after {
|
||||
content: "]";
|
||||
color: #060;
|
||||
}
|
||||
.btn-tool:hover {
|
||||
color: #fff;
|
||||
text-shadow: 0 0 6px #0f0;
|
||||
background: var(--bg-panel-hover);
|
||||
}
|
||||
|
||||
.toolbar-sep {
|
||||
width: 1px;
|
||||
background: #060;
|
||||
background: var(--secondary);
|
||||
margin: 0 0.2rem;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
/* === Toolbar dropdowns === */
|
||||
.toolbar-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toolbar-dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
display: none;
|
||||
z-index: 100;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--secondary);
|
||||
min-width: 9rem;
|
||||
}
|
||||
|
||||
.toolbar-dropdown-menu.is-open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.toolbar-dropdown-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
|
||||
/* === Edit form === */
|
||||
.edit-form {
|
||||
display: flex;
|
||||
@@ -284,13 +310,11 @@ main {
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 60vh;
|
||||
background: #000;
|
||||
border: 1px solid #0a0;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--secondary);
|
||||
border-top: none;
|
||||
color: #0f0;
|
||||
caret-color: #0f0;
|
||||
text-shadow: 0 0 4px #0a0;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
font-family: "Iosevka Slab", monospace;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
padding: 1rem;
|
||||
@@ -298,55 +322,58 @@ textarea {
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
textarea:focus {
|
||||
border-color: #0f0;
|
||||
box-shadow: 0 0 5px #0a0;
|
||||
|
||||
/* === Diary views === */
|
||||
.diary-section {
|
||||
margin: 2rem 0;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px dashed var(--secondary);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
.diary-section:first-child {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ffb300;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.btn-save::before {
|
||||
content: "[";
|
||||
}
|
||||
.btn-save::after {
|
||||
content: "]";
|
||||
}
|
||||
.btn-save:hover {
|
||||
color: #ffd54f;
|
||||
text-shadow: 0 0 8px #ffb300;
|
||||
.diary-section h2 {
|
||||
font-size: 1.2rem;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ffb300;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
.diary-photo-count {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-cancel::before {
|
||||
content: "[";
|
||||
|
||||
.diary-photo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.btn-cancel::after {
|
||||
content: "]";
|
||||
|
||||
.diary-photo-grid a {
|
||||
display: block;
|
||||
line-height: 0;
|
||||
}
|
||||
.btn-cancel:hover {
|
||||
color: #ffb300;
|
||||
text-shadow: 0 0 8px #b37800;
|
||||
|
||||
.diary-photo-grid img {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.diary-section .content {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* === Section edit links === */
|
||||
.section-edit {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
/* === Empty state === */
|
||||
@@ -358,7 +385,7 @@ textarea:focus {
|
||||
/* === hr === */
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px dashed #0a0;
|
||||
border-top: 1px dashed var(--secondary);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
@@ -370,10 +397,10 @@ hr {
|
||||
background: #111;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #0a0;
|
||||
background: var(--primary);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #0f0;
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* === Responsive === */
|
||||
|
||||
337
diary.go
Normal file
@@ -0,0 +1,337 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
pageTypeHandlers = append(pageTypeHandlers, &diaryHandler{})
|
||||
}
|
||||
|
||||
type diaryHandler struct{}
|
||||
|
||||
func (d *diaryHandler) handle(root, fsPath, urlPath string) *specialPage {
|
||||
depth, ok := findDiaryContext(root, fsPath)
|
||||
if !ok || depth == 0 {
|
||||
return nil
|
||||
}
|
||||
var content template.HTML
|
||||
switch depth {
|
||||
case 1:
|
||||
content = renderDiaryYear(fsPath, urlPath)
|
||||
case 2:
|
||||
content = renderDiaryMonth(fsPath, urlPath)
|
||||
case 3:
|
||||
content = renderDiaryDay(fsPath, urlPath)
|
||||
}
|
||||
return &specialPage{Content: content, SuppressListing: true}
|
||||
}
|
||||
|
||||
// findDiaryContext walks up from fsPath toward root looking for a
|
||||
// .page-settings file with type=diary. Returns the depth of fsPath
|
||||
// relative to the diary root, and whether one was found.
|
||||
// depth=0 means fsPath itself is the diary root.
|
||||
func findDiaryContext(root, fsPath string) (int, bool) {
|
||||
current := fsPath
|
||||
for depth := 0; ; depth++ {
|
||||
s := readPageSettings(current)
|
||||
if s != nil && s.Type == "diary" {
|
||||
return depth, true
|
||||
}
|
||||
if current == root {
|
||||
break
|
||||
}
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
break
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
|
||||
type diaryPhoto struct {
|
||||
Date time.Time
|
||||
Name string
|
||||
URL string
|
||||
}
|
||||
|
||||
type diaryMonthSummary struct {
|
||||
Name string
|
||||
URL string
|
||||
PhotoCount int
|
||||
}
|
||||
|
||||
type diaryDaySection struct {
|
||||
Heading string
|
||||
URL string
|
||||
EditURL string
|
||||
Content template.HTML
|
||||
Photos []diaryPhoto
|
||||
}
|
||||
|
||||
type diaryYearData struct{ Months []diaryMonthSummary }
|
||||
type diaryMonthData struct{ Days []diaryDaySection }
|
||||
type diaryDayData struct{ Photos []diaryPhoto }
|
||||
|
||||
var diaryYearTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-year.html"))
|
||||
var diaryMonthTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-month.html"))
|
||||
var diaryDayTmpl = template.Must(template.ParseFS(assets, "assets/diary/diary-day.html"))
|
||||
|
||||
var germanWeekdays = map[time.Weekday]string{
|
||||
time.Sunday: "Sonntag",
|
||||
time.Monday: "Montag",
|
||||
time.Tuesday: "Dienstag",
|
||||
time.Wednesday: "Mittwoch",
|
||||
time.Thursday: "Donnerstag",
|
||||
time.Friday: "Freitag",
|
||||
time.Saturday: "Samstag",
|
||||
}
|
||||
|
||||
var germanMonths = map[time.Month]string{
|
||||
time.January: "Januar",
|
||||
time.February: "Februar",
|
||||
time.March: "März",
|
||||
time.April: "April",
|
||||
time.May: "Mai",
|
||||
time.June: "Juni",
|
||||
time.July: "Juli",
|
||||
time.August: "August",
|
||||
time.September: "September",
|
||||
time.October: "Oktober",
|
||||
time.November: "November",
|
||||
time.December: "Dezember",
|
||||
}
|
||||
|
||||
func formatGermanDate(t time.Time) string {
|
||||
return fmt.Sprintf("%s, %d. %s %d",
|
||||
germanWeekdays[t.Weekday()],
|
||||
t.Day(),
|
||||
germanMonths[t.Month()],
|
||||
t.Year(),
|
||||
)
|
||||
}
|
||||
|
||||
var photoExts = map[string]bool{
|
||||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
||||
}
|
||||
|
||||
// yearPhotos returns all photos in yearFsPath whose filename starts with
|
||||
// a YYYY-MM-DD date prefix.
|
||||
func yearPhotos(yearFsPath, yearURLPath string) []diaryPhoto {
|
||||
entries, err := os.ReadDir(yearFsPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var photos []diaryPhoto
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !photoExts[strings.ToLower(filepath.Ext(name))] {
|
||||
continue
|
||||
}
|
||||
if len(name) < 10 {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", name[:10])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
photos = append(photos, diaryPhoto{
|
||||
Date: t,
|
||||
Name: name,
|
||||
URL: path.Join(yearURLPath, url.PathEscape(name)),
|
||||
})
|
||||
}
|
||||
return photos
|
||||
}
|
||||
|
||||
// renderDiaryYear renders month sections with photo counts for a year folder.
|
||||
func renderDiaryYear(fsPath, urlPath string) template.HTML {
|
||||
year, err := strconv.Atoi(filepath.Base(fsPath))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
photos := yearPhotos(fsPath, urlPath)
|
||||
|
||||
entries, err := os.ReadDir(fsPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var months []diaryMonthSummary
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
monthNum, err := strconv.Atoi(e.Name())
|
||||
if err != nil || monthNum < 1 || monthNum > 12 {
|
||||
continue
|
||||
}
|
||||
count := 0
|
||||
for _, p := range photos {
|
||||
if p.Date.Year() == year && int(p.Date.Month()) == monthNum {
|
||||
count++
|
||||
}
|
||||
}
|
||||
monthDate := time.Date(year, time.Month(monthNum), 1, 0, 0, 0, 0, time.UTC)
|
||||
months = append(months, diaryMonthSummary{
|
||||
Name: monthDate.Format("January 2006"),
|
||||
URL: path.Join(urlPath, e.Name()) + "/",
|
||||
PhotoCount: count,
|
||||
})
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := diaryYearTmpl.Execute(&buf, diaryYearData{Months: months}); err != nil {
|
||||
log.Printf("diary year template: %v", err)
|
||||
return ""
|
||||
}
|
||||
return template.HTML(buf.String())
|
||||
}
|
||||
|
||||
// renderDiaryMonth renders a section per day, each with its markdown content
|
||||
// and photos sourced from the parent year folder.
|
||||
func renderDiaryMonth(fsPath, urlPath string) template.HTML {
|
||||
yearFsPath := filepath.Dir(fsPath)
|
||||
yearURLPath := parentURL(urlPath)
|
||||
|
||||
year, err := strconv.Atoi(filepath.Base(yearFsPath))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
monthNum, err := strconv.Atoi(filepath.Base(fsPath))
|
||||
if err != nil || monthNum < 1 || monthNum > 12 {
|
||||
return ""
|
||||
}
|
||||
|
||||
allPhotos := yearPhotos(yearFsPath, yearURLPath)
|
||||
var monthPhotos []diaryPhoto
|
||||
for _, p := range allPhotos {
|
||||
if p.Date.Year() == year && int(p.Date.Month()) == monthNum {
|
||||
monthPhotos = append(monthPhotos, p)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect day numbers from subdirectories and from photo filenames.
|
||||
daySet := map[int]bool{}
|
||||
dayDirs := map[int]string{} // day number → actual directory name
|
||||
entries, _ := os.ReadDir(fsPath)
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
d, err := strconv.Atoi(e.Name())
|
||||
if err != nil || d < 1 || d > 31 {
|
||||
continue
|
||||
}
|
||||
daySet[d] = true
|
||||
dayDirs[d] = e.Name()
|
||||
}
|
||||
for _, p := range monthPhotos {
|
||||
daySet[p.Date.Day()] = true
|
||||
}
|
||||
|
||||
days := make([]int, 0, len(daySet))
|
||||
for d := range daySet {
|
||||
days = append(days, d)
|
||||
}
|
||||
sort.Ints(days)
|
||||
|
||||
var sections []diaryDaySection
|
||||
for _, dayNum := range days {
|
||||
date := time.Date(year, time.Month(monthNum), dayNum, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
heading := formatGermanDate(date)
|
||||
dayURL := path.Join(urlPath, fmt.Sprintf("%02d", dayNum)) + "/"
|
||||
var content template.HTML
|
||||
if dirName, ok := dayDirs[dayNum]; ok {
|
||||
dayURL = path.Join(urlPath, dirName) + "/"
|
||||
dayFsPath := filepath.Join(fsPath, dirName)
|
||||
if raw, err := os.ReadFile(filepath.Join(dayFsPath, "index.md")); err == nil && len(raw) > 0 {
|
||||
if h := extractFirstHeading(raw); h != "" {
|
||||
heading = h
|
||||
raw = stripFirstHeading(raw)
|
||||
}
|
||||
content = renderMarkdown(raw)
|
||||
}
|
||||
}
|
||||
|
||||
var photos []diaryPhoto
|
||||
for _, p := range monthPhotos {
|
||||
if p.Date.Day() == dayNum {
|
||||
photos = append(photos, p)
|
||||
}
|
||||
}
|
||||
|
||||
sections = append(sections, diaryDaySection{
|
||||
Heading: heading,
|
||||
URL: dayURL,
|
||||
EditURL: dayURL + "?edit",
|
||||
Content: content,
|
||||
Photos: photos,
|
||||
})
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := diaryMonthTmpl.Execute(&buf, diaryMonthData{Days: sections}); err != nil {
|
||||
log.Printf("diary month template: %v", err)
|
||||
return ""
|
||||
}
|
||||
return template.HTML(buf.String())
|
||||
}
|
||||
|
||||
// renderDiaryDay renders the photo grid for a single day, sourcing photos
|
||||
// from the grandparent year folder.
|
||||
func renderDiaryDay(fsPath, urlPath string) template.HTML {
|
||||
monthFsPath := filepath.Dir(fsPath)
|
||||
yearFsPath := filepath.Dir(monthFsPath)
|
||||
yearURLPath := parentURL(parentURL(urlPath))
|
||||
|
||||
year, err := strconv.Atoi(filepath.Base(yearFsPath))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
monthNum, err := strconv.Atoi(filepath.Base(monthFsPath))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
dayNum, err := strconv.Atoi(filepath.Base(fsPath))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
allPhotos := yearPhotos(yearFsPath, yearURLPath)
|
||||
var photos []diaryPhoto
|
||||
for _, p := range allPhotos {
|
||||
if p.Date.Year() == year && int(p.Date.Month()) == monthNum && p.Date.Day() == dayNum {
|
||||
photos = append(photos, p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(photos) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := diaryDayTmpl.Execute(&buf, diaryDayData{Photos: photos}); err != nil {
|
||||
log.Printf("diary day template: %v", err)
|
||||
return ""
|
||||
}
|
||||
return template.HTML(buf.String())
|
||||
}
|
||||
238
main.go
@@ -1,10 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
@@ -12,40 +10,32 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
)
|
||||
|
||||
//go:embed assets/*
|
||||
//go:embed assets
|
||||
var assets embed.FS
|
||||
|
||||
var md = goldmark.New(
|
||||
goldmark.WithExtensions(extension.GFM, extension.Table),
|
||||
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
|
||||
goldmark.WithRendererOptions(html.WithUnsafe()),
|
||||
)
|
||||
|
||||
var tmpl = template.Must(template.New("page.html").ParseFS(assets, "assets/page.html"))
|
||||
|
||||
type crumb struct{ Name, URL string }
|
||||
type entry struct{ Icon, Name, URL, Meta string }
|
||||
|
||||
type pageData struct {
|
||||
Title string
|
||||
Crumbs []crumb
|
||||
CanEdit bool
|
||||
EditMode bool
|
||||
PostURL string
|
||||
RawContent string
|
||||
// specialPage is the result returned by a pageTypeHandler.
|
||||
// Content is injected into the page after the standard markdown content.
|
||||
// SuppressListing hides the default file/folder listing.
|
||||
type specialPage struct {
|
||||
Content template.HTML
|
||||
Entries []entry
|
||||
SuppressListing bool
|
||||
}
|
||||
|
||||
// pageTypeHandler is implemented by each special folder type (diary, gallery, …).
|
||||
// handle returns nil when the handler does not apply to the given path.
|
||||
type pageTypeHandler interface {
|
||||
handle(root, fsPath, urlPath string) *specialPage
|
||||
}
|
||||
|
||||
// pageTypeHandlers is the registry. Each type registers itself via init().
|
||||
var pageTypeHandlers []pageTypeHandler
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", ":8080", "listen address")
|
||||
wikiDir := flag.String("dir", "./wiki", "wiki root directory")
|
||||
@@ -86,7 +76,6 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
urlPath := path.Clean("/" + r.URL.Path)
|
||||
|
||||
fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath))
|
||||
|
||||
// Security: ensure the resolved path stays within root.
|
||||
@@ -98,11 +87,26 @@ fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath))
|
||||
|
||||
info, err := os.Stat(fsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Non-existent path: redirect GETs to the canonical slash form so
|
||||
// the browser URL is consistent, then serve an empty folder page.
|
||||
// POSTs must not be redirected — the form action has no trailing
|
||||
// slash (path.Clean strips it) and the content would be lost.
|
||||
if !strings.HasSuffix(r.URL.Path, "/") && r.Method != http.MethodPost {
|
||||
http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
h.serveDir(w, r, urlPath, fsPath)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
if urlPath != "/" {
|
||||
urlPath += "/"
|
||||
}
|
||||
h.serveDir(w, r, urlPath, fsPath)
|
||||
} else {
|
||||
http.ServeFile(w, r, fsPath)
|
||||
@@ -120,33 +124,64 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
||||
indexPath := filepath.Join(fsPath, "index.md")
|
||||
rawMD, _ := os.ReadFile(indexPath)
|
||||
|
||||
// Determine section index (-1 = whole page).
|
||||
sectionIndex := -1
|
||||
if editMode {
|
||||
if s := r.URL.Query().Get("section"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil && n >= 0 {
|
||||
sectionIndex = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var rendered template.HTML
|
||||
if len(rawMD) > 0 && !editMode {
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert(rawMD, &buf); err == nil {
|
||||
rendered = template.HTML(buf.String())
|
||||
rendered = renderMarkdown(rawMD)
|
||||
}
|
||||
|
||||
var special *specialPage
|
||||
if !editMode {
|
||||
for _, ph := range pageTypeHandlers {
|
||||
if special = ph.handle(h.root, fsPath, urlPath); special != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var entries []entry
|
||||
if !editMode {
|
||||
if !editMode && (special == nil || !special.SuppressListing) {
|
||||
entries = listEntries(fsPath, urlPath)
|
||||
}
|
||||
|
||||
postURL := urlPath
|
||||
if urlPath == "/" {
|
||||
postURL = "/"
|
||||
title := pageTitle(urlPath)
|
||||
if heading := extractFirstHeading(rawMD); heading != "" {
|
||||
title = heading
|
||||
}
|
||||
|
||||
var specialContent template.HTML
|
||||
if special != nil {
|
||||
specialContent = special.Content
|
||||
}
|
||||
|
||||
rawContent := string(rawMD)
|
||||
if editMode && sectionIndex >= 0 {
|
||||
sections := splitSections(rawMD)
|
||||
if sectionIndex < len(sections) {
|
||||
rawContent = string(sections[sectionIndex])
|
||||
}
|
||||
}
|
||||
|
||||
data := pageData{
|
||||
Title: pageTitle(urlPath),
|
||||
Title: title,
|
||||
Crumbs: buildCrumbs(urlPath),
|
||||
CanEdit: true,
|
||||
EditMode: editMode,
|
||||
PostURL: postURL,
|
||||
RawContent: string(rawMD),
|
||||
SectionIndex: sectionIndex,
|
||||
PostURL: urlPath,
|
||||
RawContent: rawContent,
|
||||
Content: rendered,
|
||||
Entries: entries,
|
||||
SpecialContent: specialContent,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -162,106 +197,63 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
||||
}
|
||||
content := r.FormValue("content")
|
||||
indexPath := filepath.Join(fsPath, "index.md")
|
||||
|
||||
// If a section index was submitted, splice the edited section back into
|
||||
// the full file rather than replacing the whole document.
|
||||
if s := r.FormValue("section"); s != "" {
|
||||
sectionIndex, err := strconv.Atoi(s)
|
||||
if err != nil || sectionIndex < 0 {
|
||||
http.Error(w, "bad section", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
rawMD, _ := os.ReadFile(indexPath)
|
||||
sections := splitSections(rawMD)
|
||||
if sectionIndex < len(sections) {
|
||||
sections[sectionIndex] = []byte(content)
|
||||
}
|
||||
content = string(joinSections(sections))
|
||||
}
|
||||
|
||||
if strings.TrimSpace(content) == "" {
|
||||
if err := os.Remove(indexPath); err != nil && !os.IsNotExist(err) {
|
||||
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := os.MkdirAll(fsPath, 0755); err != nil {
|
||||
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(indexPath, []byte(content), 0644); err != nil {
|
||||
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, urlPath, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func listEntries(fsPath, urlPath string) []entry {
|
||||
entries, err := os.ReadDir(fsPath)
|
||||
// readPageSettings parses a .page-settings file in dir.
|
||||
// Returns nil if the file does not exist.
|
||||
// Format: one "key = value" pair per line; lines starting with # are comments.
|
||||
func readPageSettings(dir string) *pageSettings {
|
||||
data, err := os.ReadFile(filepath.Join(dir, ".page-settings"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var folders, files []entry
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, ".") {
|
||||
s := &pageSettings{}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
entryURL := path.Join(urlPath, name)
|
||||
if e.IsDir() {
|
||||
folders = append(folders, entry{
|
||||
Icon: "📁",
|
||||
Name: name,
|
||||
URL: entryURL + "/",
|
||||
Meta: info.ModTime().Format("2006-01-02"),
|
||||
})
|
||||
} else {
|
||||
if name == "index.md" {
|
||||
continue // rendered above, don't list it
|
||||
}
|
||||
files = append(files, entry{
|
||||
Icon: fileIcon(name),
|
||||
Name: name,
|
||||
URL: entryURL,
|
||||
Meta: formatSize(info.Size()) + " · " + info.ModTime().Format("2006-01-02"),
|
||||
})
|
||||
switch strings.TrimSpace(parts[0]) {
|
||||
case "type":
|
||||
s.Type = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name })
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
|
||||
|
||||
return append(folders, files...)
|
||||
}
|
||||
|
||||
func fileIcon(name string) string {
|
||||
ext := strings.ToLower(path.Ext(name))
|
||||
switch ext {
|
||||
case ".md":
|
||||
return "📄"
|
||||
case ".pdf":
|
||||
return "📕"
|
||||
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
|
||||
return "🖼"
|
||||
case ".mp4", ".mkv", ".avi", ".mov":
|
||||
return "🎬"
|
||||
case ".mp3", ".flac", ".ogg", ".wav":
|
||||
return "🎵"
|
||||
case ".zip", ".tar", ".gz", ".7z":
|
||||
return "📦"
|
||||
default:
|
||||
return "📎"
|
||||
}
|
||||
}
|
||||
|
||||
func formatSize(b int64) string {
|
||||
switch {
|
||||
case b < 1024:
|
||||
return fmt.Sprintf("%d B", b)
|
||||
case b < 1024*1024:
|
||||
return fmt.Sprintf("%.1f KB", float64(b)/1024)
|
||||
default:
|
||||
return fmt.Sprintf("%.1f MB", float64(b)/1024/1024)
|
||||
}
|
||||
}
|
||||
|
||||
func buildCrumbs(urlPath string) []crumb {
|
||||
if urlPath == "/" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(strings.Trim(urlPath, "/"), "/")
|
||||
crumbs := make([]crumb, len(parts))
|
||||
for i, p := range parts {
|
||||
crumbs[i] = crumb{
|
||||
Name: p,
|
||||
URL: "/" + strings.Join(parts[:i+1], "/") + "/",
|
||||
}
|
||||
}
|
||||
return crumbs
|
||||
}
|
||||
|
||||
func pageTitle(urlPath string) string {
|
||||
if urlPath == "/" {
|
||||
return "Datascape"
|
||||
}
|
||||
parts := strings.Split(strings.Trim(urlPath, "/"), "/")
|
||||
return parts[len(parts)-1]
|
||||
return s
|
||||
}
|
||||
|
||||
202
render.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
)
|
||||
|
||||
var md = goldmark.New(
|
||||
goldmark.WithExtensions(extension.GFM, extension.Table),
|
||||
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
|
||||
goldmark.WithRendererOptions(html.WithUnsafe()),
|
||||
)
|
||||
|
||||
type crumb struct{ Name, URL string }
|
||||
type entry struct {
|
||||
Icon template.HTML
|
||||
Name, URL, Meta string
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
Title string
|
||||
Crumbs []crumb
|
||||
CanEdit bool
|
||||
EditMode bool
|
||||
SectionIndex int // -1 = whole page; >=0 = section being edited
|
||||
PostURL string
|
||||
RawContent string
|
||||
Content template.HTML
|
||||
Entries []entry
|
||||
SpecialContent template.HTML
|
||||
}
|
||||
|
||||
// pageSettings holds the parsed contents of a .page-settings file.
|
||||
type pageSettings struct {
|
||||
Type string
|
||||
}
|
||||
|
||||
var (
|
||||
iconFolder = readIcon("folder")
|
||||
iconDoc = readIcon("doc")
|
||||
iconImage = readIcon("image")
|
||||
iconVideo = readIcon("video")
|
||||
iconAudio = readIcon("audio")
|
||||
iconArchive = readIcon("archive")
|
||||
iconGeneric = readIcon("generic")
|
||||
)
|
||||
|
||||
// renderMarkdown converts raw markdown to trusted HTML.
|
||||
func renderMarkdown(raw []byte) template.HTML {
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert(raw, &buf); err != nil {
|
||||
return ""
|
||||
}
|
||||
return template.HTML(buf.String())
|
||||
}
|
||||
|
||||
// extractFirstHeading returns the text of the first ATX heading in raw markdown,
|
||||
// or an empty string if none is found.
|
||||
func extractFirstHeading(raw []byte) string {
|
||||
for _, line := range strings.SplitN(string(raw), "\n", 50) {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(strings.TrimLeft(trimmed, "#"))
|
||||
if text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// stripFirstHeading removes the first ATX heading line from raw markdown.
|
||||
func stripFirstHeading(raw []byte) []byte {
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||
result := append(lines[:i:i], lines[i+1:]...)
|
||||
return []byte(strings.TrimLeft(strings.Join(result, "\n"), "\n"))
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// parentURL returns the parent URL of a slash-terminated URL path.
|
||||
func parentURL(urlPath string) string {
|
||||
parent := path.Dir(strings.TrimSuffix(urlPath, "/"))
|
||||
if parent == "." || parent == "/" {
|
||||
return "/"
|
||||
}
|
||||
return parent + "/"
|
||||
}
|
||||
|
||||
func listEntries(fsPath, urlPath string) []entry {
|
||||
entries, err := os.ReadDir(fsPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var folders, files []entry
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
entryURL := path.Join(urlPath, name)
|
||||
if e.IsDir() {
|
||||
folders = append(folders, entry{
|
||||
Icon: iconFolder,
|
||||
Name: name,
|
||||
URL: entryURL + "/",
|
||||
Meta: info.ModTime().Format("2006-01-02"),
|
||||
})
|
||||
} else {
|
||||
if name == "index.md" {
|
||||
continue // rendered above, don't list it
|
||||
}
|
||||
files = append(files, entry{
|
||||
Icon: fileIcon(name),
|
||||
Name: name,
|
||||
URL: entryURL,
|
||||
Meta: formatSize(info.Size()) + " · " + info.ModTime().Format("2006-01-02"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name })
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
|
||||
|
||||
return append(folders, files...)
|
||||
}
|
||||
|
||||
func readIcon(name string) template.HTML {
|
||||
b, _ := assets.ReadFile("assets/icons/" + name + ".svg")
|
||||
return template.HTML(strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
func fileIcon(name string) template.HTML {
|
||||
ext := strings.ToLower(path.Ext(name))
|
||||
switch ext {
|
||||
case ".md", ".pdf":
|
||||
return iconDoc
|
||||
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
|
||||
return iconImage
|
||||
case ".mp4", ".mkv", ".avi", ".mov":
|
||||
return iconVideo
|
||||
case ".mp3", ".flac", ".ogg", ".wav":
|
||||
return iconAudio
|
||||
case ".zip", ".tar", ".gz", ".7z":
|
||||
return iconArchive
|
||||
default:
|
||||
return iconGeneric
|
||||
}
|
||||
}
|
||||
|
||||
func formatSize(b int64) string {
|
||||
switch {
|
||||
case b < 1024:
|
||||
return fmt.Sprintf("%d B", b)
|
||||
case b < 1024*1024:
|
||||
return fmt.Sprintf("%.1f KB", float64(b)/1024)
|
||||
default:
|
||||
return fmt.Sprintf("%.1f MB", float64(b)/1024/1024)
|
||||
}
|
||||
}
|
||||
|
||||
func buildCrumbs(urlPath string) []crumb {
|
||||
if urlPath == "/" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(strings.Trim(urlPath, "/"), "/")
|
||||
crumbs := make([]crumb, len(parts))
|
||||
for i, p := range parts {
|
||||
crumbs[i] = crumb{
|
||||
Name: p,
|
||||
URL: "/" + strings.Join(parts[:i+1], "/") + "/",
|
||||
}
|
||||
}
|
||||
return crumbs
|
||||
}
|
||||
|
||||
func pageTitle(urlPath string) string {
|
||||
if urlPath == "/" {
|
||||
return "Datascape"
|
||||
}
|
||||
parts := strings.Split(strings.Trim(urlPath, "/"), "/")
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
31
sections.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var sectionHeadingRe = regexp.MustCompile(`(?m)^#{1,6} `)
|
||||
|
||||
// splitSections splits raw markdown into sections.
|
||||
// Section 0 is any content before the first heading.
|
||||
// Each subsequent section begins at a heading line and runs to the next.
|
||||
func splitSections(raw []byte) [][]byte {
|
||||
locs := sectionHeadingRe.FindAllIndex(raw, -1)
|
||||
if len(locs) == 0 {
|
||||
return [][]byte{raw}
|
||||
}
|
||||
sections := make([][]byte, 0, len(locs)+1)
|
||||
prev := 0
|
||||
for _, loc := range locs {
|
||||
sections = append(sections, raw[prev:loc[0]])
|
||||
prev = loc[0]
|
||||
}
|
||||
sections = append(sections, raw[prev:])
|
||||
return sections
|
||||
}
|
||||
|
||||
// joinSections reassembles sections produced by splitSections.
|
||||
func joinSections(sections [][]byte) []byte {
|
||||
return bytes.Join(sections, nil)
|
||||
}
|
||||