@noy4/docserve 0.1.0 → 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 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 24px;
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: 6px;
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", text: file.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 changes; a dropped socket recovers after restart.
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) location.reload()
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noy4/docserve",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Serves a folder of HTML as a card gallery with live reload.",
5
5
  "type": "module",
6
6
  "bin": {
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 maps cached per
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> + <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 }
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 touched) {
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: touched, contentSetChanged })
82
+ this.wss.broadcast({ type: "change", pages, contentSetChanged, templateChanged })
66
83
  }
67
84
 
68
85
  close() {