@noy4/docserve 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/bin.mjs +217 -0
- package/index.html +343 -0
- package/package.json +37 -0
- package/server/files.mjs +189 -0
- package/server/index.mjs +168 -0
- package/server/inject.mjs +100 -0
- package/server/state.mjs +61 -0
- package/server/watch.mjs +86 -0
- package/server/websocket.mjs +81 -0
package/bin.mjs
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// docserve — serve a folder of HTML as a card gallery with live reload.
|
|
3
|
+
//
|
|
4
|
+
// docserve [dir] [command] [options]
|
|
5
|
+
//
|
|
6
|
+
// Flow:
|
|
7
|
+
// parse args ──▶ stop / status ──▶ state.json
|
|
8
|
+
// └─ start
|
|
9
|
+
// ├─ single-instance check (readLiveState)
|
|
10
|
+
// ├─ --background ──▶ spawn detached ──▶ wait for state.json
|
|
11
|
+
// └─ foreground ──▶ runServer() (writes state.json on bind)
|
|
12
|
+
import fs from "node:fs"
|
|
13
|
+
import path from "node:path"
|
|
14
|
+
import { exec, spawn } from "node:child_process"
|
|
15
|
+
import { fileURLToPath } from "node:url"
|
|
16
|
+
import { parseArgs } from "node:util"
|
|
17
|
+
import { runServer } from "./server/index.mjs"
|
|
18
|
+
import { clearState, readLiveState } from "./server/state.mjs"
|
|
19
|
+
|
|
20
|
+
const pkg = JSON.parse(fs.readFileSync(new URL("./package.json", import.meta.url), "utf8"))
|
|
21
|
+
const VERSION = pkg.version
|
|
22
|
+
const DEFAULT_PORT = 4242
|
|
23
|
+
const SUBCOMMANDS = ["stop", "status"]
|
|
24
|
+
|
|
25
|
+
const HELP = `
|
|
26
|
+
docserve v${VERSION} — serve a folder of HTML as a card gallery with live reload.
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
docserve [dir] [command] [options]
|
|
30
|
+
|
|
31
|
+
Arguments:
|
|
32
|
+
dir Folder of HTML files to serve (default: current directory)
|
|
33
|
+
|
|
34
|
+
Commands:
|
|
35
|
+
stop Stop the running docserve server
|
|
36
|
+
status Show whether a server is running (exit 1 if none)
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
--open Open the gallery in the browser once the port is bound
|
|
40
|
+
--port <number> Port to bind (default: ${DEFAULT_PORT}); falls back +1 up to 20 tries
|
|
41
|
+
--background Start detached; the bound port and pid land in state.json
|
|
42
|
+
-h, --help Show this help
|
|
43
|
+
-v, --version Show version
|
|
44
|
+
`
|
|
45
|
+
|
|
46
|
+
async function main() {
|
|
47
|
+
const args = parseCliArgs()
|
|
48
|
+
if (args.command === "stop") return stopServer()
|
|
49
|
+
if (args.command === "status") return showStatus()
|
|
50
|
+
return startServer(args)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseCliArgs() {
|
|
54
|
+
let parsed
|
|
55
|
+
try {
|
|
56
|
+
parsed = parseArgs({
|
|
57
|
+
allowPositionals: true,
|
|
58
|
+
options: {
|
|
59
|
+
open: { type: "boolean" },
|
|
60
|
+
port: { type: "string" },
|
|
61
|
+
background: { type: "boolean" },
|
|
62
|
+
help: { type: "boolean", short: "h" },
|
|
63
|
+
version: { type: "boolean", short: "v" },
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.error(`[docserve] ${err.message}`)
|
|
68
|
+
console.error(HELP)
|
|
69
|
+
process.exit(1)
|
|
70
|
+
}
|
|
71
|
+
if (parsed.values.help) {
|
|
72
|
+
console.log(HELP)
|
|
73
|
+
process.exit(0)
|
|
74
|
+
}
|
|
75
|
+
if (parsed.values.version) {
|
|
76
|
+
console.log(VERSION)
|
|
77
|
+
process.exit(0)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let dir = null
|
|
81
|
+
let command = null
|
|
82
|
+
for (const positional of parsed.positionals) {
|
|
83
|
+
if (dir === null && command === null && SUBCOMMANDS.includes(positional)) {
|
|
84
|
+
command = positional
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
if (dir === null) {
|
|
88
|
+
dir = positional
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
if (command === null && SUBCOMMANDS.includes(positional)) {
|
|
92
|
+
command = positional
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
console.error(`[docserve] Unexpected argument "${positional}".`)
|
|
96
|
+
process.exit(1)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let port = DEFAULT_PORT
|
|
100
|
+
if (parsed.values.port !== undefined) {
|
|
101
|
+
port = Number(parsed.values.port)
|
|
102
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
103
|
+
console.error(`[docserve] --port expects an integer between 1 and 65535, got "${parsed.values.port}"`)
|
|
104
|
+
process.exit(1)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
dir,
|
|
110
|
+
command,
|
|
111
|
+
port,
|
|
112
|
+
open: parsed.values.open ?? false,
|
|
113
|
+
background: parsed.values.background ?? false,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function stopServer() {
|
|
118
|
+
const state = readLiveState() // stale state (dead pid) is removed silently
|
|
119
|
+
if (!state) {
|
|
120
|
+
console.log("docserve is not running.")
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
process.kill(state.pid, "SIGTERM")
|
|
125
|
+
} catch {}
|
|
126
|
+
clearState()
|
|
127
|
+
console.log(`Stopped docserve (${state.url})`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function showStatus() {
|
|
131
|
+
const state = readLiveState()
|
|
132
|
+
if (!state) {
|
|
133
|
+
console.error("docserve is not running.")
|
|
134
|
+
process.exit(1)
|
|
135
|
+
}
|
|
136
|
+
console.log(state.url)
|
|
137
|
+
console.log(`docs : ${state.docsDir}`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function startServer(args) {
|
|
141
|
+
const docsDir = path.resolve(args.dir ?? ".")
|
|
142
|
+
if (!isDirectory(docsDir)) {
|
|
143
|
+
console.error(`[docserve] Directory not found: ${docsDir}`)
|
|
144
|
+
process.exit(1)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Single instance: same docsDir just opens its URL; a different docsDir
|
|
148
|
+
// requires docserve stop first.
|
|
149
|
+
const running = readLiveState()
|
|
150
|
+
if (running) {
|
|
151
|
+
if (running.docsDir === docsDir) {
|
|
152
|
+
if (args.open) openBrowser(running.url)
|
|
153
|
+
console.log(`docserve is already serving this folder: ${running.url}`)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
console.error([
|
|
157
|
+
"[docserve] A server is already running for a different folder.",
|
|
158
|
+
` url : ${running.url}`,
|
|
159
|
+
` docs : ${running.docsDir}`,
|
|
160
|
+
"Stop it first: docserve stop",
|
|
161
|
+
].join("\n"))
|
|
162
|
+
process.exit(1)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (args.background) return startBackground(args, docsDir)
|
|
166
|
+
|
|
167
|
+
console.log(`docserve v${pkg.version}`)
|
|
168
|
+
console.log(`docs : ${docsDir}`)
|
|
169
|
+
await runServer({ docsDir, port: args.port, open: args.open })
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function startBackground(args, docsDir) {
|
|
173
|
+
const entry = fileURLToPath(import.meta.url)
|
|
174
|
+
const childArgs = [entry, docsDir]
|
|
175
|
+
if (args.open) childArgs.push("--open")
|
|
176
|
+
if (args.port !== DEFAULT_PORT) childArgs.push("--port", String(args.port))
|
|
177
|
+
const child = spawn(process.execPath, childArgs, { detached: true, stdio: "ignore" })
|
|
178
|
+
child.unref()
|
|
179
|
+
|
|
180
|
+
const state = await waitForState()
|
|
181
|
+
if (!state) {
|
|
182
|
+
console.error("[docserve] Server did not report startup (no state file). Try running in the foreground.")
|
|
183
|
+
process.exit(1)
|
|
184
|
+
}
|
|
185
|
+
console.log("docserve started in the background")
|
|
186
|
+
console.log(` pid : ${state.pid}`)
|
|
187
|
+
console.log(` url : ${state.url}`)
|
|
188
|
+
console.log(` docs : ${state.docsDir}`)
|
|
189
|
+
console.log(" stop : docserve stop")
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function waitForState({ timeoutMs = 10000, intervalMs = 200 } = {}) {
|
|
193
|
+
const deadline = Date.now() + timeoutMs
|
|
194
|
+
while (Date.now() < deadline) {
|
|
195
|
+
const state = readLiveState()
|
|
196
|
+
if (state) return state
|
|
197
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs))
|
|
198
|
+
}
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function openBrowser(url) {
|
|
203
|
+
if (process.platform === "darwin") exec(`open ${url}`)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isDirectory(path) {
|
|
207
|
+
try {
|
|
208
|
+
return fs.statSync(path).isDirectory()
|
|
209
|
+
} catch {
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
main().catch((err) => {
|
|
215
|
+
console.error(err?.message ?? err)
|
|
216
|
+
process.exit(1)
|
|
217
|
+
})
|
package/index.html
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>docserve</title>
|
|
7
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236c8cff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z'/%3E%3Cpath d='M14 2v6h6'/%3E%3Cpath d='M8 13h8M8 17h5'/%3E%3C/svg%3E">
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #0f1117;
|
|
11
|
+
--card: #1a1d27;
|
|
12
|
+
--border: #2a2e3d;
|
|
13
|
+
--text: #e6e8ee;
|
|
14
|
+
--muted: #9aa0b0;
|
|
15
|
+
--accent: #6c8cff;
|
|
16
|
+
--green: #4ade80;
|
|
17
|
+
--btn-bg: rgba(15, 17, 23, 0.82);
|
|
18
|
+
}
|
|
19
|
+
@media (prefers-color-scheme: light) {
|
|
20
|
+
:root {
|
|
21
|
+
--bg: #f8fafc;
|
|
22
|
+
--card: #ffffff;
|
|
23
|
+
--border: #e2e8f0;
|
|
24
|
+
--text: #334155;
|
|
25
|
+
--muted: #64748b;
|
|
26
|
+
--accent: #2563eb;
|
|
27
|
+
--green: #16a34a;
|
|
28
|
+
--btn-bg: rgba(255, 255, 255, 0.85);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
33
|
+
|
|
34
|
+
body {
|
|
35
|
+
background: var(--bg);
|
|
36
|
+
color: var(--text);
|
|
37
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Noto Sans JP", sans-serif;
|
|
38
|
+
padding: 40px 24px;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
h1 {
|
|
42
|
+
display: flex;
|
|
43
|
+
align-items: center;
|
|
44
|
+
gap: 10px;
|
|
45
|
+
font-size: 1.6rem;
|
|
46
|
+
margin-bottom: 8px;
|
|
47
|
+
}
|
|
48
|
+
h1 svg { width: 26px; height: 26px; color: var(--accent); flex-shrink: 0; }
|
|
49
|
+
.sub { color: var(--muted); margin-bottom: 32px; font-size: 0.9rem; }
|
|
50
|
+
.sub b { color: var(--green); }
|
|
51
|
+
|
|
52
|
+
.sub code {
|
|
53
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
54
|
+
font-size: 0.78rem;
|
|
55
|
+
background: var(--card);
|
|
56
|
+
border: 1px solid var(--border);
|
|
57
|
+
border-radius: 6px;
|
|
58
|
+
padding: 2px 8px;
|
|
59
|
+
word-break: break-all;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.grid {
|
|
63
|
+
display: grid;
|
|
64
|
+
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
|
65
|
+
gap: 16px;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.card {
|
|
69
|
+
display: flex;
|
|
70
|
+
flex-direction: column;
|
|
71
|
+
overflow: hidden;
|
|
72
|
+
background: var(--card);
|
|
73
|
+
border: 1px solid var(--border);
|
|
74
|
+
border-radius: 12px;
|
|
75
|
+
color: inherit;
|
|
76
|
+
text-decoration: none;
|
|
77
|
+
transition: transform 0.15s ease, border-color 0.15s ease;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.card:hover { border-color: var(--accent); }
|
|
81
|
+
|
|
82
|
+
.preview {
|
|
83
|
+
position: relative;
|
|
84
|
+
height: 160px;
|
|
85
|
+
overflow: hidden;
|
|
86
|
+
background: #fff;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.preview iframe {
|
|
90
|
+
border: 0;
|
|
91
|
+
transform-origin: top left;
|
|
92
|
+
pointer-events: none;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.preview::after {
|
|
96
|
+
content: "";
|
|
97
|
+
position: absolute;
|
|
98
|
+
inset: 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.copy-btn {
|
|
102
|
+
position: absolute;
|
|
103
|
+
top: 8px;
|
|
104
|
+
right: 8px;
|
|
105
|
+
z-index: 1;
|
|
106
|
+
display: grid;
|
|
107
|
+
place-items: center;
|
|
108
|
+
width: 26px;
|
|
109
|
+
height: 26px;
|
|
110
|
+
padding: 0;
|
|
111
|
+
border: 1px solid var(--border);
|
|
112
|
+
border-radius: 7px;
|
|
113
|
+
background: var(--btn-bg);
|
|
114
|
+
color: var(--text);
|
|
115
|
+
cursor: pointer;
|
|
116
|
+
opacity: 0;
|
|
117
|
+
transition: opacity 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.copy-btn svg { width: 13px; height: 13px; }
|
|
121
|
+
.copy-btn .ic-check { display: none; }
|
|
122
|
+
.card:hover .copy-btn { opacity: 1; }
|
|
123
|
+
.copy-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
124
|
+
.copy-btn.copied { color: var(--green); border-color: var(--green); }
|
|
125
|
+
.copy-btn.copied .ic-copy { display: none; }
|
|
126
|
+
.copy-btn.copied .ic-check { display: block; }
|
|
127
|
+
|
|
128
|
+
@media (hover: none) {
|
|
129
|
+
.copy-btn { opacity: 0.7; }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.meta {
|
|
133
|
+
display: flex;
|
|
134
|
+
flex: 1;
|
|
135
|
+
flex-direction: column;
|
|
136
|
+
justify-content: space-between;
|
|
137
|
+
gap: 6px;
|
|
138
|
+
padding: 10px 12px;
|
|
139
|
+
border-top: 1px solid var(--border);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.meta .title { font-size: 0.85rem; font-weight: 600; line-height: 1.4; }
|
|
143
|
+
.date { font-size: 0.7rem; color: var(--muted); }
|
|
144
|
+
|
|
145
|
+
.empty {
|
|
146
|
+
max-width: 480px;
|
|
147
|
+
margin: 48px auto;
|
|
148
|
+
padding: 32px 24px;
|
|
149
|
+
border: 1px dashed var(--border);
|
|
150
|
+
border-radius: 12px;
|
|
151
|
+
color: var(--muted);
|
|
152
|
+
text-align: center;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.empty b { color: var(--text); }
|
|
156
|
+
</style>
|
|
157
|
+
</head>
|
|
158
|
+
|
|
159
|
+
<body>
|
|
160
|
+
<h1>
|
|
161
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8M8 17h5"/></svg>
|
|
162
|
+
docserve
|
|
163
|
+
</h1>
|
|
164
|
+
<p class="sub">
|
|
165
|
+
<code id="docs-dir">__DOCSERVE_DIR__</code> - <b id="total">0</b> files
|
|
166
|
+
</p>
|
|
167
|
+
|
|
168
|
+
<div class="grid" id="grid" hidden></div>
|
|
169
|
+
|
|
170
|
+
<div class="empty" id="empty" hidden>
|
|
171
|
+
<b>No HTML files found</b>
|
|
172
|
+
<p>Place .html files in <code id="empty-dir"></code> and they will appear here automatically</p>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<script type="module">
|
|
176
|
+
const VIEWPORT_W = 960
|
|
177
|
+
const VIEWPORT_H = 900
|
|
178
|
+
|
|
179
|
+
function el(tag, props = {}, children = []) {
|
|
180
|
+
const node = document.createElement(tag)
|
|
181
|
+
for (const [key, value] of Object.entries(props)) {
|
|
182
|
+
if (value == null || value === false) continue
|
|
183
|
+
if (key === "class") node.className = value
|
|
184
|
+
else if (key === "text") node.textContent = value
|
|
185
|
+
else if (key === "html") node.innerHTML = value
|
|
186
|
+
else if (key === "style" && typeof value === "object") Object.assign(node.style, value)
|
|
187
|
+
else if (key.startsWith("on") && typeof value === "function") node.addEventListener(key.slice(2).toLowerCase(), value)
|
|
188
|
+
else node.setAttribute(key, value === true ? "" : String(value))
|
|
189
|
+
}
|
|
190
|
+
for (const child of Array.isArray(children) ? children : [children]) {
|
|
191
|
+
if (child == null || child === false) continue
|
|
192
|
+
node.append(child)
|
|
193
|
+
}
|
|
194
|
+
return node
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function icon(paths, { width = 2, class: name = "" } = {}) {
|
|
198
|
+
return `<svg${name ? ` class="${name}"` : ""} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const COPY_SVG = icon('<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>', { class: "ic-copy" })
|
|
202
|
+
const CHECK_SVG = icon('<polyline points="20 6 9 17 4 12"/>', { class: "ic-check", width: 2.5 })
|
|
203
|
+
|
|
204
|
+
class PreviewLoader {
|
|
205
|
+
queue = []
|
|
206
|
+
active = 0
|
|
207
|
+
max = 3
|
|
208
|
+
observer
|
|
209
|
+
|
|
210
|
+
constructor({ max = 3, rootMargin = "400px" } = {}) {
|
|
211
|
+
this.max = max
|
|
212
|
+
this.observer = new IntersectionObserver((entries) => {
|
|
213
|
+
for (const entry of entries) {
|
|
214
|
+
if (!entry.isIntersecting) continue
|
|
215
|
+
this.observer.unobserve(entry.target)
|
|
216
|
+
const preview = entry.target
|
|
217
|
+
const iframe = preview.querySelector("iframe")
|
|
218
|
+
const src = iframe?.dataset.src
|
|
219
|
+
if (iframe && src) this.enqueue(iframe, src)
|
|
220
|
+
}
|
|
221
|
+
}, { rootMargin })
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
watch(preview) {
|
|
225
|
+
this.observer.observe(preview)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
enqueue(iframe, src) {
|
|
229
|
+
this.queue.push({ iframe, src })
|
|
230
|
+
this.pump()
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
pump() {
|
|
234
|
+
while (this.active < this.max && this.queue.length) {
|
|
235
|
+
const item = this.queue.shift()
|
|
236
|
+
if (!item) return
|
|
237
|
+
this.active++
|
|
238
|
+
item.iframe.addEventListener(
|
|
239
|
+
"load",
|
|
240
|
+
() => {
|
|
241
|
+
this.active--
|
|
242
|
+
this.pump()
|
|
243
|
+
},
|
|
244
|
+
{ once: true },
|
|
245
|
+
)
|
|
246
|
+
item.iframe.src = item.src
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const previews = new PreviewLoader()
|
|
252
|
+
|
|
253
|
+
function fitPreviews() {
|
|
254
|
+
for (const el of document.querySelectorAll(".preview")) {
|
|
255
|
+
const iframe = el.querySelector("iframe")
|
|
256
|
+
if (!iframe) continue
|
|
257
|
+
iframe.style.width = `${VIEWPORT_W}px`
|
|
258
|
+
iframe.style.height = `${VIEWPORT_H}px`
|
|
259
|
+
iframe.style.transform = `scale(${el.clientWidth / VIEWPORT_W})`
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function displayDate(value) {
|
|
264
|
+
const date = new Date(typeof value === "number" ? value * 1000 : value)
|
|
265
|
+
if (Number.isNaN(date.getTime())) return ""
|
|
266
|
+
return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")}`
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function card(file) {
|
|
270
|
+
return el("a", { class: "card", href: file.url, target: "_blank", rel: "noopener" }, [
|
|
271
|
+
el("div", { class: "preview" }, [
|
|
272
|
+
el("iframe", { tabindex: -1, title: file.title ?? "", "data-src": file.url }),
|
|
273
|
+
el("button", {
|
|
274
|
+
type: "button",
|
|
275
|
+
class: "copy-btn",
|
|
276
|
+
title: file.path,
|
|
277
|
+
"aria-label": "Copy file path",
|
|
278
|
+
html: COPY_SVG + CHECK_SVG,
|
|
279
|
+
onclick: async (e) => {
|
|
280
|
+
const btn = e.currentTarget
|
|
281
|
+
e.preventDefault()
|
|
282
|
+
e.stopPropagation()
|
|
283
|
+
try {
|
|
284
|
+
await navigator.clipboard.writeText(btn.title)
|
|
285
|
+
btn.classList.add("copied")
|
|
286
|
+
setTimeout(() => btn.classList.remove("copied"), 1200)
|
|
287
|
+
} catch {}
|
|
288
|
+
},
|
|
289
|
+
}),
|
|
290
|
+
]),
|
|
291
|
+
el("div", { class: "meta" }, [
|
|
292
|
+
el("div", { class: "title", text: file.title }),
|
|
293
|
+
el("div", { class: "date", text: displayDate(file.created) }),
|
|
294
|
+
]),
|
|
295
|
+
])
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function load() {
|
|
299
|
+
const docsDir = document.getElementById("docs-dir").textContent
|
|
300
|
+
document.getElementById("empty-dir").textContent = docsDir
|
|
301
|
+
|
|
302
|
+
let files = []
|
|
303
|
+
try {
|
|
304
|
+
const res = await fetch("/api/files")
|
|
305
|
+
files = await res.json()
|
|
306
|
+
} catch {}
|
|
307
|
+
if (!Array.isArray(files)) files = []
|
|
308
|
+
|
|
309
|
+
document.getElementById("total").textContent = String(files.length)
|
|
310
|
+
if (files.length === 0) {
|
|
311
|
+
document.getElementById("empty").hidden = false
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const grid = document.getElementById("grid")
|
|
316
|
+
for (const f of files) grid.appendChild(card(f))
|
|
317
|
+
grid.hidden = false
|
|
318
|
+
|
|
319
|
+
for (const preview of grid.querySelectorAll(".preview")) {
|
|
320
|
+
previews.watch(preview)
|
|
321
|
+
}
|
|
322
|
+
fitPreviews()
|
|
323
|
+
new ResizeObserver(fitPreviews).observe(grid)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function connectLiveReload() {
|
|
327
|
+
// Reload when the set of files changes; a dropped socket recovers after restart.
|
|
328
|
+
const ws = new WebSocket(`${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/__reload`)
|
|
329
|
+
ws.addEventListener("message", (e) => {
|
|
330
|
+
let change
|
|
331
|
+
try { change = JSON.parse(e.data) } catch { return }
|
|
332
|
+
if (change?.type === "change" && change.contentSetChanged) location.reload()
|
|
333
|
+
})
|
|
334
|
+
ws.addEventListener("close", () => setTimeout(() => location.reload(), 1000))
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
window.addEventListener("resize", fitPreviews)
|
|
338
|
+
document.fonts?.ready.then(fitPreviews)
|
|
339
|
+
load()
|
|
340
|
+
connectLiveReload()
|
|
341
|
+
</script>
|
|
342
|
+
</body>
|
|
343
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@noy4/docserve",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Serves a folder of HTML as a card gallery with live reload.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"docserve": "./bin.mjs"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin.mjs",
|
|
14
|
+
"index.html",
|
|
15
|
+
"server/"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.12.0"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"static-server",
|
|
22
|
+
"live-reload",
|
|
23
|
+
"gallery",
|
|
24
|
+
"html"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/noy4/docserve.git"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/noy4/docserve#readme",
|
|
32
|
+
"bugs": "https://github.com/noy4/docserve/issues",
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public",
|
|
35
|
+
"provenance": true
|
|
36
|
+
}
|
|
37
|
+
}
|
package/server/files.mjs
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// File metadata for /api/files: recursive *.html listing (excluding index.html),
|
|
2
|
+
// <title> / <meta name="created_at"> extraction, and git date maps cached per
|
|
3
|
+
// repo root.
|
|
4
|
+
//
|
|
5
|
+
// Created-date priority: meta created_at > oldest git add commit > birthtime.
|
|
6
|
+
import { open, readdir, stat } from "node:fs/promises"
|
|
7
|
+
import { realpathSync } from "node:fs"
|
|
8
|
+
import { basename, isAbsolute, join, relative, sep } from "node:path"
|
|
9
|
+
import { execFile } from "node:child_process"
|
|
10
|
+
import { promisify } from "node:util"
|
|
11
|
+
|
|
12
|
+
const run = promisify(execFile)
|
|
13
|
+
|
|
14
|
+
// Walk docsDir recursively and collect .html files; any index.html is excluded
|
|
15
|
+
// from the gallery.
|
|
16
|
+
export async function listHtmlFiles(docsDir, out = []) {
|
|
17
|
+
let entries
|
|
18
|
+
try {
|
|
19
|
+
entries = await readdir(docsDir, { withFileTypes: true })
|
|
20
|
+
} catch {
|
|
21
|
+
return out
|
|
22
|
+
}
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
const full = join(docsDir, entry.name)
|
|
25
|
+
if (entry.isDirectory()) {
|
|
26
|
+
await listHtmlFiles(full, out)
|
|
27
|
+
} else if (entry.name.endsWith(".html") && entry.name !== "index.html") {
|
|
28
|
+
out.push(full)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// /api/files payload, sorted by created desc with title/path tiebreaks.
|
|
35
|
+
export async function apiFiles(docsDir) {
|
|
36
|
+
const paths = await listHtmlFiles(docsDir)
|
|
37
|
+
const { created, modified } = await gitDateMaps(docsDir)
|
|
38
|
+
const files = []
|
|
39
|
+
for (const full of paths) {
|
|
40
|
+
let st
|
|
41
|
+
try {
|
|
42
|
+
st = await stat(full)
|
|
43
|
+
} catch {
|
|
44
|
+
continue // deleted between listing and stat
|
|
45
|
+
}
|
|
46
|
+
const { title, date } = await metaOf(full, st.mtimeMs)
|
|
47
|
+
let key
|
|
48
|
+
try {
|
|
49
|
+
key = realpathSync(full)
|
|
50
|
+
} catch {
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
const rel = normalizedRelative(docsDir, full)
|
|
54
|
+
files.push({
|
|
55
|
+
title: title ?? basename(full),
|
|
56
|
+
url: `/${rel}`,
|
|
57
|
+
path: full,
|
|
58
|
+
created: date ?? created.get(key) ?? Math.floor(st.birthtimeMs / 1000),
|
|
59
|
+
modified: modified.get(key) ?? Math.floor(st.mtimeMs / 1000),
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
files.sort((a, b) => b.created - a.created || a.title.localeCompare(b.title, "ja") || a.url.localeCompare(b.url))
|
|
63
|
+
return files
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// <title> + <meta name="created_at"> extracted from the head of a report; cached by
|
|
67
|
+
// path + mtime so repeated /api/files calls only re-read files that changed.
|
|
68
|
+
const metaCache = new Map() // "path:mtimeMs" -> { title, created }
|
|
69
|
+
|
|
70
|
+
async function metaOf(full, mtimeMs) {
|
|
71
|
+
const key = `${full}:${mtimeMs}`
|
|
72
|
+
const cached = metaCache.get(key)
|
|
73
|
+
if (cached !== undefined) return cached
|
|
74
|
+
const meta = await extractMeta(full)
|
|
75
|
+
metaCache.set(key, meta)
|
|
76
|
+
return meta
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function extractMeta(full) {
|
|
80
|
+
const findTitle = (text) => text.match(/<title>([^<]*)<\/title>/)?.[1]?.trim() ?? null
|
|
81
|
+
const findDate = (text) => {
|
|
82
|
+
for (const tag of text.match(/<meta\b[^>]*>/gi) ?? []) {
|
|
83
|
+
if (!/\bname\s*=\s*["']created_at["']/i.test(tag)) continue
|
|
84
|
+
const d = new Date(tag.match(/\bcontent\s*=\s*["']([^"']*)["']/i)?.[1]?.trim() ?? "")
|
|
85
|
+
if (!isNaN(d.getTime())) return Math.floor(d.getTime() / 1000)
|
|
86
|
+
}
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const handle = await open(full, "r")
|
|
91
|
+
try {
|
|
92
|
+
const read = async (len, pos) => {
|
|
93
|
+
const { buffer, bytesRead } = await handle.read(Buffer.alloc(len), 0, len, pos)
|
|
94
|
+
return buffer.toString("utf8", 0, bytesRead)
|
|
95
|
+
}
|
|
96
|
+
// Titles and meta tags live in <head>; read only the first 4 KB.
|
|
97
|
+
const head = await read(4096, 0)
|
|
98
|
+
let meta = { title: findTitle(head), date: findDate(head) }
|
|
99
|
+
if (meta.title === null || meta.date === null) {
|
|
100
|
+
// Fallback for documents with a long preamble (inline favicon etc.)
|
|
101
|
+
const text = await read(1 << 20, 0)
|
|
102
|
+
meta = {
|
|
103
|
+
title: meta.title ?? findTitle(text),
|
|
104
|
+
date: meta.date ?? findDate(text),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return meta
|
|
108
|
+
} finally {
|
|
109
|
+
await handle.close()
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
return { title: null, date: null }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Resolve created/modified dates from git history, keyed by absolute path.
|
|
117
|
+
// A single full-repo scan with cwd set to the docs dir, cached per repo root in
|
|
118
|
+
// module scope. git log outputs newest first: overwriting keeps the oldest add
|
|
119
|
+
// (= creation date) and the newest touch (= modified date).
|
|
120
|
+
const gitMapsCache = new Map() // repo root (or docsDir when not a repo) -> { created, modified }
|
|
121
|
+
|
|
122
|
+
async function gitDateMaps(docsDir) {
|
|
123
|
+
const cwd = realpath(docsDir)
|
|
124
|
+
const top = (await git(["rev-parse", "--show-toplevel"], cwd)).trim()
|
|
125
|
+
const root = top ? realpath(top) : null
|
|
126
|
+
const cacheKey = root ?? cwd
|
|
127
|
+
let maps = gitMapsCache.get(cacheKey)
|
|
128
|
+
if (maps) return maps
|
|
129
|
+
|
|
130
|
+
maps = { created: new Map(), modified: new Map() }
|
|
131
|
+
if (root) {
|
|
132
|
+
try {
|
|
133
|
+
const [first, last] = await Promise.all([
|
|
134
|
+
git(["log", "--diff-filter=A", "--name-only", "--format=c:%at"], cwd),
|
|
135
|
+
git(["log", "--name-only", "--format=m:%at"], cwd),
|
|
136
|
+
])
|
|
137
|
+
let ts
|
|
138
|
+
for (const line of first.split("\n")) {
|
|
139
|
+
if (line.startsWith("c:")) {
|
|
140
|
+
ts = parseInt(line.slice(2), 10)
|
|
141
|
+
} else if (line && ts !== undefined) {
|
|
142
|
+
maps.created.set(join(root, line), ts) // oldest add wins
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
ts = undefined
|
|
146
|
+
for (const line of last.split("\n")) {
|
|
147
|
+
if (line.startsWith("m:")) {
|
|
148
|
+
ts = parseInt(line.slice(2), 10)
|
|
149
|
+
} else if (line && ts !== undefined) {
|
|
150
|
+
maps.modified.set(join(root, line), ts) // newest touch wins
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
// unreadable history → fall back to fs dates
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
gitMapsCache.set(cacheKey, maps)
|
|
158
|
+
return maps
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function git(args, cwd) {
|
|
162
|
+
try {
|
|
163
|
+
const { stdout } = await run("git", args, { cwd, timeout: 10000, maxBuffer: 64 * 1024 * 1024 })
|
|
164
|
+
return stdout
|
|
165
|
+
} catch {
|
|
166
|
+
return ""
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// On macOS /var is a symlink to /private/var; canonicalize to real paths so
|
|
171
|
+
// lookups match git output.
|
|
172
|
+
function realpath(p) {
|
|
173
|
+
try {
|
|
174
|
+
return realpathSync(p)
|
|
175
|
+
} catch {
|
|
176
|
+
return p
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// --- Path utilities (shared by the HTTP handler and the watcher) ---
|
|
181
|
+
|
|
182
|
+
export function isWithin(base, path) {
|
|
183
|
+
const rel = relative(base, path)
|
|
184
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function normalizedRelative(base, path) {
|
|
188
|
+
return relative(base, path).split(sep).join("/")
|
|
189
|
+
}
|
package/server/index.mjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// docserve server core: HTTP layer + server lifecycle.
|
|
2
|
+
//
|
|
3
|
+
// ┌────────────────────────────────────────────────────────┐
|
|
4
|
+
// │ runServer() │
|
|
5
|
+
// │ ├─ createServer ──▶ handler() │ # HTTP
|
|
6
|
+
// │ │ ├─ /api/files ──▶ apiFiles() │
|
|
7
|
+
// │ │ ├─ /, /index.html ──▶ gallery template │
|
|
8
|
+
// │ │ └─ other paths ──▶ raw files ──▶ injectIntoHtml() │
|
|
9
|
+
// │ ├─ WebSocketServer("/__reload") ◀─ UpdateListener │
|
|
10
|
+
// │ └─ listen ──▶ listener.start() + writeState() │
|
|
11
|
+
// └────────────────────────────────────────────────────────┘
|
|
12
|
+
import { createServer } from "node:http"
|
|
13
|
+
import { readFile } from "node:fs/promises"
|
|
14
|
+
import { extname, resolve } from "node:path"
|
|
15
|
+
import { exec } from "node:child_process"
|
|
16
|
+
import { clearState, writeState } from "./state.mjs"
|
|
17
|
+
import { apiFiles, isWithin, normalizedRelative } from "./files.mjs"
|
|
18
|
+
import { injectIntoHtml, WS_PATH } from "./inject.mjs"
|
|
19
|
+
import { WebSocketServer } from "./websocket.mjs"
|
|
20
|
+
import { UpdateListener } from "./watch.mjs"
|
|
21
|
+
|
|
22
|
+
const MAX_PORT_TRIES = 20 // on conflict, try the next port up to 20 times
|
|
23
|
+
const INDEX_TEMPLATE = resolve(import.meta.dirname, "..", "index.html")
|
|
24
|
+
|
|
25
|
+
const MIME = {
|
|
26
|
+
".html": "text/html",
|
|
27
|
+
".htm": "text/html",
|
|
28
|
+
".js": "text/javascript",
|
|
29
|
+
".mjs": "text/javascript",
|
|
30
|
+
".css": "text/css",
|
|
31
|
+
".json": "application/json",
|
|
32
|
+
".txt": "text/plain",
|
|
33
|
+
".md": "text/markdown",
|
|
34
|
+
".xml": "application/xml",
|
|
35
|
+
".png": "image/png",
|
|
36
|
+
".jpg": "image/jpeg",
|
|
37
|
+
".jpeg": "image/jpeg",
|
|
38
|
+
".gif": "image/gif",
|
|
39
|
+
".svg": "image/svg+xml",
|
|
40
|
+
".webp": "image/webp",
|
|
41
|
+
".ico": "image/x-icon",
|
|
42
|
+
".pdf": "application/pdf",
|
|
43
|
+
".woff": "font/woff",
|
|
44
|
+
".woff2": "font/woff2",
|
|
45
|
+
".mp4": "video/mp4",
|
|
46
|
+
".webm": "video/webm",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function runServer({ docsDir, port: initialPort, open = false }) {
|
|
50
|
+
const wss = new WebSocketServer({ path: WS_PATH })
|
|
51
|
+
const updateListener = new UpdateListener({ wss, docsDir })
|
|
52
|
+
let port = initialPort // actually bound port (may fall back +1 on conflict)
|
|
53
|
+
|
|
54
|
+
const cleanup = () => {
|
|
55
|
+
updateListener.close()
|
|
56
|
+
clearState()
|
|
57
|
+
}
|
|
58
|
+
process.on("SIGINT", () => { cleanup(); process.exit(0) })
|
|
59
|
+
process.on("SIGTERM", () => { cleanup(); process.exit(0) })
|
|
60
|
+
|
|
61
|
+
const server = createServer(createNodeServerAdapter(createHandler({ docsDir })))
|
|
62
|
+
wss.attach(server)
|
|
63
|
+
server.on("error", (err) => {
|
|
64
|
+
if (err.code === "EADDRINUSE" && port < initialPort + MAX_PORT_TRIES) {
|
|
65
|
+
port += 1
|
|
66
|
+
server.listen(port, "127.0.0.1")
|
|
67
|
+
} else {
|
|
68
|
+
console.error(err)
|
|
69
|
+
process.exit(1)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
server.listen(port, "127.0.0.1", async () => {
|
|
73
|
+
await updateListener.start()
|
|
74
|
+
const state = { pid: process.pid, port, url: `http://localhost:${port}/`, docsDir }
|
|
75
|
+
writeState(state)
|
|
76
|
+
console.log(state.url)
|
|
77
|
+
if (open && process.platform === "darwin") exec(`open ${state.url}`)
|
|
78
|
+
})
|
|
79
|
+
return { server, wss, updateListener }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- Node server adapter ---
|
|
83
|
+
|
|
84
|
+
function createNodeServerAdapter(handler) {
|
|
85
|
+
return async (req, res) => {
|
|
86
|
+
try {
|
|
87
|
+
const response = await handler(toWebRequest(req))
|
|
88
|
+
res.writeHead(response.status, Object.fromEntries(response.headers))
|
|
89
|
+
res.end(Buffer.from(await response.arrayBuffer()))
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.error(err)
|
|
92
|
+
res.writeHead(500, { "content-type": "text/plain" })
|
|
93
|
+
res.end("Internal Server Error")
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function toWebRequest(req) {
|
|
99
|
+
const host = req.headers.host || "localhost"
|
|
100
|
+
return new Request(`http://${host}${req.url}`, { method: req.method, headers: req.headers })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- HTTP handler ---
|
|
104
|
+
|
|
105
|
+
function createHandler({ docsDir }) {
|
|
106
|
+
return async function handler(request) {
|
|
107
|
+
const url = new URL(request.url)
|
|
108
|
+
|
|
109
|
+
if (url.pathname === "/api/files") {
|
|
110
|
+
try {
|
|
111
|
+
return Response.json(await apiFiles(docsDir))
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.error(err)
|
|
114
|
+
return Response.json([])
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let pathname
|
|
119
|
+
try {
|
|
120
|
+
pathname = decodeURIComponent(url.pathname)
|
|
121
|
+
} catch {
|
|
122
|
+
return new Response("Bad Request", { status: 400 })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (pathname === "/" || pathname === "/index.html") {
|
|
126
|
+
return galleryResponse(docsDir)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (pathname.includes("\0")) {
|
|
130
|
+
return new Response("Bad Request", { status: 400 })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const path = resolve(docsDir, pathname.slice(1))
|
|
134
|
+
if (!isWithin(docsDir, path)) {
|
|
135
|
+
return new Response("Forbidden", { status: 403 })
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const body = await readFile(path)
|
|
140
|
+
const type = MIME[extname(path).toLowerCase()] || "application/octet-stream"
|
|
141
|
+
// Inject the reload client + copy path button into docsDir pages only;
|
|
142
|
+
// the gallery template ships its own client.
|
|
143
|
+
if (type === "text/html") {
|
|
144
|
+
const html = injectIntoHtml(body.toString(), normalizedRelative(docsDir, path), path)
|
|
145
|
+
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } })
|
|
146
|
+
}
|
|
147
|
+
return new Response(body, { headers: { "content-type": type } })
|
|
148
|
+
} catch {
|
|
149
|
+
return new Response("Not Found", { status: 404 })
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The gallery is an embedded template served at / and /index.html; docsDir's own
|
|
155
|
+
// index.html is shadowed by it. __DOCSERVE_DIR__ is substituted for display.
|
|
156
|
+
async function galleryResponse(docsDir) {
|
|
157
|
+
try {
|
|
158
|
+
let html = await readFile(INDEX_TEMPLATE, "utf8")
|
|
159
|
+
html = html.replaceAll("__DOCSERVE_DIR__", escapeHtml(docsDir))
|
|
160
|
+
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } })
|
|
161
|
+
} catch {
|
|
162
|
+
return new Response("Gallery template missing", { status: 500 })
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function escapeHtml(s) {
|
|
167
|
+
return s.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
168
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Injection: every text/html response served from docsDir gets one client script
|
|
2
|
+
// before the last </body> (appended at the end if the tag is missing). The script
|
|
3
|
+
// exits immediately inside iframes, so previews get neither part; the gallery
|
|
4
|
+
// template ships its own client.
|
|
5
|
+
|
|
6
|
+
export const WS_PATH = "/__reload"
|
|
7
|
+
|
|
8
|
+
export function injectIntoHtml(html, pageId, absPath) {
|
|
9
|
+
const idx = html.toLowerCase().lastIndexOf("</body>")
|
|
10
|
+
if (idx === -1) return html + clientJs(pageId, absPath)
|
|
11
|
+
return html.slice(0, idx) + clientJs(pageId, absPath) + html.slice(idx)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function clientJs(pageId, absPath) {
|
|
15
|
+
return `<script>
|
|
16
|
+
(() => {
|
|
17
|
+
// iframes: no reload loop from dropped sockets, no copy button
|
|
18
|
+
if (window.self !== window.top) return
|
|
19
|
+
const pageId = ${JSON.stringify(pageId).replaceAll("<", "\\u003c")}
|
|
20
|
+
const proto = location.protocol === "https:" ? "wss:" : "ws:"
|
|
21
|
+
const ws = new WebSocket(proto + "//" + location.host + "${WS_PATH}")
|
|
22
|
+
ws.addEventListener("message", (e) => {
|
|
23
|
+
let change
|
|
24
|
+
try { change = JSON.parse(e.data) } catch { return }
|
|
25
|
+
if (change?.type === "change" && Array.isArray(change.pages) && change.pages.includes(pageId)) {
|
|
26
|
+
location.reload()
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
ws.addEventListener("close", () => setTimeout(() => location.reload(), 1000))
|
|
30
|
+
|
|
31
|
+
const absPath = ${JSON.stringify(absPath).replaceAll("<", "\\u003c")}
|
|
32
|
+
const style = document.createElement("style")
|
|
33
|
+
style.textContent = \`#docserve-copy {
|
|
34
|
+
position: fixed;
|
|
35
|
+
top: 12px;
|
|
36
|
+
right: 12px;
|
|
37
|
+
z-index: 2147483647;
|
|
38
|
+
display: inline-flex;
|
|
39
|
+
align-items: center;
|
|
40
|
+
gap: 6px;
|
|
41
|
+
height: 28px;
|
|
42
|
+
padding: 0 10px 0 9px;
|
|
43
|
+
border: 1px solid rgba(127, 127, 138, 0.35);
|
|
44
|
+
border-radius: 8px;
|
|
45
|
+
background: rgba(127, 127, 138, 0.14);
|
|
46
|
+
color: inherit;
|
|
47
|
+
cursor: pointer;
|
|
48
|
+
opacity: 0.55;
|
|
49
|
+
transition: opacity 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
|
50
|
+
}
|
|
51
|
+
#docserve-copy svg {
|
|
52
|
+
width: 14px;
|
|
53
|
+
height: 14px;
|
|
54
|
+
pointer-events: none;
|
|
55
|
+
}
|
|
56
|
+
#docserve-copy .label {
|
|
57
|
+
font: 500 11.5px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", sans-serif;
|
|
58
|
+
letter-spacing: 0.01em;
|
|
59
|
+
white-space: nowrap;
|
|
60
|
+
}
|
|
61
|
+
#docserve-copy:hover {
|
|
62
|
+
opacity: 1;
|
|
63
|
+
border-color: #6c8cff;
|
|
64
|
+
color: #6c8cff;
|
|
65
|
+
}
|
|
66
|
+
#docserve-copy.copied {
|
|
67
|
+
opacity: 1;
|
|
68
|
+
color: #4ade80;
|
|
69
|
+
border-color: #4ade80;
|
|
70
|
+
}
|
|
71
|
+
@media (prefers-color-scheme: light) {
|
|
72
|
+
#docserve-copy:hover { border-color: #2563eb; color: #2563eb; }
|
|
73
|
+
#docserve-copy.copied { color: #16a34a; border-color: #16a34a; }
|
|
74
|
+
}
|
|
75
|
+
#docserve-copy .ic-check { display: none; }
|
|
76
|
+
#docserve-copy.copied .ic-copy { display: none; }
|
|
77
|
+
#docserve-copy.copied .ic-check { display: block; }\`
|
|
78
|
+
const COPY = '<svg class="ic-copy" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'
|
|
79
|
+
const CHECK = '<svg class="ic-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'
|
|
80
|
+
const btn = document.createElement("button")
|
|
81
|
+
btn.id = "docserve-copy"
|
|
82
|
+
btn.type = "button"
|
|
83
|
+
btn.title = absPath
|
|
84
|
+
btn.innerHTML = COPY + CHECK + '<span class="label">Copy file path</span>'
|
|
85
|
+
btn.addEventListener("click", async () => {
|
|
86
|
+
try {
|
|
87
|
+
await navigator.clipboard.writeText(absPath)
|
|
88
|
+
btn.classList.add("copied")
|
|
89
|
+
btn.querySelector(".label").textContent = "Copied"
|
|
90
|
+
setTimeout(() => {
|
|
91
|
+
btn.classList.remove("copied")
|
|
92
|
+
btn.querySelector(".label").textContent = "Copy file path"
|
|
93
|
+
}, 1200)
|
|
94
|
+
} catch {}
|
|
95
|
+
})
|
|
96
|
+
document.head.appendChild(style)
|
|
97
|
+
document.body.appendChild(btn)
|
|
98
|
+
})()
|
|
99
|
+
</script>`
|
|
100
|
+
}
|
package/server/state.mjs
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// state.json read/write + pid liveness.
|
|
2
|
+
//
|
|
3
|
+
// The server writes ~/.cache/docserve/state.json once the port is bound and
|
|
4
|
+
// removes it on clean shutdown. Readers verify the PID is alive and treat
|
|
5
|
+
// dead-PID state as stale (removed in place).
|
|
6
|
+
import fs from "node:fs"
|
|
7
|
+
import os from "node:os"
|
|
8
|
+
import path from "node:path"
|
|
9
|
+
|
|
10
|
+
// DOCSERVE_STATE overrides the state file path (test isolation).
|
|
11
|
+
export function statePath() {
|
|
12
|
+
return process.env.DOCSERVE_STATE || path.join(os.homedir(), ".cache", "docserve", "state.json")
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function readState() {
|
|
16
|
+
try {
|
|
17
|
+
const state = JSON.parse(fs.readFileSync(statePath(), "utf8"))
|
|
18
|
+
if (
|
|
19
|
+
typeof state?.pid === "number" &&
|
|
20
|
+
typeof state?.port === "number" &&
|
|
21
|
+
typeof state?.url === "string" &&
|
|
22
|
+
typeof state?.docsDir === "string"
|
|
23
|
+
) {
|
|
24
|
+
return state
|
|
25
|
+
}
|
|
26
|
+
return null
|
|
27
|
+
} catch {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function writeState(state) {
|
|
33
|
+
fs.mkdirSync(path.dirname(statePath()), { recursive: true })
|
|
34
|
+
fs.writeFileSync(statePath(), JSON.stringify(state, null, 2) + "\n")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function clearState() {
|
|
38
|
+
try {
|
|
39
|
+
fs.rmSync(statePath(), { force: true })
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isProcessAlive(pid) {
|
|
44
|
+
try {
|
|
45
|
+
process.kill(pid, 0)
|
|
46
|
+
return true
|
|
47
|
+
} catch {
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Read the state and drop stale entries (dead pid) in place.
|
|
53
|
+
export function readLiveState() {
|
|
54
|
+
const state = readState()
|
|
55
|
+
if (!state) return null
|
|
56
|
+
if (!isProcessAlive(state.pid)) {
|
|
57
|
+
clearState()
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
return state
|
|
61
|
+
}
|
package/server/watch.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// UpdateListener: watch docsDir recursively for *.html changes and broadcast
|
|
2
|
+
// debounced batches over the reload socket.
|
|
3
|
+
//
|
|
4
|
+
// The gallery list is every *.html except any index.html; files outside that
|
|
5
|
+
// set never flip contentSetChanged.
|
|
6
|
+
import { existsSync, watch } from "node:fs"
|
|
7
|
+
import { basename, resolve } from "node:path"
|
|
8
|
+
import { isWithin, listHtmlFiles, normalizedRelative } from "./files.mjs"
|
|
9
|
+
|
|
10
|
+
const DEBOUNCE_MS = 50 // like livePreview.previewDebounceDelay
|
|
11
|
+
|
|
12
|
+
export class UpdateListener {
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this.wss = options.wss
|
|
15
|
+
this.docsDir = options.docsDir
|
|
16
|
+
this.debounceMs = options.debounceMs ?? DEBOUNCE_MS
|
|
17
|
+
this.knownFiles = new Set()
|
|
18
|
+
this.broadcastQueue = Promise.resolve()
|
|
19
|
+
this.watchers = []
|
|
20
|
+
|
|
21
|
+
this.queueBroadcast = debounceBatch((touched) => {
|
|
22
|
+
this.broadcastQueue = this.broadcastQueue
|
|
23
|
+
.then(() => this.broadcastChanges(touched))
|
|
24
|
+
.catch((error) => console.error(error))
|
|
25
|
+
}, this.debounceMs)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async start() {
|
|
29
|
+
try {
|
|
30
|
+
this.knownFiles = new Set(
|
|
31
|
+
(await listHtmlFiles(this.docsDir)).map((p) => normalizedRelative(this.docsDir, p)),
|
|
32
|
+
)
|
|
33
|
+
} catch {
|
|
34
|
+
this.knownFiles = new Set()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.watchers.push(
|
|
38
|
+
watch(this.docsDir, { recursive: true }, (event, filename) => {
|
|
39
|
+
if (!filename?.endsWith(".html")) return
|
|
40
|
+
const full = resolve(this.docsDir, filename)
|
|
41
|
+
if (!isWithin(this.docsDir, full)) return
|
|
42
|
+
this.queueBroadcast(normalizedRelative(this.docsDir, full))
|
|
43
|
+
}).on("error", () => {}),
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async broadcastChanges(touched) {
|
|
48
|
+
let contentSetChanged = false
|
|
49
|
+
|
|
50
|
+
for (const rel of touched) {
|
|
51
|
+
if (basename(rel) === "index.html") continue // never a gallery card
|
|
52
|
+
const full = resolve(this.docsDir, rel)
|
|
53
|
+
const exists = existsSync(full)
|
|
54
|
+
const wasKnown = this.knownFiles.has(rel)
|
|
55
|
+
|
|
56
|
+
if (exists && !wasKnown) {
|
|
57
|
+
this.knownFiles.add(rel)
|
|
58
|
+
contentSetChanged = true
|
|
59
|
+
} else if (!exists && wasKnown) {
|
|
60
|
+
this.knownFiles.delete(rel)
|
|
61
|
+
contentSetChanged = true
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.wss.broadcast({ type: "change", pages: touched, contentSetChanged })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
close() {
|
|
69
|
+
this.watchers.forEach((w) => w.close())
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Accumulate items during the debounce window and deliver them as one batch.
|
|
74
|
+
function debounceBatch(fn, ms) {
|
|
75
|
+
let timer = null
|
|
76
|
+
const pending = new Set()
|
|
77
|
+
return (item) => {
|
|
78
|
+
pending.add(item)
|
|
79
|
+
clearTimeout(timer)
|
|
80
|
+
timer = setTimeout(() => {
|
|
81
|
+
const items = [...pending]
|
|
82
|
+
pending.clear()
|
|
83
|
+
fn(items)
|
|
84
|
+
}, ms)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Minimal WebSocket server (RFC 6455 subset: handshake + text frames + ping/pong,
|
|
2
|
+
// zero deps). Broadcast payloads are JSON-encoded text frames.
|
|
3
|
+
import { createHash } from "node:crypto"
|
|
4
|
+
|
|
5
|
+
export class WebSocketServer {
|
|
6
|
+
constructor(options = {}) {
|
|
7
|
+
this.path = options.path
|
|
8
|
+
this.clients = new Set()
|
|
9
|
+
if (options.server) {
|
|
10
|
+
this.attach(options.server)
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
attach(server) {
|
|
15
|
+
server.on("upgrade", (req, socket) => this.handleUpgrade(req, socket))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
handleUpgrade(req, socket) {
|
|
19
|
+
const url = new URL(req.url, "http://localhost")
|
|
20
|
+
if (this.path && url.pathname !== this.path) {
|
|
21
|
+
socket.destroy()
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
const key = req.headers["sec-websocket-key"]
|
|
25
|
+
if (!key) {
|
|
26
|
+
socket.destroy()
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
socket.write(
|
|
30
|
+
"HTTP/1.1 101 Switching Protocols\r\n" +
|
|
31
|
+
"Upgrade: websocket\r\n" +
|
|
32
|
+
"Connection: Upgrade\r\n" +
|
|
33
|
+
`Sec-WebSocket-Accept: ${acceptKey(key)}\r\n\r\n`,
|
|
34
|
+
)
|
|
35
|
+
socket.setNoDelay(true)
|
|
36
|
+
this.clients.add(socket)
|
|
37
|
+
socket.on("close", () => this.clients.delete(socket))
|
|
38
|
+
socket.on("error", () => this.clients.delete(socket))
|
|
39
|
+
// Answer pings to keep the connection alive
|
|
40
|
+
socket.on("data", (buf) => {
|
|
41
|
+
if (buf.length > 0 && (buf[0] & 0x0f) === 0x9) {
|
|
42
|
+
socket.write(Buffer.from([0x8a, 0x00])) // pong
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
broadcast(payload) {
|
|
48
|
+
const frame = encodeFrame(typeof payload === "string" ? payload : JSON.stringify(payload))
|
|
49
|
+
for (const socket of this.clients) {
|
|
50
|
+
try {
|
|
51
|
+
socket.write(frame)
|
|
52
|
+
} catch {
|
|
53
|
+
this.clients.delete(socket)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function acceptKey(key) {
|
|
60
|
+
return createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function encodeFrame(text) {
|
|
64
|
+
const payload = Buffer.from(text)
|
|
65
|
+
const len = payload.length
|
|
66
|
+
let header
|
|
67
|
+
if (len < 126) {
|
|
68
|
+
header = Buffer.from([0x81, len])
|
|
69
|
+
} else if (len < 65536) {
|
|
70
|
+
header = Buffer.alloc(4)
|
|
71
|
+
header[0] = 0x81
|
|
72
|
+
header[1] = 126
|
|
73
|
+
header.writeUInt16BE(len, 2)
|
|
74
|
+
} else {
|
|
75
|
+
header = Buffer.alloc(10)
|
|
76
|
+
header[0] = 0x81
|
|
77
|
+
header[1] = 127
|
|
78
|
+
header.writeBigUInt64BE(BigInt(len), 2)
|
|
79
|
+
}
|
|
80
|
+
return Buffer.concat([header, payload])
|
|
81
|
+
}
|