Compare commits

..

20 Commits

Author SHA1 Message Date
c85ea3bb0c Redesign.
No more retro CRT
2026-04-17 15:55:48 +02:00
8081c2ebd8 Update diary view with new buttons 2026-04-15 12:32:28 +02:00
02fa19272d Allow editing of individual sections 2026-04-15 12:14:40 +02:00
b3ca714597 Improve markdown editor 2026-04-15 09:14:24 +02:00
c7d5db7af7 Remove Agents.md 2026-04-15 07:25:55 +02:00
ed8cb79b0b Update .gitignore 2026-04-14 22:25:19 +02:00
c30c4d3a0d update icon and template handling 2026-04-14 22:24:03 +02:00
ab22952e3d Unify button CSS 2026-04-13 13:50:23 +02:00
316551d263 Update README and AGENTS 2026-04-13 13:11:34 +02:00
19017bf136 Fix page creation 2026-04-13 13:07:20 +02:00
95ca30509c Refactor projcet structure 2026-04-13 12:39:01 +02:00
0e8e3b2636 Diary feature V1 2026-04-13 12:33:02 +02:00
613ada1f84 Keep white glow for amber text 2026-04-13 09:47:11 +02:00
2121bb686e Fix last commit 2026-04-10 12:49:39 +02:00
b8caab7a71 Update icons 2026-04-10 12:47:03 +02:00
2f08e15e3b Add Logo 2026-04-10 12:34:46 +02:00
568039a39b Display favicons for external links 2026-04-10 12:26:34 +02:00
ad4f58324a Fix trailing slash issue 2026-04-10 12:21:53 +02:00
22a4d2d18b Unify Edit/save/cancel buttons 2026-04-10 11:46:31 +02:00
2635dce918 Delete pages instead of writing empty files 2026-04-10 11:46:06 +02:00
29 changed files with 1669 additions and 402 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ wiki/
# Binaries
datascape
*.exe

View File

@@ -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 `![](photo.jpg)` 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
View 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

View File

@@ -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
View 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);
});
})();

View 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}}

View 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}}

View 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}}

View File

@@ -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,24 +30,63 @@
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(); },
bold: function () { wrap('**', '**', 'bold text'); },
italic: function () { wrap('*', '*', 'italic text'); },
h1: function () { linePrefix('# '); },
h2: function () { linePrefix('## '); },
h3: function () { linePrefix('### '); },
code: function () { wrap('`', '`', 'code'); },
codeblock: function () { wrap('```\n', '\n```', 'code'); },
quote: function () { linePrefix('> '); },
link: function () { wrap('[', '](url)', 'link text'); },
ul: function () { linePrefix('- '); },
ol: function () { linePrefix('1. '); },
hr: function () { wrap('\n\n---\n\n', '', ''); },
save: function () { form.submit(); },
bold: function () { wrap('**', '**', 'bold text'); },
italic: function () { wrap('*', '*', 'italic text'); },
h1: function () { linePrefix('# '); },
h2: function () { linePrefix('## '); },
h3: function () { linePrefix('### '); },
code: function () { wrap('`', '`', 'code'); },
codeblock: function () { wrap('```\n', '\n```', 'code'); },
quote: function () { linePrefix('> '); },
link: function () { wrap('[', '](url)', 'link text'); },
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
View 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
View 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
View 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,
};
})();

Binary file not shown.

Binary file not shown.

View 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;
@@ -5,7 +12,11 @@
case 'E':
e.preventDefault();
window.location.href = window.location.pathname + '?edit';
break;
break;
case 'N':
e.preventDefault();
newPage();
break;
}
});
})();

7
assets/icons/archive.svg Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View File

@@ -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)">&gt;</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)">&gt;</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
View 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&section=' + (i + 1);
a.className = 'btn btn-small section-edit';
a.textContent = 'edit';
h.appendChild(a);
});
}());

View File

@@ -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
View 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())
}

258
main.go
View File

@@ -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
Content template.HTML
Entries []entry
// 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
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,8 +76,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
urlPath := path.Clean("/" + r.URL.Path)
fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath))
fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath))
// Security: ensure the resolved path stays within root.
rel, err := filepath.Rel(h.root, fsPath)
@@ -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),
Crumbs: buildCrumbs(urlPath),
CanEdit: true,
EditMode: editMode,
PostURL: postURL,
RawContent: string(rawMD),
Content: rendered,
Entries: entries,
Title: title,
Crumbs: buildCrumbs(urlPath),
CanEdit: true,
EditMode: editMode,
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 err := os.WriteFile(indexPath, []byte(content), 0644); err != nil {
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return
// 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
View 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
View 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)
}