@noy4/docserve 0.1.1 → 0.1.2
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/index.html +26 -5
- package/package.json +1 -1
- package/server/files.mjs +41 -13
- package/server/index.mjs +1 -1
- package/server/watch.mjs +21 -4
package/index.html
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
background: var(--bg);
|
|
36
36
|
color: var(--text);
|
|
37
37
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Noto Sans JP", sans-serif;
|
|
38
|
-
padding: 40px
|
|
38
|
+
padding: 40px 80px;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
h1 {
|
|
@@ -134,12 +134,20 @@
|
|
|
134
134
|
flex: 1;
|
|
135
135
|
flex-direction: column;
|
|
136
136
|
justify-content: space-between;
|
|
137
|
-
gap:
|
|
137
|
+
gap: 8px;
|
|
138
138
|
padding: 10px 12px;
|
|
139
139
|
border-top: 1px solid var(--border);
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
.meta .title { font-size: 0.85rem; font-weight: 600; line-height: 1.4; }
|
|
143
|
+
.meta .title .favicon {
|
|
144
|
+
width: 16px;
|
|
145
|
+
height: 16px;
|
|
146
|
+
margin-right: 6px;
|
|
147
|
+
vertical-align: -3px;
|
|
148
|
+
border-radius: 4px;
|
|
149
|
+
object-fit: contain;
|
|
150
|
+
}
|
|
143
151
|
.date { font-size: 0.7rem; color: var(--muted); }
|
|
144
152
|
|
|
145
153
|
.empty {
|
|
@@ -289,7 +297,18 @@
|
|
|
289
297
|
}),
|
|
290
298
|
]),
|
|
291
299
|
el("div", { class: "meta" }, [
|
|
292
|
-
el("div", { class: "title"
|
|
300
|
+
el("div", { class: "title" }, [
|
|
301
|
+
file.favicon
|
|
302
|
+
? el("img", {
|
|
303
|
+
class: "favicon",
|
|
304
|
+
src: file.favicon,
|
|
305
|
+
alt: "",
|
|
306
|
+
loading: "lazy",
|
|
307
|
+
onerror: (e) => e.currentTarget.remove(),
|
|
308
|
+
})
|
|
309
|
+
: null,
|
|
310
|
+
el("span", { text: file.title }),
|
|
311
|
+
]),
|
|
293
312
|
el("div", { class: "date", text: displayDate(file.created) }),
|
|
294
313
|
]),
|
|
295
314
|
])
|
|
@@ -324,12 +343,14 @@
|
|
|
324
343
|
}
|
|
325
344
|
|
|
326
345
|
function connectLiveReload() {
|
|
327
|
-
// Reload when the set of files
|
|
346
|
+
// Reload when the set of files or the gallery template itself changes;
|
|
347
|
+
// a dropped socket recovers after restart.
|
|
328
348
|
const ws = new WebSocket(`${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/__reload`)
|
|
329
349
|
ws.addEventListener("message", (e) => {
|
|
330
350
|
let change
|
|
331
351
|
try { change = JSON.parse(e.data) } catch { return }
|
|
332
|
-
if (change?.type === "change" && change.contentSetChanged
|
|
352
|
+
if (change?.type === "change" && (change.contentSetChanged || change.templateChanged))
|
|
353
|
+
location.reload()
|
|
333
354
|
})
|
|
334
355
|
ws.addEventListener("close", () => setTimeout(() => location.reload(), 1000))
|
|
335
356
|
}
|
package/package.json
CHANGED
package/server/files.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// File metadata for /api/files: recursive *.html listing (excluding index.html),
|
|
2
|
-
// <title> / <meta name="created_at"> extraction, and git date
|
|
3
|
-
// repo root.
|
|
2
|
+
// <title> / <meta name="created_at"> / <link rel="icon"> extraction, and git date
|
|
3
|
+
// maps cached per repo root.
|
|
4
4
|
//
|
|
5
5
|
// Created-date priority: meta created_at > oldest git add commit > birthtime.
|
|
6
6
|
import { open, readdir, stat } from "node:fs/promises"
|
|
7
7
|
import { realpathSync } from "node:fs"
|
|
8
|
-
import { basename, isAbsolute, join, relative, sep } from "node:path"
|
|
8
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"
|
|
9
9
|
import { execFile } from "node:child_process"
|
|
10
10
|
import { promisify } from "node:util"
|
|
11
11
|
|
|
@@ -43,7 +43,7 @@ export async function apiFiles(docsDir) {
|
|
|
43
43
|
} catch {
|
|
44
44
|
continue // deleted between listing and stat
|
|
45
45
|
}
|
|
46
|
-
const { title, date } = await metaOf(full, st.mtimeMs)
|
|
46
|
+
const { title, date, favicon } = await metaOf(full, st.mtimeMs, docsDir)
|
|
47
47
|
let key
|
|
48
48
|
try {
|
|
49
49
|
key = realpathSync(full)
|
|
@@ -55,6 +55,7 @@ export async function apiFiles(docsDir) {
|
|
|
55
55
|
title: title ?? basename(full),
|
|
56
56
|
url: `/${rel}`,
|
|
57
57
|
path: full,
|
|
58
|
+
favicon: favicon ?? undefined,
|
|
58
59
|
created: date ?? created.get(key) ?? Math.floor(st.birthtimeMs / 1000),
|
|
59
60
|
modified: modified.get(key) ?? Math.floor(st.mtimeMs / 1000),
|
|
60
61
|
})
|
|
@@ -63,20 +64,21 @@ export async function apiFiles(docsDir) {
|
|
|
63
64
|
return files
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
// <title
|
|
67
|
-
// path + mtime so repeated /api/files calls only re-read
|
|
68
|
-
|
|
67
|
+
// <title>, <meta name="created_at"> and <link rel="icon"> extracted from the head
|
|
68
|
+
// of a report; cached by path + mtime so repeated /api/files calls only re-read
|
|
69
|
+
// files that changed.
|
|
70
|
+
const metaCache = new Map() // "path:mtimeMs" -> { title, date, favicon }
|
|
69
71
|
|
|
70
|
-
async function metaOf(full, mtimeMs) {
|
|
72
|
+
async function metaOf(full, mtimeMs, docsDir) {
|
|
71
73
|
const key = `${full}:${mtimeMs}`
|
|
72
74
|
const cached = metaCache.get(key)
|
|
73
75
|
if (cached !== undefined) return cached
|
|
74
|
-
const meta = await extractMeta(full)
|
|
76
|
+
const meta = await extractMeta(full, docsDir)
|
|
75
77
|
metaCache.set(key, meta)
|
|
76
78
|
return meta
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
async function extractMeta(full) {
|
|
81
|
+
async function extractMeta(full, docsDir) {
|
|
80
82
|
const findTitle = (text) => text.match(/<title>([^<]*)<\/title>/)?.[1]?.trim() ?? null
|
|
81
83
|
const findDate = (text) => {
|
|
82
84
|
for (const tag of text.match(/<meta\b[^>]*>/gi) ?? []) {
|
|
@@ -86,6 +88,31 @@ async function extractMeta(full) {
|
|
|
86
88
|
}
|
|
87
89
|
return null
|
|
88
90
|
}
|
|
91
|
+
// Attribute value from a tag string ("v", 'v', or bare); null when absent.
|
|
92
|
+
const attrOf = (tag, name) => {
|
|
93
|
+
const m = tag.match(new RegExp(`(?<![\\w-])${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i"))
|
|
94
|
+
return m?.[1] ?? m?.[2] ?? m?.[3] ?? null
|
|
95
|
+
}
|
|
96
|
+
// First <link rel="...icon..."> href resolved to a URL served from docsDir;
|
|
97
|
+
// data: and absolute http(s) URLs are kept as-is.
|
|
98
|
+
const findFavicon = (text) => {
|
|
99
|
+
// Quoted values may contain ">" (inline SVG data URIs), so scan links
|
|
100
|
+
// value-aware instead of with a plain [^>]* tag match.
|
|
101
|
+
for (const tag of text.match(/<link\b(?:"[^"]*"|'[^']*'|[^>])*>/gi) ?? []) {
|
|
102
|
+
const rel = attrOf(tag, "rel")
|
|
103
|
+
if (!rel || !/\bicon\b/i.test(rel)) continue
|
|
104
|
+
const href = attrOf(tag, "href")?.trim()
|
|
105
|
+
if (!href) continue
|
|
106
|
+
if (/^data:/i.test(href)) return href.replaceAll("#", "%23")
|
|
107
|
+
if (/^(https?:|\/\/)/i.test(href)) return href
|
|
108
|
+
const target = href.startsWith("/")
|
|
109
|
+
? join(docsDir, href.split(/[?#]/)[0])
|
|
110
|
+
: join(dirname(full), href.split(/[?#]/)[0])
|
|
111
|
+
if (!isWithin(docsDir, target)) return null
|
|
112
|
+
return `/${normalizedRelative(docsDir, target)}`
|
|
113
|
+
}
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
89
116
|
try {
|
|
90
117
|
const handle = await open(full, "r")
|
|
91
118
|
try {
|
|
@@ -95,13 +122,14 @@ async function extractMeta(full) {
|
|
|
95
122
|
}
|
|
96
123
|
// Titles and meta tags live in <head>; read only the first 4 KB.
|
|
97
124
|
const head = await read(4096, 0)
|
|
98
|
-
let meta = { title: findTitle(head), date: findDate(head) }
|
|
99
|
-
if (meta.title === null || meta.date === null) {
|
|
125
|
+
let meta = { title: findTitle(head), date: findDate(head), favicon: findFavicon(head) }
|
|
126
|
+
if (meta.title === null || meta.date === null || meta.favicon === null) {
|
|
100
127
|
// Fallback for documents with a long preamble (inline favicon etc.)
|
|
101
128
|
const text = await read(1 << 20, 0)
|
|
102
129
|
meta = {
|
|
103
130
|
title: meta.title ?? findTitle(text),
|
|
104
131
|
date: meta.date ?? findDate(text),
|
|
132
|
+
favicon: meta.favicon ?? findFavicon(text),
|
|
105
133
|
}
|
|
106
134
|
}
|
|
107
135
|
return meta
|
|
@@ -109,7 +137,7 @@ async function extractMeta(full) {
|
|
|
109
137
|
await handle.close()
|
|
110
138
|
}
|
|
111
139
|
} catch {
|
|
112
|
-
return { title: null, date: null }
|
|
140
|
+
return { title: null, date: null, favicon: null }
|
|
113
141
|
}
|
|
114
142
|
}
|
|
115
143
|
|
package/server/index.mjs
CHANGED
|
@@ -48,7 +48,7 @@ const MIME = {
|
|
|
48
48
|
|
|
49
49
|
export async function runServer({ docsDir, port: initialPort, open = false }) {
|
|
50
50
|
const wss = new WebSocketServer({ path: WS_PATH })
|
|
51
|
-
const updateListener = new UpdateListener({ wss, docsDir })
|
|
51
|
+
const updateListener = new UpdateListener({ wss, docsDir, templatePath: INDEX_TEMPLATE })
|
|
52
52
|
let port = initialPort // actually bound port (may fall back +1 on conflict)
|
|
53
53
|
|
|
54
54
|
const cleanup = () => {
|
package/server/watch.mjs
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
// UpdateListener: watch docsDir recursively for *.html changes and broadcast
|
|
2
|
-
// debounced batches over the reload socket.
|
|
2
|
+
// debounced batches over the reload socket. The gallery template is watched
|
|
3
|
+
// too; its edits set templateChanged so gallery tabs pick them up even though
|
|
4
|
+
// nothing in docsDir changed.
|
|
3
5
|
//
|
|
4
6
|
// The gallery list is every *.html except any index.html; files outside that
|
|
5
7
|
// set never flip contentSetChanged.
|
|
6
8
|
import { existsSync, watch } from "node:fs"
|
|
7
|
-
import { basename, resolve } from "node:path"
|
|
9
|
+
import { basename, dirname, resolve } from "node:path"
|
|
8
10
|
import { isWithin, listHtmlFiles, normalizedRelative } from "./files.mjs"
|
|
9
11
|
|
|
10
12
|
const DEBOUNCE_MS = 50 // like livePreview.previewDebounceDelay
|
|
13
|
+
const TEMPLATE_TOUCH = "\u0000template" // sentinel: never a real docsDir-relative path
|
|
11
14
|
|
|
12
15
|
export class UpdateListener {
|
|
13
16
|
constructor(options = {}) {
|
|
14
17
|
this.wss = options.wss
|
|
15
18
|
this.docsDir = options.docsDir
|
|
19
|
+
this.templatePath = options.templatePath
|
|
16
20
|
this.debounceMs = options.debounceMs ?? DEBOUNCE_MS
|
|
17
21
|
this.knownFiles = new Set()
|
|
18
22
|
this.broadcastQueue = Promise.resolve()
|
|
@@ -42,12 +46,25 @@ export class UpdateListener {
|
|
|
42
46
|
this.queueBroadcast(normalizedRelative(this.docsDir, full))
|
|
43
47
|
}).on("error", () => {}),
|
|
44
48
|
)
|
|
49
|
+
|
|
50
|
+
if (this.templatePath) {
|
|
51
|
+
// Watch the template's directory: atomic saves replace the file, so the
|
|
52
|
+
// file itself would miss events.
|
|
53
|
+
const templateName = basename(this.templatePath)
|
|
54
|
+
this.watchers.push(
|
|
55
|
+
watch(dirname(this.templatePath), (event, filename) => {
|
|
56
|
+
if (filename === templateName) this.queueBroadcast(TEMPLATE_TOUCH)
|
|
57
|
+
}).on("error", () => {}),
|
|
58
|
+
)
|
|
59
|
+
}
|
|
45
60
|
}
|
|
46
61
|
|
|
47
62
|
async broadcastChanges(touched) {
|
|
48
63
|
let contentSetChanged = false
|
|
64
|
+
const templateChanged = touched.includes(TEMPLATE_TOUCH)
|
|
65
|
+
const pages = touched.filter((rel) => rel !== TEMPLATE_TOUCH)
|
|
49
66
|
|
|
50
|
-
for (const rel of
|
|
67
|
+
for (const rel of pages) {
|
|
51
68
|
if (basename(rel) === "index.html") continue // never a gallery card
|
|
52
69
|
const full = resolve(this.docsDir, rel)
|
|
53
70
|
const exists = existsSync(full)
|
|
@@ -62,7 +79,7 @@ export class UpdateListener {
|
|
|
62
79
|
}
|
|
63
80
|
}
|
|
64
81
|
|
|
65
|
-
this.wss.broadcast({ type: "change", pages
|
|
82
|
+
this.wss.broadcast({ type: "change", pages, contentSetChanged, templateChanged })
|
|
66
83
|
}
|
|
67
84
|
|
|
68
85
|
close() {
|