Compare commits
4 Commits
ab57377034
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f78ebe7d5d | |||
| d943fc5596 | |||
| e9061a221c | |||
| 5a5305dd42 |
@@ -1,141 +1,7 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
## Project Overview
|
I want to understand every line of code that goes into this project. Never create, edit, move, rename, or delete project files unless I explicitly ask you to do so. Instead, show me every proposed edit in the chat so I can type it in manually.
|
||||||
|
|
||||||
`datascape` is a minimal personal wiki where **the folder structure is the wiki**.
|
Do not run commands that modify project files, install dependencies, or change repository state unless I explicitly request that action. Instead, show me those commands in the chat so I can run them manually.
|
||||||
No database, no CMS, no abstraction layer — every folder is a page, and `index.md`
|
|
||||||
in a folder is that page's content.
|
|
||||||
|
|
||||||
## Build & Deploy
|
I'm an experienced developer. Do not explain syntax, APIs, programming concepts, or implementation details unless explicitly asked.
|
||||||
|
|
||||||
```bash
|
|
||||||
# Local build (host architecture)
|
|
||||||
go build .
|
|
||||||
|
|
||||||
# Deploy to NAS
|
|
||||||
make deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
### Editor bundle (the one build-pipeline exception)
|
|
||||||
|
|
||||||
The page editor uses CodeMirror 6, vendored as a single pre-built IIFE at
|
|
||||||
`assets/editor/vendor/codemirror.bundle.js` and embedded via `embed.FS`. This is
|
|
||||||
the **only** deliberate exception to the "no build pipeline" rule below — it is a
|
|
||||||
one-time, committed artifact, not a runtime build. `go build` / `make deploy`
|
|
||||||
never touch Node and only consume the committed bundle.
|
|
||||||
|
|
||||||
Regenerate the bundle **only** when upgrading the `@codemirror/*` versions:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# bump versions in editor-build/package.json first, then:
|
|
||||||
make editor # runs `npm ci && npm run build` in editor-build/, rewrites the vendored bundle
|
|
||||||
```
|
|
||||||
|
|
||||||
Commit the regenerated `codemirror.bundle.js` and the updated
|
|
||||||
`editor-build/package-lock.json`. `editor-build/node_modules/` is gitignored.
|
|
||||||
|
|
||||||
The bundle is served immutable under a stable filename, so the edit template
|
|
||||||
appends `?v=<content-hash>` to its `<script>` src (`editorBundleVersion` in
|
|
||||||
`main.go`). The hash changes whenever the bundle bytes change, so a rebuilt
|
|
||||||
bundle busts client caches automatically — no manual version bump needed.
|
|
||||||
|
|
||||||
## HTTP API Surface
|
|
||||||
|
|
||||||
| Method | Path | Behaviour |
|
|
||||||
|--------|------|-----------|
|
|
||||||
| GET | `/{path}/` | If folder exists: render `index.md` + list contents. If not: show empty create prompt. |
|
|
||||||
| GET | `/{path}/?edit` | CodeMirror 6 editor initialized with `index.md` content |
|
|
||||||
| POST | `/{path}` | Write `index.md` to disk; creates the folder if it does not exist yet |
|
|
||||||
|
|
||||||
Non-existent paths without a trailing slash redirect to the slash form (GET only — POSTs
|
|
||||||
are not redirected because `path.Clean` strips the trailing slash from `PostURL` and the
|
|
||||||
content would be lost).
|
|
||||||
|
|
||||||
## Code Structure
|
|
||||||
|
|
||||||
When adding a new special folder type, create a new `.go` file. Do not add type-specific logic to `main.go` or `render.go`.
|
|
||||||
|
|
||||||
Prefer separate, human-readable `.html` files over inlined HTML strings in Go. Embed them via `embed.FS` if needed.
|
|
||||||
|
|
||||||
|
|
||||||
## Architecture Rules
|
|
||||||
|
|
||||||
- **Single binary** — no installer, no runtime dependencies, no Docker
|
|
||||||
- **Go stdlib `net/http`** only — no web framework
|
|
||||||
- **`goldmark`** for Markdown rendering — no other Markdown libraries
|
|
||||||
- **`embed.FS`** for all assets — no external serving, no CDN
|
|
||||||
- **No database** of any kind
|
|
||||||
- **No indexing or caching** unless explicitly requested and justified
|
|
||||||
- Keep dependencies to an absolute minimum; if stdlib can do it, use stdlib
|
|
||||||
|
|
||||||
## Frontend Rules
|
|
||||||
|
|
||||||
- Vanilla JS only — no frameworks, no build pipeline (the single exception is the vendored CodeMirror editor bundle; see Build & Deploy)
|
|
||||||
- Each feature gets its own JS file; global behaviour goes in `global-shortcuts.js`
|
|
||||||
- Do not inline JS in templates or merge unrelated features into one file
|
|
||||||
- `ALT+SHIFT` is the modifier for all keyboard shortcuts — do not introduce others
|
|
||||||
- Editor toolbar buttons use `data-action` + `data-key`; adding `data-key` auto-registers the shortcut
|
|
||||||
- The editor is a *mode* of a page. Opening it pushes a normal history entry so
|
|
||||||
the browser/Android Back button cancels the edit and returns to the page — this
|
|
||||||
"back to cancel" gesture takes priority. `history-nav.js` only rewrites the way
|
|
||||||
*out*: leaving the editor via the same-page CANCEL link uses `location.replace`
|
|
||||||
so the editor entry is collapsed rather than sandwiched between two page entries
|
|
||||||
(which would let Back walk back into the editor). SAVE does the equivalent — it
|
|
||||||
POSTs via fetch and then `replaceState`s the editor entry with the saved page,
|
|
||||||
so the editor never lingers in history once you leave it (the pre-save page
|
|
||||||
snapshot one step back is the accepted cost). Links to a *different* page's
|
|
||||||
editor (new page / new child) push normally. The save POST answers `204` +
|
|
||||||
`X-Target` when the request carries `X-Save-Mode: replace`, because the target
|
|
||||||
may hold a `#section` anchor only the server can compute and fetch drops
|
|
||||||
fragments from followed redirects.
|
|
||||||
- For mutating modals (anything that POSTs and then navigates), call `closeModal()` and then `postReplace(action, body, target)` from `page/actions.js`. Do NOT use `<form>.submit()`. Two reasons:
|
|
||||||
1. The modal must be removed from the DOM before navigation, or the browser's bfcache snapshots it open and back-nav restores the modal.
|
|
||||||
2. `postReplace` uses `window.location.replace` so the action + result occupy a single history entry. A naive POST → 303 → GET creates two entries, and back-nav lands on a stale pre-mutation snapshot of the same page.
|
|
||||||
|
|
||||||
## CSS
|
|
||||||
|
|
||||||
Follow **SMACSS** conventions (Scalable and Modular Architecture for CSS). The stylesheet is organized into five categories:
|
|
||||||
|
|
||||||
- **Base** — element resets and global defaults only. Never style `header`, `textarea`, `input`, `aside`, `footer`, etc. directly for visual treatment — always via a class.
|
|
||||||
- **Layout** — `.row`, `.col`, `.page-wrap`. Use these for flex layout; do not inline `display: flex` on feature classes.
|
|
||||||
- **Modules** — reusable components: `.panel`, `.panel-header`, `.menu-row`, `.btn`, `.input`, `.muted`, `.truncate`, etc. New visual patterns should reuse these. Before adding a new module, check whether an existing one + a modifier already covers the case.
|
|
||||||
- **State** — `.is-*` prefix only (`.is-open`, `.is-selected`, `.is-active`, `.is-disabled`, `.is-empty`). State is the only place a class describes a moment in time rather than a structural role.
|
|
||||||
- **Theme** — colors, borders, spacing, and font sizes come from CSS variables defined in `:root` (`--bg`, `--secondary`, `--border`, `--border-dashed`, `--space-*`, `--font-*`). No hardcoded `1px solid #...`, no hardcoded rem spacing in component rules.
|
|
||||||
|
|
||||||
Naming: flat-dash (`.panel-header`, `.btn-small`), not BEM (`.panel__header--small`). Modifiers attach as additional classes (`<div class="btn btn-small">`), not as new standalone classes.
|
|
||||||
|
|
||||||
Anti-patterns to reject:
|
|
||||||
- One-off classes that duplicate an existing module (`.save-button` when `.btn` exists, `.form-name-input` when `.input` exists).
|
|
||||||
- Element selectors (`textarea { ... }`, `header { ... }`) for visual treatment — add a class instead.
|
|
||||||
- Inlining `display: flex; gap: X` on a feature class instead of composing with `.row` / `.col`.
|
|
||||||
- Adding a new module for a single use site — prefer a modifier on an existing module first.
|
|
||||||
- Hardcoded colors, border widths, or spacing values inside component rules — pull a variable, or add one to `:root` if it's missing.
|
|
||||||
|
|
||||||
## Development Priorities
|
|
||||||
|
|
||||||
When building features, apply this order:
|
|
||||||
1. Correctness on the filesystem — never corrupt or lose files
|
|
||||||
2. Mobile usability (primary editing device is Android over Wireguard VPN)
|
|
||||||
3. Simplicity of implementation, adhere to KISS
|
|
||||||
4. Performance
|
|
||||||
|
|
||||||
## Date Formatting
|
|
||||||
|
|
||||||
- General UI dates (file listings, metadata): ISO `YYYY-MM-DD`
|
|
||||||
- Diary headings (year/month/day) are also ISO short form: `# 2026`, `## 2026-05`, `### 2026-05-28`. No long-form rendering.
|
|
||||||
- Calendar widget month names are German; the `germanMonths` map in `diary.go` keeps the labels keyed by `time.Month` since Go's `time.Format` is English-only.
|
|
||||||
|
|
||||||
## What to Avoid
|
|
||||||
|
|
||||||
- Any parallel folder structure (e.g. a separate `media/` tree mirroring `pages/`)
|
|
||||||
- Over-engineering auth — Basic auth is sufficient for a personal VPN tool
|
|
||||||
- Heavy payloads or expensive rendering (target CPU: ARMv7 32-bit NAS)
|
|
||||||
- Suggesting Docker (plain binary is preferred)
|
|
||||||
|
|
||||||
## Out of Scope (do not implement unless explicitly asked)
|
|
||||||
|
|
||||||
- Full-text search
|
|
||||||
- Browser-based file upload
|
|
||||||
- Version history / git integration
|
|
||||||
- Multi-user support
|
|
||||||
- Tagging or metadata beyond `index.md` content
|
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ func runOpenCommand(template, path string) error {
|
|||||||
if !sawPath {
|
if !sawPath {
|
||||||
tokens = append(tokens, path)
|
tokens = append(tokens, path)
|
||||||
}
|
}
|
||||||
return exec.Command(tokens[0], tokens[1:]...).Start()
|
cmd := exec.Command(tokens[0], tokens[1:]...)
|
||||||
|
hideConsole(cmd)
|
||||||
|
return cmd.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
// tokenizeCommand splits a command-line string into argv tokens, honouring
|
// tokenizeCommand splits a command-line string into argv tokens, honouring
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
// Allows running commands without showing a terminal window on startup when not running on windows
|
||||||
|
func hideConsole(*exec.Cmd) {}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is the windows CREATE_NO_WINDOW syscall.
|
||||||
|
// We have no golang.org/x/sys dependency, so this will suffice
|
||||||
|
const createNoWindow = 0x08000000
|
||||||
|
|
||||||
|
// Allows running commands without showing a terminal window on startup on windows
|
||||||
|
func hideConsole(cmd *exec.Cmd) {
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
CreationFlags: createNoWindow,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "1"
|
const version = "2"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|||||||
-57
@@ -1,57 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/yuin/goldmark"
|
|
||||||
"github.com/yuin/goldmark/ast"
|
|
||||||
"github.com/yuin/goldmark/parser"
|
|
||||||
"github.com/yuin/goldmark/text"
|
|
||||||
"github.com/yuin/goldmark/util"
|
|
||||||
)
|
|
||||||
|
|
||||||
type extLinksTransformer struct{}
|
|
||||||
|
|
||||||
func (extLinksTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
|
|
||||||
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
|
||||||
if !entering {
|
|
||||||
return ast.WalkContinue, nil
|
|
||||||
}
|
|
||||||
link, ok := n.(*ast.Link)
|
|
||||||
if !ok {
|
|
||||||
return ast.WalkContinue, nil
|
|
||||||
}
|
|
||||||
if isExternalURL(string(link.Destination)) {
|
|
||||||
link.SetAttribute([]byte("target"), []byte("_blank"))
|
|
||||||
link.SetAttribute([]byte("rel"), []byte("noopener noreferrer"))
|
|
||||||
}
|
|
||||||
return ast.WalkContinue, nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func isExternalURL(dest string) bool {
|
|
||||||
if strings.HasPrefix(dest, "//") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
i := strings.Index(dest, ":")
|
|
||||||
if i <= 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, c := range dest[:i] {
|
|
||||||
if !(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') &&
|
|
||||||
!(c >= '0' && c <= '9') && c != '+' && c != '-' && c != '.' {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
type extLinksExt struct{}
|
|
||||||
|
|
||||||
func newExtLinksExt() goldmark.Extender { return &extLinksExt{} }
|
|
||||||
|
|
||||||
func (e *extLinksExt) Extend(m goldmark.Markdown) {
|
|
||||||
m.Parser().AddOptions(parser.WithASTTransformers(
|
|
||||||
util.Prioritized(extLinksTransformer{}, 999),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,7 @@ var md goldmark.Markdown
|
|||||||
// targets against the filesystem.
|
// targets against the filesystem.
|
||||||
func initMarkdown(root string) {
|
func initMarkdown(root string) {
|
||||||
md = goldmark.New(
|
md = goldmark.New(
|
||||||
goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(root), newWikiEmbedExt(root), newExtLinksExt()),
|
goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(root), newWikiEmbedExt(root)),
|
||||||
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
|
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
|
||||||
goldmark.WithRendererOptions(html.WithUnsafe(), html.WithHardWraps()),
|
goldmark.WithRendererOptions(html.WithUnsafe(), html.WithHardWraps()),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user