pocketspec 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +75 -0
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/package.json +37 -0
- package/public/app.js +488 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/index.html +26 -0
- package/public/manifest.json +12 -0
- package/public/marked.min.js +6 -0
- package/public/style.css +344 -0
- package/server.js +395 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## What this is
|
|
6
|
+
|
|
7
|
+
pocketspec serves local markdown folders over the LAN so you can read docs/specs on your phone, tap a paragraph to comment, and have an AI agent read those comments back to revise the docs. It's a single-purpose tool: zero npm dependencies, Node stdlib server, vanilla-JS SPA client.
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
node server.js ~/docs # serve a folder (same as `npx pocketspec ~/docs`)
|
|
13
|
+
node server.js ~/a ~/b # serve multiple folders (ephemeral roots)
|
|
14
|
+
node server.js # serve folders registered via `add` (from config.json)
|
|
15
|
+
node server.js --help
|
|
16
|
+
|
|
17
|
+
node server.js add ~/docs "Name" # register a persistent root
|
|
18
|
+
node server.js list # list registered roots
|
|
19
|
+
|
|
20
|
+
node server.js ~/docs --port 8080 # starting port (falls back to next free port, 10 tries)
|
|
21
|
+
node server.js ~/docs --read-only # disable all writes (edit + comments)
|
|
22
|
+
node server.js ~/docs --password P # HTTP Basic Auth; prefer POCKETSPEC_PASSWORD env var
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
There is no build, lint, or test setup — it's plain Node + static files. Run the server and open the printed Network URL to verify changes.
|
|
26
|
+
|
|
27
|
+
## Architecture
|
|
28
|
+
|
|
29
|
+
Two files hold essentially everything:
|
|
30
|
+
|
|
31
|
+
- **`server.js`** — the entire backend. A single `http.createServer` handler routes by `pathname`. No framework, no router lib.
|
|
32
|
+
- **`public/app.js`** — the entire frontend SPA. Hash-based routing, renders markdown client-side via the vendored `public/marked.min.js`.
|
|
33
|
+
|
|
34
|
+
### Roots model
|
|
35
|
+
|
|
36
|
+
A "root" is one served folder, addressed by integer index. Roots come from one of two sources, resolved per request by `currentRoots()`:
|
|
37
|
+
|
|
38
|
+
- **Ephemeral**: folder paths passed on the CLI → `RUNTIME_ROOTS` (not persisted).
|
|
39
|
+
- **Persistent**: `config.json` `{ roots: [{name, path}] }`, managed by the `add`/`list` subcommands. `config.json` is gitignored (it holds personal paths).
|
|
40
|
+
|
|
41
|
+
If any folder args are given they fully override config; otherwise config is used.
|
|
42
|
+
|
|
43
|
+
### API (all under `/api/`)
|
|
44
|
+
|
|
45
|
+
- `GET /api/roots` — list roots `{id, name, path}`
|
|
46
|
+
- `GET /api/meta` — `{ readOnly }`
|
|
47
|
+
- `GET /api/list?root=&path=` — folder listing (`{dirs, files}`)
|
|
48
|
+
- `GET /api/doc?root=&path=` — raw markdown text
|
|
49
|
+
- `GET /api/raw?root=&path=` — images/assets referenced by docs
|
|
50
|
+
- `GET/POST/DELETE /api/comments?root=&path=[&id=]` — read/add/delete comments
|
|
51
|
+
- `POST /api/save?root=&path=` — overwrite a `.md` file (`{content}`)
|
|
52
|
+
|
|
53
|
+
Everything else falls through to static files in `public/`, then to `index.html` (SPA fallback).
|
|
54
|
+
|
|
55
|
+
### Security invariants (do not regress these)
|
|
56
|
+
|
|
57
|
+
- **Path traversal**: every file access goes through `resolveInRoot(rootIndex, relPath)`, which resolves against the root's realpath and rejects anything not inside it — checked both before and after `realpathSync` (symlink escape). New file-serving endpoints MUST use it.
|
|
58
|
+
- **Read-only**: when `READ_ONLY`, any non-GET on `/api/comments` and `/api/save` returns 403.
|
|
59
|
+
- **Auth**: `checkAuth` gates *every* request (constant-time compare, username ignored). It's the first thing in the handler.
|
|
60
|
+
- The server binds `0.0.0.0` by design (LAN access) — there is no auth by default. This is intentional; the README documents the trust-the-network / Tailscale model.
|
|
61
|
+
|
|
62
|
+
### Comment anchoring
|
|
63
|
+
|
|
64
|
+
Comments live in a sidecar `file.md.comments` JSON file next to the doc (`commentsPathFor` = append `.comments`). Each comment: `{id, text, anchor, ts}` where `anchor` is `{index, snippet}` or `null` (general comment).
|
|
65
|
+
|
|
66
|
+
The client re-anchors on render (`resolveAnchor` in app.js): it matches a comment to a block first by `index`, falling back to matching the 80-char `snippet`. If the passage was edited away and neither matches, the comment becomes an **orphan** and is shown under "General comments" rather than lost. Blocks are tagged with `data-bi` (block index) at render time; the snippet is `blockSnippet` (trimmed `textContent`, first 80 chars). Keep these two definitions of snippet (client `SNIPPET_LEN`/`blockSnippet` and server-stored snippet) consistent.
|
|
67
|
+
|
|
68
|
+
### Client routing
|
|
69
|
+
|
|
70
|
+
Hash routes (`#/`, `#/0`, `#/0/sub/dir`, `#/0/sub/x.md`) drive everything, so the phone back button works. PWA-enabled via `manifest.json` + icons.
|
|
71
|
+
|
|
72
|
+
## Conventions
|
|
73
|
+
|
|
74
|
+
- Everything in the repo is English — UI, CLI, server strings, comments, commit messages. Keep it that way; no Portuguese.
|
|
75
|
+
- No dependencies. `marked` is vendored, not installed. Don't add an npm dependency without strong reason.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lucas Monteiro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# pocketspec
|
|
2
|
+
|
|
3
|
+
**Leave your AI agent writing docs and specs — read them on your phone from anywhere, comment by tapping a paragraph, and let the agent read your comments back.**
|
|
4
|
+
|
|
5
|
+
Point your AI agent (Claude, Cursor, whatever) at a folder, let it write specs while you do something else, and follow along from your phone over your local network. Spot a vague paragraph? Tap it and comment. The comment lands in a `.comments` file next to the doc — which the agent reads back to revise. The AI doc review loop, on the go.
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<img src="https://raw.githubusercontent.com/lucassmatos/pocketspec/main/docs/demo.gif" alt="pocketspec demo: read a spec on your phone, tap a paragraph to comment, and the agent reads the comment back" width="320">
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx pocketspec ~/path/to/docs
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Starts a server on your local network and prints the address (e.g. `http://192.168.1.x:4321`) to open on your phone. Multiple folders:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx pocketspec ~/project-a/docs ~/project-b/specs
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
No install — `npx` fetches and runs it.
|
|
24
|
+
|
|
25
|
+
## The comment loop
|
|
26
|
+
|
|
27
|
+
- **Comment a passage:** tap any paragraph/block → a comment box opens → it shows up below the block with a bar marking the passage.
|
|
28
|
+
- **General comment:** the floating 💬 button.
|
|
29
|
+
- **Edit the doc:** the ✏️ button at the top opens the raw markdown; saving writes to the file.
|
|
30
|
+
- Comments live in a sidecar `file.md.comments` (JSON) next to the `.md`. Easy for an agent to read: each comment stores the anchored passage, the text, and a timestamp. If the passage is later edited, the comment isn't lost — it moves to "General comments" with a reference to the original text.
|
|
31
|
+
|
|
32
|
+
## Options
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx pocketspec ~/docs --port 8080 # starting port (tries the next free one if taken)
|
|
36
|
+
npx pocketspec ~/docs --read-only # read-only: no editing, no commenting
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Persistent folders (instead of passing paths every time):
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx pocketspec add ~/docs "My project" # register
|
|
43
|
+
npx pocketspec list # list
|
|
44
|
+
npx pocketspec # serve the registered ones
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Security — read this
|
|
48
|
+
|
|
49
|
+
pocketspec has **no authentication** and, by default, exposes write endpoints (edit file, comment) on your local network. That's by design: the point is reading from your phone on the same Wi-Fi.
|
|
50
|
+
|
|
51
|
+
- **Use it only on a trusted network** (your home, not a coffee-shop Wi-Fi).
|
|
52
|
+
- Want read-only, no write risk? Use `--read-only`.
|
|
53
|
+
- **Want access from outside your network?** Do NOT expose this to the open internet. Use a peer-to-peer VPN like [Tailscale](https://tailscale.com): install it on your laptop and phone, then reach it via the Tailscale IP from anywhere — with nothing publicly exposed.
|
|
54
|
+
|
|
55
|
+
## How it works
|
|
56
|
+
|
|
57
|
+
- Zero npm dependencies. Node stdlib on the server; [`marked`](https://github.com/markedjs/marked) (MIT) vendored in `public/marked.min.js` rendering in the browser, so it works even with no internet on your phone.
|
|
58
|
+
- Lists only folders and `.md` files (dotfiles ignored). Images referenced by docs are served via `/api/raw`.
|
|
59
|
+
- Hash-based navigation (`#/0/folder/doc.md`), so your phone's back button works. PWA: you can "Add to Home Screen" and it opens as an app.
|
|
60
|
+
- Path-traversal protection: it never serves anything outside the folders you pass.
|
|
61
|
+
|
|
62
|
+
## Run from source (dev)
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
node server.js ~/docs # same as npx, from the clone
|
|
66
|
+
node server.js --help
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT. `marked` is also MIT (its license is kept in the vendored file).
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pocketspec",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Read your markdown docs on your phone over the local network, comment by tapping a paragraph, and let your AI agent read the comments back.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"pocketspec": "server.js"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/lucassmatos/pocketspec.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/lucassmatos/pocketspec#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/lucassmatos/pocketspec/issues"
|
|
15
|
+
},
|
|
16
|
+
"author": "Lucas Monteiro",
|
|
17
|
+
"files": [
|
|
18
|
+
"server.js",
|
|
19
|
+
"public/",
|
|
20
|
+
"CLAUDE.md"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"keywords": [
|
|
27
|
+
"markdown",
|
|
28
|
+
"docs",
|
|
29
|
+
"mobile",
|
|
30
|
+
"lan",
|
|
31
|
+
"ai",
|
|
32
|
+
"claude",
|
|
33
|
+
"comments",
|
|
34
|
+
"review",
|
|
35
|
+
"specs"
|
|
36
|
+
]
|
|
37
|
+
}
|
package/public/app.js
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const contentEl = document.getElementById('content');
|
|
4
|
+
const breadcrumbEl = document.getElementById('breadcrumb');
|
|
5
|
+
const actionsEl = document.getElementById('actions');
|
|
6
|
+
|
|
7
|
+
// Routes (hash-based, so the browser back button works):
|
|
8
|
+
// #/ → list of registered roots
|
|
9
|
+
// #/0 → root 0, top folder
|
|
10
|
+
// #/0/sub/dir → folder inside root 0
|
|
11
|
+
// #/0/sub/x.md → rendered document
|
|
12
|
+
|
|
13
|
+
function parseHash() {
|
|
14
|
+
const hash = decodeURIComponent(location.hash.replace(/^#\/?/, ''));
|
|
15
|
+
if (!hash) return { root: null, path: '' };
|
|
16
|
+
const [rootStr, ...rest] = hash.split('/');
|
|
17
|
+
return { root: Number(rootStr), path: rest.join('/') };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function hashFor(root, relPath) {
|
|
21
|
+
const suffix = relPath ? '/' + relPath.split('/').map(encodeURIComponent).join('/') : '';
|
|
22
|
+
return `#/${root}${suffix}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function fetchJson(url, options) {
|
|
26
|
+
const res = await fetch(url, options);
|
|
27
|
+
if (!res.ok) throw new Error(await res.text());
|
|
28
|
+
return res.json();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let rootsCache = null;
|
|
32
|
+
async function getRoots() {
|
|
33
|
+
if (!rootsCache) rootsCache = await fetchJson('/api/roots');
|
|
34
|
+
return rootsCache;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Server tells us whether writes are allowed (--read-only). Cached once.
|
|
38
|
+
let metaCache = null;
|
|
39
|
+
async function getMeta() {
|
|
40
|
+
if (!metaCache) {
|
|
41
|
+
try { metaCache = await fetchJson('/api/meta'); }
|
|
42
|
+
catch { metaCache = { readOnly: false }; }
|
|
43
|
+
}
|
|
44
|
+
return metaCache;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// current doc view state (used by comment + edit handlers)
|
|
48
|
+
let current = { root: null, path: '' };
|
|
49
|
+
|
|
50
|
+
// ---------- breadcrumb ----------
|
|
51
|
+
|
|
52
|
+
function renderBreadcrumb(rootName, root, relPath, isDoc) {
|
|
53
|
+
breadcrumbEl.innerHTML = '';
|
|
54
|
+
const add = (label, href) => {
|
|
55
|
+
if (breadcrumbEl.childNodes.length) {
|
|
56
|
+
const sep = document.createElement('span');
|
|
57
|
+
sep.className = 'sep';
|
|
58
|
+
sep.textContent = '›';
|
|
59
|
+
breadcrumbEl.appendChild(sep);
|
|
60
|
+
}
|
|
61
|
+
if (href !== null) {
|
|
62
|
+
const a = document.createElement('a');
|
|
63
|
+
a.href = href;
|
|
64
|
+
a.textContent = label;
|
|
65
|
+
breadcrumbEl.appendChild(a);
|
|
66
|
+
} else {
|
|
67
|
+
const span = document.createElement('span');
|
|
68
|
+
span.className = 'current';
|
|
69
|
+
span.textContent = label;
|
|
70
|
+
breadcrumbEl.appendChild(span);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
add('🏠', '#/');
|
|
75
|
+
if (root === null) return;
|
|
76
|
+
|
|
77
|
+
const parts = relPath ? relPath.split('/') : [];
|
|
78
|
+
const last = parts.length - 1;
|
|
79
|
+
add(rootName, parts.length ? hashFor(root, '') : null);
|
|
80
|
+
parts.forEach((part, i) => {
|
|
81
|
+
const label = isDoc && i === last ? part.replace(/\.md$/i, '') : part;
|
|
82
|
+
add(label, i === last ? null : hashFor(root, parts.slice(0, i + 1).join('/')));
|
|
83
|
+
});
|
|
84
|
+
// keep the tail of a long path visible
|
|
85
|
+
breadcrumbEl.scrollLeft = breadcrumbEl.scrollWidth;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------- listing views ----------
|
|
89
|
+
|
|
90
|
+
function listItem(href, icon, label, meta) {
|
|
91
|
+
const li = document.createElement('li');
|
|
92
|
+
const a = document.createElement('a');
|
|
93
|
+
a.href = href;
|
|
94
|
+
a.innerHTML = `<span class="icon">${icon}</span><span class="label"></span>` +
|
|
95
|
+
(meta ? `<span class="meta">${meta}</span>` : '');
|
|
96
|
+
a.querySelector('.label').textContent = label;
|
|
97
|
+
li.appendChild(a);
|
|
98
|
+
return li;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function renderHome() {
|
|
102
|
+
const roots = await getRoots();
|
|
103
|
+
renderBreadcrumb(null, null, '', false);
|
|
104
|
+
contentEl.innerHTML = '<h1 class="page-title">Documents</h1>';
|
|
105
|
+
if (!roots.length) {
|
|
106
|
+
contentEl.insertAdjacentHTML('beforeend',
|
|
107
|
+
'<p class="empty">No folders yet.<br>Use <code>pocketspec add <folder></code></p>');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const ul = document.createElement('ul');
|
|
111
|
+
ul.className = 'listing';
|
|
112
|
+
for (const root of roots) {
|
|
113
|
+
ul.appendChild(listItem(hashFor(root.id, ''), '📚', root.name, ''));
|
|
114
|
+
}
|
|
115
|
+
contentEl.appendChild(ul);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function formatSize(bytes) {
|
|
119
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
120
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
121
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function renderFolder(root, relPath) {
|
|
125
|
+
const roots = await getRoots();
|
|
126
|
+
const rootInfo = roots.find((r) => r.id === root);
|
|
127
|
+
if (!rootInfo) throw new Error('root not found');
|
|
128
|
+
const data = await fetchJson(`/api/list?root=${root}&path=${encodeURIComponent(relPath)}`);
|
|
129
|
+
renderBreadcrumb(rootInfo.name, root, relPath, false);
|
|
130
|
+
contentEl.innerHTML = '';
|
|
131
|
+
|
|
132
|
+
if (!data.dirs.length && !data.files.length) {
|
|
133
|
+
contentEl.innerHTML = '<p class="empty">Empty folder (no .md files)</p>';
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const ul = document.createElement('ul');
|
|
137
|
+
ul.className = 'listing';
|
|
138
|
+
const join = (name) => (relPath ? relPath + '/' + name : name);
|
|
139
|
+
for (const dir of data.dirs) {
|
|
140
|
+
ul.appendChild(listItem(hashFor(root, join(dir)), '📁', dir, ''));
|
|
141
|
+
}
|
|
142
|
+
for (const file of data.files) {
|
|
143
|
+
ul.appendChild(listItem(hashFor(root, join(file.name)), '📄',
|
|
144
|
+
file.name.replace(/\.md$/i, ''), formatSize(file.size)));
|
|
145
|
+
}
|
|
146
|
+
contentEl.appendChild(ul);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------- document view ----------
|
|
150
|
+
|
|
151
|
+
async function renderDoc(root, relPath) {
|
|
152
|
+
const roots = await getRoots();
|
|
153
|
+
const rootInfo = roots.find((r) => r.id === root);
|
|
154
|
+
if (!rootInfo) throw new Error('root not found');
|
|
155
|
+
const res = await fetch(`/api/doc?root=${root}&path=${encodeURIComponent(relPath)}`);
|
|
156
|
+
if (!res.ok) throw new Error(await res.text());
|
|
157
|
+
const md = await res.text();
|
|
158
|
+
renderBreadcrumb(rootInfo.name, root, relPath, true);
|
|
159
|
+
current = { root, path: relPath };
|
|
160
|
+
|
|
161
|
+
const doc = document.createElement('article');
|
|
162
|
+
doc.className = 'doc';
|
|
163
|
+
doc.innerHTML = marked.parse(md);
|
|
164
|
+
|
|
165
|
+
// Rewrite relative links: .md links navigate in-app, other assets go through /api/raw
|
|
166
|
+
const docDir = relPath.split('/').slice(0, -1).join('/');
|
|
167
|
+
const resolveRel = (href) => {
|
|
168
|
+
const stack = docDir ? docDir.split('/') : [];
|
|
169
|
+
for (const part of href.split('/')) {
|
|
170
|
+
if (part === '' || part === '.') continue;
|
|
171
|
+
else if (part === '..') stack.pop();
|
|
172
|
+
else stack.push(part);
|
|
173
|
+
}
|
|
174
|
+
return stack.join('/');
|
|
175
|
+
};
|
|
176
|
+
const isExternal = (href) => /^([a-z]+:|\/|#)/i.test(href);
|
|
177
|
+
|
|
178
|
+
for (const a of doc.querySelectorAll('a[href]')) {
|
|
179
|
+
const href = a.getAttribute('href');
|
|
180
|
+
if (isExternal(href)) continue;
|
|
181
|
+
const [target, anchor] = href.split('#');
|
|
182
|
+
if (/\.md$/i.test(target)) {
|
|
183
|
+
a.href = hashFor(root, resolveRel(target)) + (anchor ? '#' + anchor : '');
|
|
184
|
+
} else if (target) {
|
|
185
|
+
a.href = `/api/raw?root=${root}&path=${encodeURIComponent(resolveRel(target))}`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (const img of doc.querySelectorAll('img[src]')) {
|
|
189
|
+
const src = img.getAttribute('src');
|
|
190
|
+
if (isExternal(src)) continue;
|
|
191
|
+
img.src = `/api/raw?root=${root}&path=${encodeURIComponent(resolveRel(src))}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const meta = await getMeta();
|
|
195
|
+
|
|
196
|
+
// each top-level element is a commentable block (tap to comment, unless read-only)
|
|
197
|
+
[...doc.children].forEach((el, i) => el.setAttribute('data-bi', i));
|
|
198
|
+
if (!meta.readOnly) doc.addEventListener('click', onBlockTap);
|
|
199
|
+
|
|
200
|
+
contentEl.innerHTML = '';
|
|
201
|
+
contentEl.appendChild(doc);
|
|
202
|
+
|
|
203
|
+
const general = document.createElement('section');
|
|
204
|
+
general.id = 'general-comments';
|
|
205
|
+
contentEl.appendChild(general);
|
|
206
|
+
|
|
207
|
+
renderActions(!meta.readOnly);
|
|
208
|
+
if (!meta.readOnly) ensureFab();
|
|
209
|
+
await refreshComments();
|
|
210
|
+
window.scrollTo(0, 0);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------- comments ----------
|
|
214
|
+
|
|
215
|
+
const SNIPPET_LEN = 80;
|
|
216
|
+
const blockSnippet = (el) => el.textContent.trim().slice(0, SNIPPET_LEN);
|
|
217
|
+
|
|
218
|
+
function commentsUrl(extra) {
|
|
219
|
+
return `/api/comments?root=${current.root}&path=${encodeURIComponent(current.path)}` + (extra || '');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function resolveAnchor(blocks, anchor) {
|
|
223
|
+
if (!anchor) return null;
|
|
224
|
+
const byIndex = blocks[anchor.index];
|
|
225
|
+
if (byIndex && blockSnippet(byIndex) === anchor.snippet) return byIndex;
|
|
226
|
+
return blocks.find((b) => blockSnippet(b) === anchor.snippet) || null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function formatTime(ts) {
|
|
230
|
+
const d = new Date(ts);
|
|
231
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
232
|
+
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function commentBubble(comment, note) {
|
|
236
|
+
const div = document.createElement('div');
|
|
237
|
+
div.className = 'comment-bubble';
|
|
238
|
+
const body = document.createElement('div');
|
|
239
|
+
body.className = 'comment-text';
|
|
240
|
+
body.textContent = comment.text;
|
|
241
|
+
const meta = document.createElement('div');
|
|
242
|
+
meta.className = 'comment-meta';
|
|
243
|
+
meta.textContent = '💬 ' + formatTime(comment.ts) + (note ? ` · ${note}` : '');
|
|
244
|
+
if (!(metaCache && metaCache.readOnly)) {
|
|
245
|
+
const del = document.createElement('button');
|
|
246
|
+
del.className = 'comment-delete';
|
|
247
|
+
del.textContent = '✕';
|
|
248
|
+
del.addEventListener('click', async () => {
|
|
249
|
+
if (!confirm('Delete this comment?')) return;
|
|
250
|
+
await fetchJson(commentsUrl(`&id=${comment.id}`), { method: 'DELETE' });
|
|
251
|
+
await refreshComments();
|
|
252
|
+
});
|
|
253
|
+
meta.appendChild(del);
|
|
254
|
+
}
|
|
255
|
+
div.appendChild(body);
|
|
256
|
+
div.appendChild(meta);
|
|
257
|
+
return div;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function refreshComments() {
|
|
261
|
+
const doc = contentEl.querySelector('article.doc');
|
|
262
|
+
const general = document.getElementById('general-comments');
|
|
263
|
+
if (!doc || !general) return;
|
|
264
|
+
|
|
265
|
+
const data = await fetchJson(commentsUrl());
|
|
266
|
+
doc.querySelectorAll('.comment-thread').forEach((el) => el.remove());
|
|
267
|
+
doc.querySelectorAll('.has-comments').forEach((el) => el.classList.remove('has-comments'));
|
|
268
|
+
general.innerHTML = '';
|
|
269
|
+
|
|
270
|
+
const blocks = [...doc.children].filter((el) => el.hasAttribute('data-bi'));
|
|
271
|
+
const orphans = [];
|
|
272
|
+
const generals = [];
|
|
273
|
+
|
|
274
|
+
for (const comment of data.comments) {
|
|
275
|
+
const block = resolveAnchor(blocks, comment.anchor);
|
|
276
|
+
if (!comment.anchor) generals.push(comment);
|
|
277
|
+
else if (!block) orphans.push(comment);
|
|
278
|
+
else {
|
|
279
|
+
let thread = block.nextElementSibling;
|
|
280
|
+
if (!thread || !thread.classList.contains('comment-thread')) {
|
|
281
|
+
thread = document.createElement('div');
|
|
282
|
+
thread.className = 'comment-thread';
|
|
283
|
+
block.after(thread);
|
|
284
|
+
}
|
|
285
|
+
block.classList.add('has-comments');
|
|
286
|
+
thread.appendChild(commentBubble(comment));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (generals.length || orphans.length) {
|
|
291
|
+
const h = document.createElement('h2');
|
|
292
|
+
h.className = 'general-comments-title';
|
|
293
|
+
h.textContent = 'General comments';
|
|
294
|
+
general.appendChild(h);
|
|
295
|
+
for (const c of generals) general.appendChild(commentBubble(c));
|
|
296
|
+
for (const c of orphans) {
|
|
297
|
+
general.appendChild(commentBubble(c, `on: “${c.anchor.snippet.slice(0, 40)}…”`));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------- comment sheet ----------
|
|
303
|
+
|
|
304
|
+
let sheet = null;
|
|
305
|
+
let pendingAnchor = null;
|
|
306
|
+
let selectedBlock = null;
|
|
307
|
+
|
|
308
|
+
// In iOS standalone (home-screen) mode, programmatic focus() marks the field
|
|
309
|
+
// as focused WITHOUT showing the keyboard — and a later tap on the already-
|
|
310
|
+
// focused field won't re-trigger focus, so the keyboard never appears.
|
|
311
|
+
// Let the user's own tap do the focusing there.
|
|
312
|
+
const isStandalone = window.navigator.standalone === true ||
|
|
313
|
+
window.matchMedia('(display-mode: standalone)').matches;
|
|
314
|
+
|
|
315
|
+
// Keep the sheet above the iOS keyboard (fixed elements anchor to the layout
|
|
316
|
+
// viewport, which the keyboard does not always resize).
|
|
317
|
+
function adjustSheetForKeyboard() {
|
|
318
|
+
if (!sheet || sheet.hidden) return;
|
|
319
|
+
const vv = window.visualViewport;
|
|
320
|
+
const offset = vv ? Math.max(0, window.innerHeight - vv.height - vv.offsetTop) : 0;
|
|
321
|
+
sheet.style.transform = offset > 0 ? `translateY(-${offset}px)` : '';
|
|
322
|
+
}
|
|
323
|
+
if (window.visualViewport) {
|
|
324
|
+
window.visualViewport.addEventListener('resize', adjustSheetForKeyboard);
|
|
325
|
+
window.visualViewport.addEventListener('scroll', adjustSheetForKeyboard);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function ensureSheet() {
|
|
329
|
+
if (sheet) return sheet;
|
|
330
|
+
sheet = document.createElement('div');
|
|
331
|
+
sheet.id = 'sheet';
|
|
332
|
+
sheet.hidden = true;
|
|
333
|
+
sheet.innerHTML = `
|
|
334
|
+
<div class="sheet-context" id="sheet-context"></div>
|
|
335
|
+
<textarea id="sheet-text" rows="3" placeholder="Write your comment…"></textarea>
|
|
336
|
+
<div class="sheet-buttons">
|
|
337
|
+
<button class="btn" id="sheet-cancel">Cancel</button>
|
|
338
|
+
<button class="btn primary" id="sheet-send">Comment</button>
|
|
339
|
+
</div>`;
|
|
340
|
+
document.body.appendChild(sheet);
|
|
341
|
+
sheet.querySelector('#sheet-cancel').addEventListener('click', closeSheet);
|
|
342
|
+
sheet.querySelector('#sheet-send').addEventListener('click', async () => {
|
|
343
|
+
const textarea = sheet.querySelector('#sheet-text');
|
|
344
|
+
const text = textarea.value.trim();
|
|
345
|
+
if (!text) return;
|
|
346
|
+
await fetchJson(commentsUrl(), {
|
|
347
|
+
method: 'POST',
|
|
348
|
+
headers: { 'Content-Type': 'application/json' },
|
|
349
|
+
body: JSON.stringify({ text, anchor: pendingAnchor }),
|
|
350
|
+
});
|
|
351
|
+
textarea.value = '';
|
|
352
|
+
closeSheet();
|
|
353
|
+
await refreshComments();
|
|
354
|
+
});
|
|
355
|
+
return sheet;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function openSheet(anchor, contextLabel) {
|
|
359
|
+
ensureSheet();
|
|
360
|
+
pendingAnchor = anchor;
|
|
361
|
+
sheet.querySelector('#sheet-context').textContent = contextLabel;
|
|
362
|
+
sheet.hidden = false;
|
|
363
|
+
if (!isStandalone) sheet.querySelector('#sheet-text').focus();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function closeSheet() {
|
|
367
|
+
if (sheet) {
|
|
368
|
+
sheet.querySelector('#sheet-text').blur();
|
|
369
|
+
sheet.style.transform = '';
|
|
370
|
+
sheet.hidden = true;
|
|
371
|
+
}
|
|
372
|
+
pendingAnchor = null;
|
|
373
|
+
if (selectedBlock) {
|
|
374
|
+
selectedBlock.classList.remove('block-selected');
|
|
375
|
+
selectedBlock = null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function onBlockTap(e) {
|
|
380
|
+
if (e.target.closest('a, .comment-thread, input, button')) return;
|
|
381
|
+
const block = e.target.closest('[data-bi]');
|
|
382
|
+
if (!block) return;
|
|
383
|
+
if (selectedBlock) selectedBlock.classList.remove('block-selected');
|
|
384
|
+
selectedBlock = block;
|
|
385
|
+
block.classList.add('block-selected');
|
|
386
|
+
const snippet = blockSnippet(block);
|
|
387
|
+
openSheet(
|
|
388
|
+
{ index: Number(block.getAttribute('data-bi')), snippet },
|
|
389
|
+
`Commenting on: “${snippet.slice(0, 60)}${snippet.length > 60 ? '…' : ''}”`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---------- floating button + topbar actions ----------
|
|
394
|
+
|
|
395
|
+
let fab = null;
|
|
396
|
+
function ensureFab() {
|
|
397
|
+
if (fab) { fab.hidden = false; return; }
|
|
398
|
+
fab = document.createElement('button');
|
|
399
|
+
fab.id = 'fab';
|
|
400
|
+
fab.title = 'General comment';
|
|
401
|
+
fab.textContent = '💬';
|
|
402
|
+
fab.addEventListener('click', () => openSheet(null, 'General comment on the document'));
|
|
403
|
+
document.body.appendChild(fab);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function hideDocChrome() {
|
|
407
|
+
if (fab) fab.hidden = true;
|
|
408
|
+
closeSheet();
|
|
409
|
+
actionsEl.innerHTML = '';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function renderActions(isDoc) {
|
|
413
|
+
actionsEl.innerHTML = '';
|
|
414
|
+
if (!isDoc) return;
|
|
415
|
+
const edit = document.createElement('button');
|
|
416
|
+
edit.className = 'topbtn';
|
|
417
|
+
edit.title = 'Edit document';
|
|
418
|
+
edit.textContent = '✏️';
|
|
419
|
+
edit.addEventListener('click', openEditor);
|
|
420
|
+
actionsEl.appendChild(edit);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ---------- editor ----------
|
|
424
|
+
|
|
425
|
+
async function openEditor() {
|
|
426
|
+
const { root, path: relPath } = current;
|
|
427
|
+
const res = await fetch(`/api/doc?root=${root}&path=${encodeURIComponent(relPath)}`);
|
|
428
|
+
if (!res.ok) { alert(await res.text()); return; }
|
|
429
|
+
const md = await res.text();
|
|
430
|
+
|
|
431
|
+
hideDocChrome();
|
|
432
|
+
contentEl.innerHTML = '';
|
|
433
|
+
|
|
434
|
+
const textarea = document.createElement('textarea');
|
|
435
|
+
textarea.id = 'editor';
|
|
436
|
+
textarea.value = md;
|
|
437
|
+
|
|
438
|
+
// buttons live in the sticky topbar so they never scroll out of view
|
|
439
|
+
const cancel = document.createElement('button');
|
|
440
|
+
cancel.className = 'btn';
|
|
441
|
+
cancel.textContent = 'Cancel';
|
|
442
|
+
cancel.addEventListener('click', route);
|
|
443
|
+
const save = document.createElement('button');
|
|
444
|
+
save.className = 'btn primary';
|
|
445
|
+
save.textContent = 'Save';
|
|
446
|
+
save.addEventListener('click', async () => {
|
|
447
|
+
save.disabled = true;
|
|
448
|
+
save.textContent = 'Saving…';
|
|
449
|
+
try {
|
|
450
|
+
await fetchJson(`/api/save?root=${root}&path=${encodeURIComponent(relPath)}`, {
|
|
451
|
+
method: 'POST',
|
|
452
|
+
headers: { 'Content-Type': 'application/json' },
|
|
453
|
+
body: JSON.stringify({ content: textarea.value }),
|
|
454
|
+
});
|
|
455
|
+
await route();
|
|
456
|
+
} catch (err) {
|
|
457
|
+
alert('Error saving: ' + err.message);
|
|
458
|
+
save.disabled = false;
|
|
459
|
+
save.textContent = 'Save';
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
actionsEl.appendChild(cancel);
|
|
463
|
+
actionsEl.appendChild(save);
|
|
464
|
+
|
|
465
|
+
contentEl.appendChild(textarea);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ---------- router ----------
|
|
469
|
+
|
|
470
|
+
async function route() {
|
|
471
|
+
const { root, path: relPath } = parseHash();
|
|
472
|
+
const isDoc = root !== null && !Number.isNaN(root) && /\.md$/i.test(relPath);
|
|
473
|
+
if (!isDoc) hideDocChrome();
|
|
474
|
+
try {
|
|
475
|
+
if (root === null || Number.isNaN(root)) await renderHome();
|
|
476
|
+
else if (isDoc) await renderDoc(root, relPath);
|
|
477
|
+
else await renderFolder(root, relPath);
|
|
478
|
+
} catch (err) {
|
|
479
|
+
contentEl.innerHTML = '';
|
|
480
|
+
const p = document.createElement('p');
|
|
481
|
+
p.className = 'error';
|
|
482
|
+
p.textContent = 'Error: ' + err.message;
|
|
483
|
+
contentEl.appendChild(p);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
window.addEventListener('hashchange', route);
|
|
488
|
+
route();
|