ppkit 0.1.0__py3-none-any.whl

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.
pp/commands/http.py ADDED
@@ -0,0 +1,1499 @@
1
+ """Browse project folders: rendered md, highlighted code
2
+
3
+ Like `python -m http.server`, but for reading what agents write across projects: markdown rendered with math
4
+ (KaTeX) and diagrams (mermaid), code highlighted with line anchors, one tab per project, a cross-project
5
+ "Recent" view (newest files first), and live reload while a file or folder changes.
6
+
7
+ pp http roots from config (tabs + discovery rules); no config -> the current directory
8
+ pp http ~/projects/webapp . just these folders, one tab each (a file argument serves its folder)
9
+ pp http -p 9000 port (busy -> the next free one of ~20, printed)
10
+ pp http --no-discover configured [[http.roots]] only, skip [[http.discover]] rules
11
+ pp http --config-only never fall back to the current directory
12
+ pp http -q no request log
13
+
14
+ Pages: / Recent (filter box is instant; Enter re-queries all files, `glob` takes e.g. "*.md *.py")
15
+ /t/<tab>/<path> folder listing (README rendered below) or file view /raw/<tab>/<path> raw bytes
16
+
17
+ Config: ~/.config/pp/config.toml (every key optional)
18
+
19
+ [http]
20
+ port = 8000
21
+ bind = "127.0.0.1"
22
+ recent = ["*.md"] # what the Recent view collects
23
+ exclude_dirs = ["target"] # folders Recent and discovery skip (still listed and served; deny hides),
24
+ # ADDED to the defaults (node_modules .git .venv __pycache__ dist build ...)
25
+ deny = ["*.secret"] # ADDED to the defaults (.env .env.* *.pem *.key *.p12 *.pfx id_rsa* id_ed25519*
26
+ # id_ecdsa* id_dsa* *.sqlite credentials* .netrc .git-credentials .pgpass
27
+ # .pypirc .npmrc); never listed, never served, hidden or not
28
+ follow_symlinks = false # true: serve symlinks that leave their root
29
+ hosts = ["box.tailnet.ts.net"] # Host names a proxy in front may pass through (loopback names always work)
30
+
31
+ [[http.roots]] # a fixed tab
32
+ name = "notes"
33
+ path = "~/notes"
34
+
35
+ [[http.discover]] # a rule: every dir `depth` levels under `under` holding any marker is a tab
36
+ under = "~/projects"
37
+ depth = 1
38
+ marker = [".git", "CLAUDE.md", "README.md"] # optional; without it every subdirectory qualifies
39
+
40
+ Rules are re-read every ~10 s, so a new project shows up without a restart.
41
+
42
+ Security: binds 127.0.0.1 by default, so only this machine can connect. Exposing it is a deliberate act:
43
+ `--bind 0.0.0.0` (anyone on the network can read every file under every tab) or, better, keep loopback and put an
44
+ authenticating proxy / tunnel in front. On loopback, a request naming any Host but a loopback one (or one of
45
+ `hosts`) is refused, so a web page cannot rebind its own domain to 127.0.0.1 and read the tabs. Paths are confined
46
+ to their tab's real directory (symlinks out are listed but not served, unless follow_symlinks), deny globs apply to
47
+ every path component, dot-entries are hidden from listings unless ?hidden=1, pipes and devices are never opened, and
48
+ raw HTML/SVG/XML is served with a CSP sandbox so it cannot script this origin. Every answer comes from the file or
49
+ folder that opened, so a path given to something else while a request is in flight is not served. Rendering
50
+ libraries (marked, highlight.js, KaTeX, mermaid) load from cdnjs; without network the raw text is still shown.
51
+ """
52
+ from __future__ import annotations
53
+
54
+ import argparse
55
+ import base64
56
+ import email.utils
57
+ import errno
58
+ import fnmatch
59
+ import functools
60
+ import hashlib
61
+ import html
62
+ import ipaddress
63
+ import json
64
+ import mimetypes
65
+ import os
66
+ import re
67
+ import signal
68
+ import socket
69
+ import socketserver
70
+ import stat
71
+ import sys
72
+ import threading
73
+ import time
74
+ import urllib.parse
75
+ from dataclasses import dataclass, field
76
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
77
+
78
+ from pp.lib import config, paths, ui
79
+
80
+ EXCLUDE_DIRS = ["node_modules", ".git", ".venv", "venv", "__pycache__", ".cache", ".pkgs", ".triton_cache", "dist",
81
+ "build", ".shadow-cljs", ".cpcache", ".mypy_cache", ".pytest_cache", ".tox", "target", ".next",
82
+ "site-packages", "bazel-out", "bazel-bin", "bazel-testlogs"]
83
+ DENY = [".env", ".env.*", "*.pem", "*.key", "id_rsa*", "id_ed25519*", "id_ecdsa*", "id_dsa*", "*.p12", "*.pfx",
84
+ "*.sqlite", "credentials*", ".netrc", ".git-credentials", ".pgpass", ".pypirc", ".npmrc"]
85
+
86
+ CDNJS = "https://cdnjs.cloudflare.com/ajax/libs"
87
+ CDN = {
88
+ "marked": f"{CDNJS}/marked/12.0.2/marked.min.js",
89
+ "hljs": f"{CDNJS}/highlight.js/11.9.0/highlight.min.js",
90
+ "hljsLang": f"{CDNJS}/highlight.js/11.9.0/languages/{{}}.min.js",
91
+ "hljsLight": f"{CDNJS}/highlight.js/11.9.0/styles/github.min.css",
92
+ "hljsDark": f"{CDNJS}/highlight.js/11.9.0/styles/github-dark.min.css",
93
+ "katex": f"{CDNJS}/KaTeX/0.16.9/katex.min.js",
94
+ "katexCss": f"{CDNJS}/KaTeX/0.16.9/katex.min.css",
95
+ "mermaid": f"{CDNJS}/mermaid/10.9.1/mermaid.min.js",
96
+ }
97
+
98
+ MD_EXT = {".md", ".markdown", ".mdown", ".mkd", ".mdx"}
99
+ IMG_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico", ".avif"}
100
+ VIDEO_EXT = {".mp4", ".webm", ".mov", ".m4v", ".ogv"}
101
+ AUDIO_EXT = {".mp3", ".wav", ".ogg", ".oga", ".flac", ".m4a", ".opus"}
102
+ README_NAMES = ("readme.md", "readme.markdown", "index.md")
103
+
104
+ # extension -> highlight.js language (core bundle, or a languages/<x>.min.js the page loads on demand)
105
+ LANG = {
106
+ "py": "python", "pyi": "python", "pyx": "python", "bzl": "python", "star": "python", "ipynb": "json",
107
+ "js": "javascript", "mjs": "javascript", "cjs": "javascript", "jsx": "javascript",
108
+ "ts": "typescript", "mts": "typescript", "cts": "typescript", "tsx": "typescript",
109
+ "clj": "clojure", "cljs": "clojure", "cljc": "clojure", "edn": "clojure", "bb": "clojure",
110
+ "sh": "bash", "bash": "bash", "zsh": "bash", "fish": "bash", "ksh": "bash",
111
+ "toml": "ini", "ini": "ini", "cfg": "ini", "conf": "ini", "properties": "properties", "env": "bash",
112
+ "yaml": "yaml", "yml": "yaml", "json": "json", "jsonl": "json", "json5": "json", "geojson": "json",
113
+ "rs": "rust", "go": "go", "c": "c", "h": "c", "cpp": "cpp", "cc": "cpp", "cxx": "cpp", "hpp": "cpp",
114
+ "hh": "cpp", "hxx": "cpp", "cu": "cpp", "cuh": "cpp", "ino": "cpp", "m": "objectivec", "mm": "objectivec",
115
+ "java": "java", "kt": "kotlin", "kts": "kotlin", "scala": "scala", "groovy": "groovy", "gradle": "gradle",
116
+ "rb": "ruby", "lua": "lua", "sql": "sql", "pl": "perl", "pm": "perl", "php": "php", "r": "r", "swift": "swift",
117
+ "cs": "csharp", "fs": "fsharp", "hs": "haskell", "ex": "elixir", "exs": "elixir", "erl": "erlang",
118
+ "ml": "ocaml", "jl": "julia", "dart": "dart", "el": "lisp", "lisp": "lisp", "scm": "scheme", "rkt": "scheme",
119
+ "vim": "vim", "nix": "nix", "proto": "protobuf", "cmake": "cmake", "mk": "makefile", "make": "makefile",
120
+ "css": "css", "scss": "scss", "less": "less", "html": "xml", "htm": "xml", "xml": "xml", "svg": "xml",
121
+ "xsl": "xml", "plist": "xml", "vue": "xml", "tex": "latex", "sty": "latex", "cls": "latex", "bib": "latex",
122
+ "diff": "diff", "patch": "diff", "graphql": "graphql", "gql": "graphql", "wasm": "wasm", "wat": "wasm",
123
+ "ps1": "powershell", "bat": "dos", "cmd": "dos", "awk": "awk", "nginx": "nginx", "dockerfile": "dockerfile",
124
+ "glsl": "glsl", "frag": "glsl", "vert": "glsl", "asm": "x86asm", "s": "x86asm", "ll": "llvm",
125
+ "txt": "plaintext", "log": "plaintext", "csv": "plaintext", "tsv": "plaintext",
126
+ }
127
+ LANG_BY_NAME = {
128
+ "makefile": "makefile", "gnumakefile": "makefile", "dockerfile": "dockerfile", "containerfile": "dockerfile",
129
+ "build": "python", "workspace": "python", "build.bazel": "python", "module.bazel": "python",
130
+ "workspace.bazel": "python", "cmakelists.txt": "cmake", "jenkinsfile": "groovy", "vagrantfile": "ruby",
131
+ "gemfile": "ruby", "rakefile": "ruby", ".bashrc": "bash", ".zshrc": "bash", ".profile": "bash",
132
+ ".bash_profile": "bash", ".zprofile": "bash", ".gitignore": "bash", ".dockerignore": "bash",
133
+ ".editorconfig": "ini", ".gitconfig": "ini", "pipfile": "ini", "go.mod": "go",
134
+ }
135
+ SHEBANG = {"python": "python", "bash": "bash", "sh": "bash", "zsh": "bash", "node": "javascript", "bb": "clojure",
136
+ "perl": "perl", "ruby": "ruby", "lua": "lua"}
137
+
138
+
139
+ @dataclass
140
+ class Settings:
141
+ port: int = 8000
142
+ bind: str = "127.0.0.1"
143
+ recent: list = field(default_factory=lambda: ["*.md"])
144
+ exclude_dirs: set = field(default_factory=lambda: set(EXCLUDE_DIRS))
145
+ deny: list = field(default_factory=lambda: list(DENY))
146
+ follow_symlinks: bool = False
147
+ hosts: list = field(default_factory=list) # Host names a browser may use besides loopback ones
148
+ cache_ttl: float = 10.0
149
+ scan_budget: float = 2.0 # seconds for one Recent scan over all tabs
150
+ scan_depth: int = 8
151
+ scan_entries: int = 100_000
152
+ recent_limit: int = 300
153
+ list_cap: int = 5000
154
+ embed_cap: int = 2_000_000 # bytes of a text file embedded in its view page
155
+
156
+ @classmethod
157
+ def from_config(cls, cfg: dict) -> "Settings":
158
+ """Settings from the [http] table; a value of the wrong type is a ConfigError naming the key."""
159
+ s = cls()
160
+ config.check_keys(cfg, "[http]", KEYS)
161
+
162
+ def strs(k):
163
+ v = cfg.get(k)
164
+ if isinstance(v, str):
165
+ return [v]
166
+ if v is None or (isinstance(v, list) and all(isinstance(x, str) for x in v)):
167
+ return list(v or [])
168
+ raise config.ConfigError(f"[http] {k} = {v!r}: a string or a list of strings")
169
+
170
+ def num(k, typ, lo):
171
+ v = cfg[k]
172
+ if isinstance(v, bool) or not isinstance(v, (int, float) if typ is float else int) or v < lo:
173
+ raise config.ConfigError(f"[http] {k} = {v!r}: {'a number' if typ is float else 'an integer'} >= {lo}")
174
+ return typ(v)
175
+ if "port" in cfg:
176
+ s.port = num("port", int, 0)
177
+ if s.port > 65535:
178
+ raise config.ConfigError(f"[http] port = {s.port}: a port is 0..65535")
179
+ if "bind" in cfg:
180
+ if not isinstance(cfg["bind"], str):
181
+ raise config.ConfigError(f"[http] bind = {cfg['bind']!r}: an address like \"127.0.0.1\"")
182
+ s.bind = cfg["bind"]
183
+ if "recent" in cfg:
184
+ s.recent = strs("recent") or s.recent
185
+ s.exclude_dirs |= set(strs("exclude_dirs"))
186
+ s.deny += [d for d in strs("deny") if d not in s.deny]
187
+ if not isinstance(cfg.get("follow_symlinks", False), bool):
188
+ raise config.ConfigError(f"[http] follow_symlinks = {cfg['follow_symlinks']!r}: true or false")
189
+ s.follow_symlinks = cfg.get("follow_symlinks", False)
190
+ s.hosts = strs("hosts")
191
+ for k in ("cache_ttl", "scan_budget"):
192
+ if k in cfg:
193
+ setattr(s, k, num(k, float, 0))
194
+ for k in ("scan_depth", "scan_entries", "recent_limit", "list_cap", "embed_cap"):
195
+ if k in cfg:
196
+ setattr(s, k, num(k, int, 1))
197
+ return s
198
+
199
+
200
+ KEYS = ("port", "bind", "recent", "exclude_dirs", "deny", "follow_symlinks", "hosts", "roots", "discover", "cache_ttl",
201
+ "scan_budget", "scan_depth", "scan_entries", "recent_limit", "list_cap", "embed_cap")
202
+
203
+
204
+ def tables(cfg: dict, key: str, need: str) -> list[dict]:
205
+ """[[http.<key>]]: an array of tables, each with `need`; anything else is a ConfigError."""
206
+ v = cfg.get(key, [])
207
+ if not isinstance(v, list) or not all(isinstance(t, dict) for t in v):
208
+ raise config.ConfigError(f"[http] {key} must be written [[http.{key}]] (an array of tables)")
209
+ for t in v:
210
+ if not isinstance(t.get(need), str) or not t[need]:
211
+ raise config.ConfigError(f"[[http.{key}]] {t}: needs {need} = \"/some/dir\"")
212
+ for t in v if key == "discover" else []:
213
+ d, m = t.get("depth", 1), t.get("marker", [])
214
+ if isinstance(d, bool) or not isinstance(d, int) or d < 1:
215
+ raise config.ConfigError(f"[[http.discover]] under = {t['under']!r}: depth = {d!r} is not an integer >= 1")
216
+ if not isinstance(m, str) and not (isinstance(m, list) and all(isinstance(x, str) for x in m)):
217
+ raise config.ConfigError(f"[[http.discover]] under = {t['under']!r}: marker is a string or a list of them")
218
+ return v
219
+
220
+
221
+ def _globs(spec) -> list[str]:
222
+ if isinstance(spec, str):
223
+ spec = spec.replace(",", " ").split()
224
+ return [g for g in (spec or []) if g]
225
+
226
+
227
+ def _match(globs: list[str], name: str, rel: str) -> bool:
228
+ name, rel = name.lower(), rel.lower()
229
+ for g in globs:
230
+ g = g.lower()
231
+ if "/" in g:
232
+ if fnmatch.fnmatchcase(rel, g) or (g.startswith("**/") and fnmatch.fnmatchcase(rel, g[3:])):
233
+ return True
234
+ elif fnmatch.fnmatchcase(name, g):
235
+ return True
236
+ return False
237
+
238
+
239
+ class Cached:
240
+ """A value per key, computed by one caller at a time and no oftener than once every `ttl` seconds.
241
+
242
+ `get(key)` returns the value for that key, computing it with `compute(key)` when there is none or it is
243
+ older than the ttl. The computation runs under the lock, so callers that arrive while a value is stale
244
+ make one computation between them and all read its answer, and this is the one place a value is stored:
245
+ no two computations overlap, so no answer can replace a newer one. A value is stamped with the time its
246
+ computation started, so a slow scan is not passed off as fresh, and keys older than the ttl are dropped,
247
+ so the keys a request names cannot pile up.
248
+ """
249
+
250
+ def __init__(self, ttl: float, compute):
251
+ self.ttl = ttl
252
+ self._compute = compute
253
+ self._lock = threading.Lock()
254
+ self._values: dict = {}
255
+
256
+ def get(self, key=()):
257
+ with self._lock:
258
+ now = time.monotonic()
259
+ hit = self._values.get(key)
260
+ if hit is not None and now - hit[0] < self.ttl:
261
+ return hit[1]
262
+ value = self._compute(key)
263
+ self._values = {k: v for k, v in self._values.items() if now - v[0] < self.ttl}
264
+ self._values[key] = (now, value)
265
+ return value
266
+
267
+
268
+ class App:
269
+ """Roots (fixed + discovered, cached), path confinement, the Recent scan."""
270
+
271
+ def __init__(self, settings: Settings, fixed: list[tuple[str, str]], rules: list[dict], quiet: bool = False):
272
+ self.s = settings
273
+ self.fixed = fixed
274
+ self.rules = rules
275
+ self.quiet = quiet
276
+ self._tabs = Cached(settings.cache_ttl, self._walk)
277
+ self._found = Cached(settings.cache_ttl, self._scan_once)
278
+
279
+ # ---- roots ----
280
+
281
+ def denied(self, name: str) -> bool:
282
+ n = name.lower()
283
+ return any(fnmatch.fnmatchcase(n, p.lower()) for p in self.s.deny)
284
+
285
+ def _discover(self, rule: dict) -> list[tuple[str, str]]:
286
+ if not rule.get("under"):
287
+ return []
288
+ under = paths.expand(str(rule["under"]))
289
+ depth = max(1, int(rule.get("depth", 1)))
290
+ markers = rule.get("marker") or []
291
+ markers = [markers] if isinstance(markers, str) else list(markers)
292
+ level = [(under, "")]
293
+ for _ in range(depth):
294
+ nxt = []
295
+ for d, rel in level:
296
+ try:
297
+ entries = sorted(os.scandir(d), key=lambda e: e.name)
298
+ except OSError:
299
+ continue
300
+ for e in entries:
301
+ if e.name.startswith(".") or e.name in self.s.exclude_dirs or self.denied(e.name):
302
+ continue
303
+ try:
304
+ if not e.is_dir():
305
+ continue
306
+ except OSError:
307
+ continue
308
+ nxt.append((e.path, f"{rel}/{e.name}" if rel else e.name))
309
+ level = nxt
310
+ return [(rel, d) for d, rel in level
311
+ if not markers or any(os.path.exists(os.path.join(d, m)) for m in markers)]
312
+
313
+ def roots(self) -> dict[str, str]:
314
+ """tab name -> real directory, fixed tabs first, then discovered ones by name. One walk every
315
+ cache_ttl seconds, whoever asks."""
316
+ return self._tabs.get()
317
+
318
+ def _walk(self, _key) -> dict[str, str]:
319
+ found = list(self.fixed)
320
+ disc = []
321
+ for rule in self.rules:
322
+ disc += self._discover(rule)
323
+ found += sorted(disc)
324
+ out: dict[str, str] = {}
325
+ seen = set()
326
+ for name, p in found:
327
+ real = os.path.realpath(paths.expand(p))
328
+ if real in seen or not os.path.isdir(real):
329
+ continue
330
+ seen.add(real)
331
+ base, k = name or os.path.basename(real) or "root", 2
332
+ name = base
333
+ while name in out:
334
+ name, k = f"{base}-{k}", k + 1
335
+ out[name] = real
336
+ return out
337
+
338
+ def resolve(self, root_name: str, segs: list[str]) -> tuple[str, str] | None:
339
+ """(root real dir, real path) for a tab-relative path, or None when it must 404."""
340
+ root = self.roots().get(root_name)
341
+ if root is None:
342
+ return None
343
+ for s in segs:
344
+ if s in (".", "..") or "/" in s or "\\" in s or "\0" in s or self.denied(s):
345
+ return None
346
+ real = os.path.realpath(os.path.join(root, *segs))
347
+ inside = real == root or real.startswith(root.rstrip(os.sep) + os.sep)
348
+ if not inside and not self.s.follow_symlinks:
349
+ return None
350
+ comps = os.path.relpath(real, root).split(os.sep) if inside and real != root else [os.path.basename(real)]
351
+ if real != root and any(self.denied(c) for c in comps):
352
+ return None
353
+ if not os.path.exists(real):
354
+ return None
355
+ return root, real
356
+
357
+ # ---- Recent ----
358
+
359
+ def _scan(self, roots: dict[str, str], globs: list[str]) -> tuple[list[dict], bool]:
360
+ items: list[dict] = []
361
+ truncated = False
362
+ t0 = time.monotonic()
363
+ n = 0
364
+ names = list(roots)
365
+ for i, name in enumerate(names):
366
+ # each tab gets its share of the budget; time a tab leaves unused passes to the next
367
+ deadline = t0 + self.s.scan_budget * (i + 1) / len(names)
368
+ cap = self.s.scan_entries * (i + 1) // len(names)
369
+ stack = [(roots[name], "", 0)]
370
+ while stack:
371
+ d, rel, depth = stack.pop()
372
+ try:
373
+ it = os.scandir(d)
374
+ except OSError:
375
+ continue
376
+ with it:
377
+ for e in it:
378
+ n += 1
379
+ if n > cap or time.monotonic() > deadline:
380
+ truncated = True
381
+ stack = []
382
+ break
383
+ nm = e.name
384
+ if nm.startswith(".") or self.denied(nm):
385
+ continue
386
+ try:
387
+ if e.is_symlink() and not self.s.follow_symlinks:
388
+ continue
389
+ if e.is_dir():
390
+ if nm not in self.s.exclude_dirs and depth < self.s.scan_depth:
391
+ stack.append((e.path, f"{rel}{nm}/", depth + 1))
392
+ continue
393
+ if not _match(globs, nm, rel + nm) or not e.is_file(): # no pipes, sockets
394
+ continue
395
+ st = e.stat()
396
+ except OSError:
397
+ continue
398
+ items.append({"root": name, "path": rel + nm, "size": st.st_size, "mtime": st.st_mtime})
399
+ items.sort(key=lambda x: -x["mtime"])
400
+ return items, truncated
401
+
402
+ def _scan_once(self, key) -> tuple[list[dict], bool, float]:
403
+ """One scan for a (roots, globs) key, with how long it took."""
404
+ t0 = time.monotonic()
405
+ items, truncated = self._scan(dict(key[0]), list(key[1]))
406
+ return items, truncated, time.monotonic() - t0
407
+
408
+ def recent(self, globs: list[str] | None = None, q: str = "", limit: int | None = None) -> dict:
409
+ globs = globs or self.s.recent
410
+ roots = self.roots()
411
+ items, truncated, took = self._found.get((tuple(roots.items()), tuple(globs)))
412
+ if q:
413
+ ql = q.lower()
414
+ items = [x for x in items if ql in f"{x['root']}/{x['path']}".lower()]
415
+ return {"items": items[: limit or self.s.recent_limit], "total": len(items), "truncated": truncated,
416
+ "took": round(took, 3), "globs": globs, "roots": len(roots)}
417
+
418
+
419
+ # ---- HTML -----------------------------------------------------------------------------------------
420
+
421
+ def esc(s) -> str:
422
+ return html.escape(str(s), quote=True)
423
+
424
+
425
+ def q(s: str) -> str:
426
+ return urllib.parse.quote(s, safe="")
427
+
428
+
429
+ def turl(root: str, segs: list[str], is_dir: bool = False, kind: str = "t") -> str:
430
+ tail = "/".join(q(s) for s in segs)
431
+ return f"/{kind}/{q(root)}/" + tail + ("/" if is_dir and segs else "")
432
+
433
+
434
+ def _json_script(id_: str, obj) -> str:
435
+ data = json.dumps(obj, ensure_ascii=False).replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
436
+ return f'<script type="application/json" id="{id_}">{data}</script>'
437
+
438
+
439
+ def _age(ts: float) -> str:
440
+ return ui.human_age(ts).replace(" ago", "")
441
+
442
+
443
+ def _when(ts: float) -> str:
444
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts))
445
+
446
+
447
+ FAVICON = ("data:image/svg+xml," + urllib.parse.quote(
448
+ "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='3' fill='#0969da'/>"
449
+ "<text x='8' y='12' font-size='10' text-anchor='middle' fill='white' font-family='sans-serif'>pp</text></svg>"))
450
+
451
+
452
+ def page(app: App, title: str, active: str | None, body: str, data: dict, src: str | None = None,
453
+ libs_css: bool = False) -> bytes:
454
+ roots = app.roots()
455
+ tabs = [f'<a href="/" class="{"on" if active is None else ""}">Recent</a>']
456
+ tabs += [f'<a href="{turl(n, [], True)}" class="{"on" if n == active else ""}" title="{esc(p)}">{esc(n)}</a>'
457
+ for n, p in roots.items()]
458
+ head_css = ""
459
+ if libs_css:
460
+ head_css = (f'<link rel="stylesheet" href="{CDN["hljsLight"]}" media="(prefers-color-scheme: light)">'
461
+ f'<link rel="stylesheet" href="{CDN["hljsDark"]}" media="(prefers-color-scheme: dark)">'
462
+ f'<link rel="stylesheet" href="{CDN["katexCss"]}">')
463
+ data = dict(data, cdn=CDN)
464
+ doc = (
465
+ "<!doctype html><html lang=en><head><meta charset=utf-8>"
466
+ '<meta name=viewport content="width=device-width,initial-scale=1">'
467
+ f"<title>{esc(title)}</title><link rel=icon href=\"{FAVICON}\">{head_css}<style>{CSS}</style></head><body>"
468
+ f'<header><nav class="tabs"><span class="brand">pp</span>{"".join(tabs)}</nav></header>'
469
+ f"<main>{body}</main>"
470
+ + _json_script("pp-page", data)
471
+ + (_json_script("pp-src", src) if src is not None else "")
472
+ + f"<script>{JS}</script></body></html>"
473
+ )
474
+ return doc.encode("utf-8")
475
+
476
+
477
+ def crumbs(root: str, segs: list[str], is_dir: bool) -> str:
478
+ parts = [f'<a href="{turl(root, [], True)}">{esc(root)}</a>']
479
+ for i, s in enumerate(segs):
480
+ if i == len(segs) - 1:
481
+ parts.append(f"<b>{esc(s)}</b>")
482
+ else:
483
+ parts.append(f'<a href="{turl(root, segs[: i + 1], True)}">{esc(s)}</a>')
484
+ return '<nav class="crumbs">' + ' <span class="sep">/</span> '.join(parts) + "</nav>"
485
+
486
+
487
+ def guess_lang(name: str, head: str) -> str:
488
+ low = name.lower()
489
+ if low in LANG_BY_NAME:
490
+ return LANG_BY_NAME[low]
491
+ if low.endswith(".dockerfile") or low.startswith("dockerfile."):
492
+ return "dockerfile"
493
+ ext = low.rsplit(".", 1)[1] if "." in low.strip(".") else ""
494
+ if ext in LANG:
495
+ return LANG[ext]
496
+ if head.startswith("#!"):
497
+ first = head.split("\n", 1)[0]
498
+ words = first[2:].replace("/", " ").split()
499
+ for w in reversed(words):
500
+ w = w.rstrip("0123456789.")
501
+ if w in SHEBANG:
502
+ return SHEBANG[w]
503
+ return ""
504
+
505
+
506
+ def raw_type(name: str, head: bytes) -> tuple[str, bool]:
507
+ """(Content-Type, needs a CSP sandbox) for raw serving. A type can only come from the name's extension;
508
+ everything else about the answer comes from the open file. The leading slash keeps a name like `data:x`
509
+ an extension rather than a URL scheme."""
510
+ mime, _ = mimetypes.guess_type("/" + name)
511
+ if mime == "image/svg+xml":
512
+ return mime, True
513
+ if mime and (mime.split("/")[0] in ("image", "audio", "video", "font") or mime == "application/pdf"):
514
+ return mime, False
515
+ if b"\0" in head:
516
+ return mime or "application/octet-stream", False
517
+ if mime in ("text/html", "application/xhtml+xml"):
518
+ return "text/html; charset=utf-8", True
519
+ if mime in ("application/xml", "text/xml"):
520
+ return mime + "; charset=utf-8", True
521
+ if mime == "text/css":
522
+ return "text/css; charset=utf-8", False
523
+ if mime in ("text/javascript", "application/javascript"):
524
+ return "text/javascript; charset=utf-8", False
525
+ if mime == "application/json":
526
+ return "application/json; charset=utf-8", False
527
+ return "text/plain; charset=utf-8", False
528
+
529
+
530
+ # ---- request handling -----------------------------------------------------------------------------
531
+
532
+ def _loopback(host: str) -> bool:
533
+ """A name or address that only ever means this machine."""
534
+ h = host.strip("[]").lower().rstrip(".")
535
+ if h == "localhost" or h.endswith(".localhost"):
536
+ return True
537
+ try:
538
+ return ipaddress.ip_address(h).is_loopback
539
+ except ValueError:
540
+ return False
541
+
542
+
543
+ class Opened:
544
+ """What a request may act on: a folder or file inside a tab, open, with the fstat of that open fd.
545
+
546
+ True when something opened. `why` says what to answer when nothing did: "gone" (nothing there, not this
547
+ tab's, or not what the approved name means any more), "kind" (a folder where a file was asked for, or the
548
+ other way round), "irregular" (a pipe, socket or device, whose contents pp does not read) or "unreadable"
549
+ (`error` is the error the open gave). Used as `with`, so the fd is closed on every way out.
550
+ """
551
+
552
+ __slots__ = ("fd", "st", "why", "error")
553
+
554
+ def __init__(self, fd: int | None = None, st: os.stat_result | None = None, why: str = "",
555
+ error: OSError | None = None):
556
+ self.fd, self.st, self.why, self.error = fd, st, why, error
557
+
558
+ def __bool__(self) -> bool:
559
+ return self.fd is not None
560
+
561
+ def __enter__(self) -> "Opened":
562
+ return self
563
+
564
+ def __exit__(self, *exc) -> bool:
565
+ if self.fd is not None:
566
+ os.close(self.fd)
567
+ self.fd = None
568
+ return False
569
+
570
+ @property
571
+ def is_dir(self) -> bool:
572
+ return stat.S_ISDIR(self.st.st_mode)
573
+
574
+ def entries(self):
575
+ """What this folder holds, read through the fd it opened on: the listing is of the folder the request
576
+ acted on, whatever its name means by now. Used as `with`, as os.scandir is."""
577
+ return os.scandir(self.fd)
578
+
579
+
580
+ def _not_opened(e: OSError, real: str) -> Opened:
581
+ """Why an open failed: a name that has gone in the meantime is not found, anything else is pp not reading
582
+ what is there. The error names the path that was asked for, not the fd it was opened through."""
583
+ gone = e.errno in (errno.ENOENT, errno.ENOTDIR, errno.ELOOP)
584
+ return Opened(why="gone" if gone else "unreadable", error=OSError(e.errno, e.strerror, real))
585
+
586
+
587
+ def open_in_tab(app: App, root: str, segs: list[str], *, dir_ok: bool, file_ok: bool) -> Opened:
588
+ """Open what a tab-relative path names, so every answer about it comes from the file that opened.
589
+
590
+ App.resolve approves the name. Nothing freezes the tree between a check and an open, so a directory
591
+ component swapped for a symlink in between would be followed and the answer would be a file outside every
592
+ tab. The first open therefore takes an O_PATH fd, which reads nothing and starts no device, and
593
+ /proc/self/fd says whether that fd is the path that was approved. The fstat of that fd says what this is,
594
+ and only a folder or a regular file is then opened for its contents, through the fd already held, so the
595
+ bytes and the fstat returned here belong to one file. Every decision the request makes comes from that
596
+ fstat, the live-reload baseline included, so a page's baseline is the file its body was read from.
597
+
598
+ A tab's own root is a folder and nothing else, so an empty path is never a file here, whatever the caller
599
+ asks for: App._walk admits a directory and keeps the name only while it is one, and a tab whose directory
600
+ has become a regular file is stale. That holds for every endpoint, so none of them can be left serving it.
601
+ """
602
+ file_ok = file_ok and bool(segs)
603
+ r = app.resolve(root, segs)
604
+ if r is None:
605
+ return Opened(why="gone")
606
+ real = r[1]
607
+ try:
608
+ pin = os.open(real, os.O_PATH)
609
+ except OSError as e:
610
+ return _not_opened(e, real)
611
+ try:
612
+ if os.path.realpath(f"/proc/self/fd/{pin}") != real:
613
+ return Opened(why="gone")
614
+ st = os.fstat(pin)
615
+ if stat.S_ISDIR(st.st_mode):
616
+ if not dir_ok:
617
+ return Opened(why="kind")
618
+ flags = os.O_RDONLY | os.O_DIRECTORY
619
+ elif stat.S_ISREG(st.st_mode):
620
+ if not file_ok:
621
+ return Opened(why="kind")
622
+ flags = os.O_RDONLY
623
+ else:
624
+ return Opened(why="irregular")
625
+ try:
626
+ fd = os.open(f"/proc/self/fd/{pin}", flags)
627
+ except OSError as e:
628
+ return _not_opened(e, real)
629
+ return Opened(fd, os.fstat(fd))
630
+ finally:
631
+ os.close(pin)
632
+
633
+
634
+ class Handler(BaseHTTPRequestHandler):
635
+ app: App = None # set on the subclass made by make_server
636
+ host_guard = True # bound to loopback: refuse Host names that are not (DNS rebinding)
637
+ server_version = "pp-http"
638
+ sys_version = ""
639
+
640
+ def log_request(self, code="-", size="-"):
641
+ code = getattr(code, "value", code)
642
+ if self.app.quiet or (self.path.startswith("/_pp/stat") and code == 200):
643
+ return
644
+ sys.stderr.write(f"{time.strftime('%H:%M:%S')} {code} {self.command} "
645
+ f"{ui.printable(urllib.parse.unquote(self.path)[:160], tabs=False)}\n")
646
+
647
+ def log_message(self, fmt, *args):
648
+ if not self.app.quiet:
649
+ sys.stderr.write(f"{time.strftime('%H:%M:%S')} {ui.printable(fmt % args, tabs=False)}\n")
650
+
651
+ def do_GET(self):
652
+ self._dispatch()
653
+
654
+ def do_HEAD(self):
655
+ self._dispatch()
656
+
657
+ # ---- plumbing ----
658
+
659
+ def _send(self, code: int, body: bytes, ctype: str, headers: dict | None = None):
660
+ self.send_response(code)
661
+ self.send_header("Content-Type", ctype)
662
+ if ctype.startswith("text/html"):
663
+ self.send_header("Content-Security-Policy", _csp())
664
+ self.send_header("Content-Length", str(len(body)))
665
+ self.send_header("Cache-Control", "no-store")
666
+ for k, v in (headers or {}).items():
667
+ self.send_header(k, v)
668
+ self.end_headers()
669
+ if self.command != "HEAD":
670
+ self.wfile.write(body)
671
+
672
+ def _json(self, obj, code: int = 200):
673
+ self._send(code, json.dumps(obj, ensure_ascii=False).encode(), "application/json; charset=utf-8")
674
+
675
+ def _redirect(self, loc: str):
676
+ self.send_response(301)
677
+ self.send_header("Location", loc)
678
+ self.send_header("Content-Length", "0")
679
+ self.end_headers()
680
+
681
+ def _not_found(self, what: str = ""):
682
+ body = page(self.app, "not found", "", f'<p class="note">Not found{": " + esc(what) if what else ""}.</p>'
683
+ '<p><a href="/">Recent</a></p>', {"kind": "none"})
684
+ self._send(404, body, "text/html; charset=utf-8")
685
+
686
+ def _unreadable(self, root, ex):
687
+ """403: what opened cannot be read, and the error says why."""
688
+ self._send(403, page(self.app, "unreadable", root, f'<p class="note">{esc(ex)}</p>', {"kind": "none"}),
689
+ "text/html; charset=utf-8")
690
+
691
+ def _refuse(self, o: Opened, root, segs):
692
+ """The answer when nothing opened: 403 for what pp will not read, 404 for everything else."""
693
+ if o.why == "irregular":
694
+ return self._not_a_file(root, segs)
695
+ if o.why == "unreadable":
696
+ return self._unreadable(root, o.error)
697
+ return self._not_found("/".join([root] + segs))
698
+
699
+ def _host_ok(self) -> bool:
700
+ h = self.headers.get("Host")
701
+ if not self.host_guard or h is None: # no Host: an HTTP/1.0 tool, never a browser
702
+ return True
703
+ try:
704
+ name = urllib.parse.urlsplit("//" + h).hostname or ""
705
+ except ValueError:
706
+ return False
707
+ return _loopback(name) or name in (x.lower() for x in self.app.s.hosts)
708
+
709
+ def _dispatch(self):
710
+ try:
711
+ if not self._host_ok():
712
+ msg = (f"pp http: Host {self.headers['Host']!r} is not a loopback name. If a proxy in front of pp "
713
+ "uses it, add it to [http] hosts in ~/.config/pp/config.toml.\n")
714
+ return self._send(403, msg.encode(), "text/plain; charset=utf-8")
715
+ u = urllib.parse.urlsplit(self.path)
716
+ qs = {k: v[-1] for k, v in urllib.parse.parse_qs(u.query).items()}
717
+ if not u.path.startswith("/"):
718
+ return self._not_found()
719
+ raw = u.path.split("/")[1:]
720
+ if u.path == "/":
721
+ return self._recent_page(qs)
722
+ if u.path == "/_pp/stat":
723
+ return self._stat(qs)
724
+ if u.path == "/_pp/recent":
725
+ return self._recent_json(qs)
726
+ if raw[0] in ("t", "raw") and len(raw) >= 2 and raw[1]:
727
+ root = urllib.parse.unquote(raw[1])
728
+ segs = [urllib.parse.unquote(s) for s in raw[2:] if s != ""]
729
+ if raw[0] == "t":
730
+ return self._view(root, segs, u.path.endswith("/") and len(raw) > 2, qs)
731
+ return self._raw(root, segs, qs)
732
+ return self._not_found()
733
+ except (BrokenPipeError, ConnectionResetError):
734
+ pass
735
+
736
+ # ---- endpoints ----
737
+
738
+ def _stat(self, qs):
739
+ """What the live reload polls: the mtime and size of the file or folder that opened.
740
+
741
+ It answers from an fd like every other endpoint, so the page it feeds compares its baseline against
742
+ the same file it was built from, and a name given to something else since is not answered for."""
743
+ segs = [s for s in qs.get("path", "").split("/") if s]
744
+ with open_in_tab(self.app, qs.get("root", ""), segs, dir_ok=True, file_ok=True) as o:
745
+ if not o:
746
+ return self._json({"error": "not found"}, 404)
747
+ self._json({"mtime": o.st.st_mtime, "size": o.st.st_size, "dir": o.is_dir})
748
+
749
+ def _recent_json(self, qs):
750
+ try:
751
+ limit = int(qs.get("limit") or 0) or None
752
+ except ValueError:
753
+ limit = None
754
+ self._json(self.app.recent(_globs(qs.get("glob")), qs.get("q", ""), limit))
755
+
756
+ def _recent_page(self, qs):
757
+ glob_s, qtext = qs.get("glob", ""), qs.get("q", "")
758
+ res = self.app.recent(_globs(glob_s), qtext)
759
+ rows = []
760
+ for x in res["items"]:
761
+ segs = x["path"].split("/")
762
+ rows.append(
763
+ f'<tr data-k="{esc((x["root"] + "/" + x["path"]).lower())}">'
764
+ f'<td><a class="tag" href="{turl(x["root"], [], True)}">{esc(x["root"])}</a></td>'
765
+ f'<td class="p"><a href="{turl(x["root"], segs)}">{esc(x["path"])}</a></td>'
766
+ f'<td class="r hide-sm">{ui.human_size(x["size"])}</td>'
767
+ f'<td class="r" title="{_when(x["mtime"])}">{_age(x["mtime"])}</td></tr>')
768
+ note = (f'<span id="pp-count">{len(res["items"])}</span> of {res["total"]} files matching '
769
+ f'{esc(" ".join(res["globs"]))} across {res["roots"]} tab(s), scanned in {res["took"]:.2f}s')
770
+ if res["truncated"]:
771
+ note += "; <b>scan truncated</b> (time/entry cap): narrow the glob or the tabs"
772
+ body = (
773
+ '<form class="bar" method="get" action="/">'
774
+ f'<input id="pp-filter" name="q" value="{esc(qtext)}" placeholder="filter… (instant; Enter searches all)" autocomplete="off">'
775
+ f'<input name="glob" value="{esc(glob_s)}" placeholder="{esc(" ".join(self.app.s.recent))}" title="globs, space-separated" class="glob">'
776
+ '<button type="submit">scan</button><span id="pp-upd" class="badge" hidden></span></form>'
777
+ '<div id="pp-main">'
778
+ + ('<div class="scroll"><table class="list"><thead><tr><th>tab</th><th>path</th><th class="r hide-sm">size</th>'
779
+ '<th class="r">modified</th></tr></thead><tbody>' + "".join(rows) + "</tbody></table></div>"
780
+ if rows else '<p class="note">Nothing matches.</p>')
781
+ + f'<p class="note">{note}</p></div>')
782
+ self._send(200, page(self.app, "Recent (pp http)", None, body, {"kind": "recent"}), "text/html; charset=utf-8")
783
+
784
+ def _view(self, root: str, segs: list[str], slash: bool, qs):
785
+ with open_in_tab(self.app, root, segs, dir_ok=True, file_ok=True) as o:
786
+ if not o:
787
+ return self._refuse(o, root, segs)
788
+ if o.is_dir:
789
+ if segs and not slash:
790
+ return self._redirect(turl(root, segs, True) + (f"?{urllib.parse.urlencode(qs)}" if qs else ""))
791
+ if not segs and not self.path.split("?")[0].endswith("/"):
792
+ return self._redirect(turl(root, [], True))
793
+ return self._dir(root, segs, o, qs)
794
+ return self._file(root, segs, o)
795
+
796
+ def _dir(self, root, segs, o: Opened, qs):
797
+ hidden = qs.get("hidden") == "1"
798
+ sort = qs.get("sort", "name")
799
+ entries, capped, dots = [], False, 0
800
+ try:
801
+ with o.entries() as it: # the folder that opened, listing itself through its own fd
802
+ for e in it:
803
+ if self.app.denied(e.name):
804
+ continue
805
+ if e.name.startswith(".") and not hidden:
806
+ dots += 1
807
+ continue
808
+ link = e.is_symlink()
809
+ if link and not self.app.resolve(root, segs + [e.name]):
810
+ # leaves the tab, is denied or dangles: named, but neither linked nor stat'ed
811
+ entries.append((e.name, False, 0, 0, "out"))
812
+ continue
813
+ try:
814
+ is_dir = e.is_dir()
815
+ est = e.stat()
816
+ size, mtime = est.st_size, est.st_mtime
817
+ except OSError:
818
+ is_dir, size, mtime = False, 0, 0
819
+ entries.append((e.name, is_dir, size, mtime, "in" if link else ""))
820
+ if len(entries) >= self.app.s.list_cap:
821
+ capped = True
822
+ break
823
+ except OSError as ex:
824
+ return self._unreadable(root, ex)
825
+ keyf = {"mtime": lambda x: -x[3], "size": lambda x: -x[2]}.get(sort, lambda x: x[0].lower())
826
+ entries.sort(key=lambda x: (not x[1], keyf(x)))
827
+ extra = "?hidden=1" if hidden else ""
828
+ rows = []
829
+ for name, is_dir, size, mtime, link in entries:
830
+ if link == "out":
831
+ why = ("a symlink that leaves this tab, points at a denied name or points nowhere"
832
+ + ("" if self.app.s.follow_symlinks else "; [http] follow_symlinks = true serves links out"))
833
+ rows.append(f'<tr><td class="p"><span class="note" title="{esc(why)}">{esc(name)} ↪ not served</span>'
834
+ '</td><td class="hide-sm"></td><td></td></tr>')
835
+ continue
836
+ href = turl(root, segs + [name], is_dir) + (extra if is_dir else "")
837
+ label = esc(name) + ("/" if is_dir else "") + (' <span class="note">↪</span>' if link else "")
838
+ rows.append(f'<tr><td class="p"><a href="{href}" class="{"d" if is_dir else ""}">{label}</a></td>'
839
+ f'<td class="r hide-sm">{"" if is_dir else ui.human_size(size)}</td>'
840
+ f'<td class="r" title="{_when(mtime) if mtime else ""}">{_age(mtime) if mtime else ""}</td></tr>')
841
+ here = turl(root, segs, True)
842
+ hq = "&hidden=1" if hidden else ""
843
+ sorts = " · ".join(f'<a href="{here}?sort={k}{hq}" class="{"on" if sort == k else ""}">{k}</a>'
844
+ for k in ("name", "mtime", "size"))
845
+ toggle = (f'<a href="{here}{"?sort=" + sort if sort != "name" else ""}">hide dotfiles</a>' if hidden
846
+ else f'<a href="{here}?hidden=1{"&sort=" + sort if sort != "name" else ""}">show dotfiles</a>')
847
+ files = {n.lower(): n for n, d, *_ in entries if not d}
848
+ readme_name = next((files[k] for k in README_NAMES if k in files), None)
849
+ readme_src = self._embed(root, segs + [readme_name]) if readme_name else None
850
+ if readme_src is None:
851
+ readme_name = None
852
+ body = (
853
+ crumbs(root, segs, True)
854
+ + f'<div class="bar tools"><span class="meta">sort: {sorts}</span><span class="meta">{toggle}</span>'
855
+ '<span id="pp-upd" class="badge" hidden></span></div>'
856
+ + '<div id="pp-main"><div class="scroll"><table class="list"><thead><tr><th>name</th>'
857
+ '<th class="r hide-sm">size</th><th class="r">modified</th></tr></thead><tbody>'
858
+ + (f'<tr><td class="p"><a href="{turl(root, segs[:-1], True)}{extra}" class="d">../</a></td><td class="hide-sm"></td><td></td></tr>'
859
+ if segs else "")
860
+ + "".join(rows) + "</tbody></table></div>"
861
+ + ("" if rows else f'<p class="note">Empty folder{f" but for {dots} hidden dotfile(s)" if dots else ""}.</p>')
862
+ + (f'<p class="note">Showing the first {self.app.s.list_cap} entries.</p>' if capped else "")
863
+ + (f'<div class="readme"><div class="readme-h"><a href="{turl(root, segs + [readme_name])}">{esc(readme_name)}</a></div>'
864
+ '<div id="pp-body"></div></div>' if readme_name else "")
865
+ + "</div>")
866
+ data = {"kind": "dir", "root": root, "path": "/".join(segs), "mtime": o.st.st_mtime, "size": o.st.st_size,
867
+ "readme": bool(readme_name)}
868
+ title = ("/".join(segs) or root) + " (pp http)"
869
+ self._send(200, page(self.app, title, root, body, data, readme_src, libs_css=bool(readme_name)),
870
+ "text/html; charset=utf-8")
871
+
872
+ def _embed(self, root, segs) -> str | None:
873
+ """A README's text for the folder page it sits in, or None when this tab has no business reading it.
874
+ It opens like any other path, so a symlink out of the tab is named in the listing and not embedded,
875
+ and what is read is the file that opened."""
876
+ with open_in_tab(self.app, root, segs, dir_ok=False, file_ok=True) as o:
877
+ if not o:
878
+ return None
879
+ try:
880
+ with os.fdopen(o.fd, "rb", closefd=False) as f:
881
+ return f.read(self.app.s.embed_cap).decode("utf-8", "replace")
882
+ except OSError:
883
+ return None
884
+
885
+ def _not_a_file(self, root, segs):
886
+ """A pipe, socket or device under a tab: never opened, the same answer from the view and from /raw/."""
887
+ return self._send(403, page(self.app, "not a file", root, crumbs(root, segs, False)
888
+ + '<p class="note">Not a regular file (a pipe, socket or device): not opened.</p>',
889
+ {"kind": "none"}), "text/html; charset=utf-8")
890
+
891
+ def _file(self, root, segs, o: Opened):
892
+ st = o.st # the file that opened, not whatever the name means now
893
+ try:
894
+ with os.fdopen(o.fd, "rb", closefd=False) as f:
895
+ head = f.read(self.app.s.embed_cap + 1)
896
+ except OSError as ex:
897
+ return self._unreadable(root, ex)
898
+ name = segs[-1]
899
+ ext = os.path.splitext(name)[1].lower()
900
+ raw = turl(root, segs, kind="raw")
901
+ binary = b"\0" in head[:8192]
902
+ data = {"kind": "code", "root": root, "path": "/".join(segs), "mtime": st.st_mtime, "size": st.st_size,
903
+ "raw": raw}
904
+ src = None
905
+ tools = [f'<span class="meta">{ui.human_size(st.st_size)} · modified <span title="{_when(st.st_mtime)}">'
906
+ f'{_age(st.st_mtime)} ago</span></span>',
907
+ f'<a href="{raw}">raw</a>', f'<a href="{raw}?dl=1">download</a>']
908
+ if ext in IMG_EXT:
909
+ data["kind"] = "image"
910
+ main = f'<img class="media" src="{raw}?v={st.st_mtime}" alt="{esc(name)}">'
911
+ elif ext in VIDEO_EXT:
912
+ data["kind"] = "media"
913
+ main = f'<video class="media" src="{raw}" controls preload="metadata"></video>'
914
+ elif ext in AUDIO_EXT:
915
+ data["kind"] = "media"
916
+ main = f'<audio src="{raw}" controls preload="metadata"></audio>'
917
+ elif ext == ".pdf":
918
+ data["kind"] = "media"
919
+ main = f'<embed class="pdf" src="{raw}" type="application/pdf">'
920
+ elif binary:
921
+ data["kind"] = "binary"
922
+ main = (f'<p class="note">Binary file, {ui.human_size(st.st_size)}. '
923
+ f'<a href="{raw}?dl=1">Download</a></p>')
924
+ else:
925
+ truncated = len(head) > self.app.s.embed_cap
926
+ src = head[: self.app.s.embed_cap].decode("utf-8", "replace")
927
+ data["truncated"] = truncated
928
+ if ext in MD_EXT:
929
+ data["kind"] = "md"
930
+ tools.append('<button id="pp-source" type="button">source</button>')
931
+ else:
932
+ data["lang"] = guess_lang(name, src[:200])
933
+ tools.append('<button id="pp-wrap" type="button">wrap</button>')
934
+ if ext in (".html", ".htm", ".svg"):
935
+ tools.append(f'<a href="{raw}" target="_blank" rel="noopener">open rendered (sandboxed)</a>')
936
+ main = '<div id="pp-body"></div><noscript><p class="note">Needs JavaScript; see the raw link.</p></noscript>'
937
+ if truncated:
938
+ main = (f'<p class="note">Showing the first {ui.human_size(self.app.s.embed_cap)} of '
939
+ f'{ui.human_size(st.st_size)}. Live reload is off; <a href="{raw}">raw</a> has it all.</p>' + main)
940
+ tools.append('<span id="pp-upd" class="badge" hidden></span>')
941
+ body = (crumbs(root, segs, False) + f'<div class="bar tools">{" ".join(tools)}</div>'
942
+ f'<div id="pp-main" class="{"wide" if data["kind"] == "code" else ""}">{main}</div>')
943
+ self._send(200, page(self.app, f"{name} (pp http)", root, body, data, src, libs_css=data["kind"] in ("md", "code")),
944
+ "text/html; charset=utf-8")
945
+
946
+ def _raw(self, root, segs, qs):
947
+ with open_in_tab(self.app, root, segs, dir_ok=False, file_ok=True) as o:
948
+ if not o:
949
+ return self._refuse(o, root, segs)
950
+ self._send_bytes(segs, qs, o)
951
+
952
+ def _send_bytes(self, segs, qs, o: Opened):
953
+ """The bytes of the file that opened, with the one byte range a media player asks for."""
954
+ st = o.st
955
+ with os.fdopen(o.fd, "rb", closefd=False) as f:
956
+ head = f.read(8192)
957
+ ctype, sandbox = raw_type(segs[-1], head)
958
+ start, end = 0, st.st_size - 1
959
+ # one byte range, so a video can seek (Safari will not play without it); anything else gets it all
960
+ m = re.fullmatch(r"bytes=(\d*)-(\d*)", (self.headers.get("Range") or "").strip())
961
+ if m and (m[1] or m[2]):
962
+ start, end = ((int(m[1]), min(int(m[2] or end), end)) if m[1]
963
+ else (max(0, st.st_size - int(m[2])), end))
964
+ if start > end:
965
+ self.send_response(416)
966
+ self.send_header("Content-Range", f"bytes */{st.st_size}")
967
+ self.send_header("Content-Length", "0")
968
+ return self.end_headers()
969
+ self.send_response(206 if m and (m[1] or m[2]) else 200)
970
+ self.send_header("Content-Type", ctype)
971
+ self.send_header("Content-Length", str(end - start + 1))
972
+ if m and (m[1] or m[2]):
973
+ self.send_header("Content-Range", f"bytes {start}-{end}/{st.st_size}")
974
+ self.send_header("Accept-Ranges", "bytes")
975
+ self.send_header("Last-Modified", email.utils.formatdate(st.st_mtime, usegmt=True))
976
+ self.send_header("Cache-Control", "no-cache")
977
+ self.send_header("X-Content-Type-Options", "nosniff")
978
+ if sandbox:
979
+ self.send_header("Content-Security-Policy", "sandbox")
980
+ if qs.get("dl") == "1":
981
+ self.send_header("Content-Disposition", f"attachment; filename*=UTF-8''{q(segs[-1])}")
982
+ self.end_headers()
983
+ if self.command == "HEAD":
984
+ return
985
+ f.seek(start)
986
+ left = end - start + 1
987
+ while left > 0:
988
+ chunk = f.read(min(65536, left))
989
+ if not chunk:
990
+ break
991
+ self.wfile.write(chunk)
992
+ left -= len(chunk)
993
+
994
+
995
+ @functools.cache
996
+ def _csp() -> str:
997
+ """Rendered markdown is untrusted HTML (any repo's README). Only our one inline script (pinned by hash) and
998
+ cdnjs may run, so an injected <img onerror=...> or javascript: link is inert. Styles stay inline-able for
999
+ KaTeX and mermaid; images may come from anywhere, as markdown images do."""
1000
+ h = base64.b64encode(hashlib.sha256(JS.encode()).digest()).decode()
1001
+ return (f"default-src 'self'; script-src 'sha256-{h}' https://cdnjs.cloudflare.com; "
1002
+ "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; font-src 'self' data: https://cdnjs.cloudflare.com; "
1003
+ "img-src 'self' data: blob: https: http:; media-src 'self'; object-src 'self'; frame-src 'self'; "
1004
+ "connect-src 'self'; base-uri 'none'; form-action 'self'")
1005
+
1006
+
1007
+ class _Server(ThreadingHTTPServer):
1008
+ daemon_threads = True
1009
+
1010
+ def server_bind(self):
1011
+ # HTTPServer.server_bind does a reverse-DNS getfqdn(); skip it (slow on some hosts, useless here)
1012
+ socketserver.TCPServer.server_bind(self)
1013
+ self.server_name, self.server_port = self.server_address[:2]
1014
+
1015
+ def handle_error(self, request, client_address):
1016
+ if isinstance(sys.exc_info()[1], (BrokenPipeError, ConnectionResetError)):
1017
+ return
1018
+ super().handle_error(request, client_address)
1019
+
1020
+
1021
+ def make_server(app: App, bind: str, port: int, tries: int = 20) -> ThreadingHTTPServer:
1022
+ """Bind port, or the next free one of `tries` (port 0: the OS picks)."""
1023
+ handler = type("PPHandler", (Handler,), {"app": app, "host_guard": _loopback(bind)})
1024
+ family = socket.AF_INET6 if ":" in bind else socket.AF_INET
1025
+ cls = type("PPServer", (_Server,), {"address_family": family})
1026
+ last = None
1027
+ ports = [0] if port == 0 else range(port, min(port + tries, 65536))
1028
+ for p in ports:
1029
+ try:
1030
+ return cls((bind, p), handler)
1031
+ except OSError as e:
1032
+ if e.errno not in (errno.EADDRINUSE, errno.EACCES):
1033
+ raise OSError(f"cannot listen on {bind} port {p}: {e.strerror or e}") from None
1034
+ last = e
1035
+ raise OSError(f"no free port in {ports[0]}..{ports[-1]} on {bind}: {last.strerror}")
1036
+
1037
+
1038
+ class Serving:
1039
+ """The serving loop, and the clean stop SIGINT or SIGTERM asks for.
1040
+
1041
+ `with Serving(server) as s:` serves in a thread, with both signals blocked in every thread, so a stop that
1042
+ arrives at any moment stays pending until `s.wait()` takes it and none can be missed between two
1043
+ statements. `wait()` returns the signal it took. Leaving the block stops taking connections: `shutdown()`
1044
+ returns only once the serving loop has exited, so the thread that serves can never be the one to call it,
1045
+ and the thread that waits here serves nothing. The threads that answer requests are daemons, so a response
1046
+ still being written is cut short when pp exits.
1047
+ """
1048
+
1049
+ POLL = 0.1 # how long the serving loop may take to notice the stop, so how long Ctrl-C takes
1050
+
1051
+ SIGS = (signal.SIGINT, signal.SIGTERM) # the stop a person asks for, however they ask
1052
+
1053
+ def __init__(self, server):
1054
+ self.server = server
1055
+ self.sigs = set(self.SIGS)
1056
+ self._thread = None
1057
+ self._mask = None
1058
+
1059
+ def __enter__(self) -> "Serving":
1060
+ self._mask = signal.pthread_sigmask(signal.SIG_BLOCK, self.sigs)
1061
+ self._thread = threading.Thread(target=self.server.serve_forever, args=(self.POLL,), name="pp-http",
1062
+ daemon=True)
1063
+ self._thread.start() # started under the mask, which it inherits, so no stop reaches it
1064
+ return self
1065
+
1066
+ def wait(self) -> int:
1067
+ while True:
1068
+ try:
1069
+ return signal.sigwait(self.sigs)
1070
+ except InterruptedError: # some other signal; the ones waited for are still pending
1071
+ pass
1072
+
1073
+ def __exit__(self, *exc) -> bool:
1074
+ self.server.shutdown()
1075
+ self._thread.join()
1076
+ signal.pthread_sigmask(signal.SIG_SETMASK, self._mask)
1077
+ return False
1078
+
1079
+
1080
+ def _port(v: str) -> int:
1081
+ if not v.isdigit() or int(v) > 65535:
1082
+ raise argparse.ArgumentTypeError(f"{v!r} is not a port (0..65535)")
1083
+ return int(v)
1084
+
1085
+
1086
+ def main(argv: list[str]) -> int:
1087
+ ap = ui.parser("http", __doc__)
1088
+ ap.add_argument("paths", nargs="*", metavar="PATH", help="folders to serve, one tab each (default: from config)")
1089
+ ap.add_argument("-p", "--port", type=_port, help="port (default 8000, or [http] port; 0: any free one)")
1090
+ ap.add_argument("--bind", help="address to bind (default 127.0.0.1, or [http] bind)")
1091
+ ap.add_argument("--config-only", action="store_true", help="never fall back to the current directory")
1092
+ ap.add_argument("--no-discover", action="store_true", help="skip [[http.discover]] rules")
1093
+ ap.add_argument("-q", "--quiet", action="store_true", help="no request log")
1094
+ a = ap.parse_args(argv)
1095
+ cfg = config.load("http")
1096
+ s = Settings.from_config(cfg)
1097
+ roots_cfg, rules_cfg = tables(cfg, "roots", "path"), tables(cfg, "discover", "under")
1098
+ files = []
1099
+ if a.paths:
1100
+ fixed, rules = [], []
1101
+ for p in a.paths:
1102
+ full = paths.expand(p)
1103
+ if os.path.isfile(full):
1104
+ files.append(full)
1105
+ full = os.path.dirname(full)
1106
+ if not os.path.isdir(full):
1107
+ ui.die(f"not a directory: {p}", 2)
1108
+ fixed.append((os.path.basename(os.path.normpath(full)) or full, full))
1109
+ else:
1110
+ fixed = [(str(r.get("name") or ""), r["path"]) for r in roots_cfg]
1111
+ rules = [] if a.no_discover else rules_cfg
1112
+ if not fixed and not rules:
1113
+ if a.config_only:
1114
+ ui.die(f"no [[http.roots]] or [[http.discover]] in {config.global_path()}", 2)
1115
+ cwd = os.getcwd()
1116
+ fixed = [(os.path.basename(cwd) or cwd, cwd)]
1117
+ app = App(s, fixed, rules, quiet=a.quiet)
1118
+ roots = app.roots()
1119
+ if not roots:
1120
+ ui.warn("no tabs: the configured roots/rules matched no directory (they are re-checked every ~10 s)")
1121
+ bind = a.bind or s.bind
1122
+ want = s.port if a.port is None else a.port
1123
+ mimetypes.init() # the type table is built here, so the threads that serve only ever read it
1124
+ try:
1125
+ server = make_server(app, bind, want)
1126
+ except OSError as e:
1127
+ ui.die(str(e))
1128
+ port = server.server_address[1]
1129
+ host = bind if bind not in ("0.0.0.0", "::", "") else socket.gethostname()
1130
+ url = f"http://{'[' + host + ']' if ':' in host else host}:{port}/"
1131
+ try:
1132
+ with Serving(server) as serving:
1133
+ print(f"pp http: serving {url}" + (f" (port {want} is busy)" if want and port != want else "")
1134
+ + ("" if _loopback(bind) else ui.c(f" (bound to {bind}: reachable from the network)", "yellow")))
1135
+ for f in files:
1136
+ for name, real in roots.items():
1137
+ if os.path.realpath(os.path.dirname(f)) == real:
1138
+ print(f" file: {url.rstrip('/')}{turl(name, [os.path.basename(f)])}")
1139
+ break
1140
+ if roots:
1141
+ print(ui.table([[n, p] for n, p in roots.items()], indent=" "))
1142
+ for r in rules:
1143
+ m = r.get("marker")
1144
+ print(f" discover: {r.get('under')} depth {r.get('depth', 1)}"
1145
+ + (f" marker {m if isinstance(m, str) else '|'.join(m)}" if m else "")
1146
+ + " (re-checked every ~10 s)")
1147
+ print("Ctrl-C to stop", flush=True)
1148
+ if serving.wait() == signal.SIGINT:
1149
+ print() # the terminal has echoed ^C, so the next prompt starts on its own line
1150
+ except KeyboardInterrupt: # a Ctrl-C before the signals were blocked, or a second one on the way out
1151
+ print()
1152
+ finally:
1153
+ server.server_close()
1154
+ return 0
1155
+
1156
+
1157
+ # ---- page assets ----------------------------------------------------------------------------------
1158
+
1159
+ CSS = r"""
1160
+ :root{--bg:#fff;--fg:#1f2328;--muted:#656d76;--border:#d0d7de;--accent:#0969da;--soft:#f6f8fa;--hl:#fff8c5;
1161
+ --tag:#ddf4ff;--tagfg:#0550ae;color-scheme:light dark}
1162
+ @media (prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--muted:#8d96a0;--border:#30363d;--accent:#4493f8;
1163
+ --soft:#161b22;--hl:#3b2e00;--tag:#12263f;--tagfg:#79c0ff}}
1164
+ *{box-sizing:border-box}
1165
+ html{-webkit-text-size-adjust:100%}
1166
+ body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif}
1167
+ a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
1168
+ header{position:sticky;top:0;z-index:5;background:var(--soft);border-bottom:1px solid var(--border)}
1169
+ .tabs{display:flex;gap:2px;overflow-x:auto;padding:6px 16px 0;white-space:nowrap;scrollbar-width:thin}
1170
+ .tabs a,.tabs .brand{padding:6px 12px;border-radius:6px 6px 0 0;border:1px solid transparent;border-bottom:none;font-size:14px;color:var(--muted)}
1171
+ .tabs .brand{font-weight:700;color:var(--fg);padding-left:0}
1172
+ .tabs a.on{background:var(--bg);color:var(--fg);border-color:var(--border);font-weight:600;margin-bottom:-1px}
1173
+ .tabs a:hover{text-decoration:none;color:var(--fg)}
1174
+ main{padding:16px;max-width:1280px;margin:0 auto}main:has(#pp-main.wide){max-width:none}
1175
+ .crumbs{font-size:16px;margin:2px 0 8px;word-break:break-all}.crumbs .sep{color:var(--muted)}
1176
+ .bar{display:flex;flex-wrap:wrap;gap:8px 14px;align-items:center;margin:0 0 12px}
1177
+ .bar input{font:inherit;font-size:14px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--fg);min-width:0}
1178
+ #pp-filter{flex:1 1 260px}.bar .glob{flex:0 1 160px}
1179
+ button{font:inherit;font-size:13px;padding:4px 10px;border:1px solid var(--border);border-radius:6px;background:var(--soft);color:var(--fg);cursor:pointer}
1180
+ button.on{background:var(--tag);color:var(--tagfg)}
1181
+ .meta,.note{color:var(--muted);font-size:13px}.meta a.on{color:var(--fg);font-weight:600}
1182
+ .badge{font-size:12px;background:var(--tag);color:var(--tagfg);padding:1px 8px;border-radius:10px}
1183
+ .scroll{overflow-x:auto}
1184
+ table.list{width:100%;border-collapse:collapse;font-size:14px}
1185
+ table.list th{text-align:left;font-weight:600;color:var(--muted);border-bottom:1px solid var(--border);padding:6px 8px;font-size:12px;text-transform:uppercase;letter-spacing:.03em}
1186
+ table.list td{padding:5px 8px;border-bottom:1px solid var(--border);vertical-align:top}
1187
+ table.list tr:hover td{background:var(--soft)}
1188
+ table.list .r{text-align:right;white-space:nowrap;color:var(--muted)}td.p{word-break:break-all}
1189
+ a.d{font-weight:600}
1190
+ .tag{background:var(--tag);color:var(--tagfg);padding:1px 8px;border-radius:10px;font-size:12px;white-space:nowrap}
1191
+ .readme{margin-top:24px;border:1px solid var(--border);border-radius:6px;padding:8px 24px 24px}
1192
+ .readme-h{font-size:13px;border-bottom:1px solid var(--border);margin:0 -24px 8px;padding:0 16px 8px}
1193
+ .media{max-width:100%;height:auto;border:1px solid var(--border);border-radius:6px;background:repeating-conic-gradient(#8881 0 25%,transparent 0 50%) 0 0/16px 16px}
1194
+ .pdf{width:100%;height:85vh;border:1px solid var(--border);border-radius:6px}
1195
+ pre.plain{white-space:pre-wrap;word-break:break-word;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:var(--soft);border:1px solid var(--border);border-radius:6px;padding:12px}
1196
+ .code{font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:var(--soft);border:1px solid var(--border);border-radius:6px;overflow-x:auto;padding:8px 0}
1197
+ .code .rows{display:inline-block;min-width:100%}.code.wrap .rows{display:block}
1198
+ .code .row{display:flex}
1199
+ .code .ln{flex:none;width:var(--lnw,4ch);box-sizing:content-box;text-align:right;padding:0 12px 0 8px;color:var(--muted);user-select:none;position:sticky;left:0;background:var(--soft)}
1200
+ .code .ln:hover{color:var(--accent);text-decoration:none}
1201
+ .code .lc{white-space:pre;padding-right:16px}
1202
+ .code.wrap .lc{white-space:pre-wrap;word-break:break-all;flex:1 1 auto;min-width:0}
1203
+ .code .row.hl,.code .row.hl .ln{background:var(--hl)}
1204
+ .hljs{background:transparent!important;padding:0!important}
1205
+ .md{max-width:980px;margin:0 auto;font-size:16px;line-height:1.65;overflow-wrap:break-word}
1206
+ .readme .md{max-width:none}
1207
+ .md>:first-child{margin-top:0}
1208
+ .md h1,.md h2,.md h3,.md h4,.md h5,.md h6{margin:1.4em 0 .6em;line-height:1.25;scroll-margin-top:64px}
1209
+ .md h1,.md h2{border-bottom:1px solid var(--border);padding-bottom:.3em}
1210
+ .md h1{font-size:1.9em}.md h2{font-size:1.5em}.md h3{font-size:1.25em}.md h4{font-size:1.05em}
1211
+ .md .hanchor{opacity:0;margin-left:.4em;font-size:.75em;font-weight:400}
1212
+ .md :is(h1,h2,h3,h4,h5,h6):hover .hanchor{opacity:1}
1213
+ .md code{font:.86em ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:var(--soft);padding:.15em .35em;border-radius:4px}
1214
+ .md pre{background:var(--soft);border:1px solid var(--border);border-radius:6px;padding:12px 14px;overflow:auto;line-height:1.45}
1215
+ .md pre code{background:none;padding:0;font-size:13px;border-radius:0}
1216
+ .md table{border-collapse:collapse;display:block;overflow-x:auto;max-width:100%;margin:1em 0}
1217
+ .md th,.md td{border:1px solid var(--border);padding:5px 12px}
1218
+ .md tr:nth-child(2n){background:var(--soft)}
1219
+ .md blockquote{margin:1em 0;padding:0 1em;color:var(--muted);border-left:.25em solid var(--border)}
1220
+ .md img{max-width:100%}
1221
+ .md hr{border:0;border-top:1px solid var(--border);margin:1.5em 0}
1222
+ .md li+li{margin-top:.2em}
1223
+ .md .katex-display{overflow-x:auto;overflow-y:hidden;padding:2px 0}
1224
+ .md .pp-math.raw{font-family:ui-monospace,monospace;color:var(--muted)}
1225
+ .md .mermaid{text-align:center;margin:1em 0}
1226
+ #pp-toc{display:none}
1227
+ @media (min-width:1560px){#pp-toc{display:block;position:fixed;top:96px;right:16px;width:250px;max-height:calc(100vh - 120px);overflow:auto;font-size:13px;border-left:1px solid var(--border);padding-left:12px}
1228
+ #pp-toc a{display:block;color:var(--muted);padding:2px 0}#pp-toc a:hover{color:var(--fg)}#pp-toc .l3{padding-left:12px}}
1229
+ @media (max-width:640px){.hide-sm{display:none}main{padding:12px 16px}.readme{padding:8px 12px 16px}.readme-h{margin:0 -12px 8px}}
1230
+ """
1231
+
1232
+ JS = r"""
1233
+ (function () {
1234
+ 'use strict';
1235
+ const PAGE = JSON.parse(document.getElementById('pp-page').textContent);
1236
+ const CDN = PAGE.cdn;
1237
+ const $ = (s, el) => (el || document).querySelector(s);
1238
+ const esc = s => String(s).replace(/[&<>"]/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c]));
1239
+ const store = {get(k) { try { return localStorage.getItem(k); } catch (e) { return null; } },
1240
+ set(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }};
1241
+ function readSrc(doc) { const el = (doc || document).getElementById('pp-src'); return el ? JSON.parse(el.textContent) : null; }
1242
+ let SRC = readSrc();
1243
+ if (typeof SRC === 'string') SRC = SRC.replace(/\r\n/g, '\n');
1244
+
1245
+ const loading = {};
1246
+ function loadScript(src) {
1247
+ if (!loading[src]) loading[src] = new Promise((res, rej) => {
1248
+ const s = document.createElement('script'); s.src = src; s.async = false;
1249
+ s.onload = () => res(); s.onerror = () => { delete loading[src]; rej(new Error('failed: ' + src)); };
1250
+ document.head.appendChild(s);
1251
+ });
1252
+ return loading[src];
1253
+ }
1254
+ const soft = p => p.then(() => true, () => false);
1255
+ const wantMd = PAGE.kind === 'md' || (PAGE.kind === 'dir' && PAGE.readme);
1256
+ const libs = wantMd ? Promise.all([soft(loadScript(CDN.marked)), soft(loadScript(CDN.hljs)), soft(loadScript(CDN.katex))])
1257
+ : PAGE.kind === 'code' ? soft(loadScript(CDN.hljs)) : Promise.resolve();
1258
+
1259
+ // ---- highlight.js ----
1260
+ const ALIAS = {tex: 'latex', clj: 'clojure', cljs: 'clojure', edn: 'clojure', docker: 'dockerfile', hs: 'haskell',
1261
+ ex: 'elixir', exs: 'elixir', jl: 'julia', ml: 'ocaml', proto: 'protobuf', zsh: 'bash', sh: 'bash', console: 'shell',
1262
+ toml: 'ini', yml: 'yaml', py: 'python', js: 'javascript', ts: 'typescript', rs: 'rust', golang: 'go', 'c++': 'cpp'};
1263
+ const EXTRA = new Set(['clojure', 'dockerfile', 'latex', 'haskell', 'elixir', 'erlang', 'scheme', 'lisp', 'cmake',
1264
+ 'protobuf', 'nix', 'groovy', 'gradle', 'vim', 'powershell', 'ocaml', 'fsharp', 'julia', 'dart', 'scala', 'elm',
1265
+ 'nginx', 'apache', 'properties', 'tcl', 'fortran', 'matlab', 'prolog', 'x86asm', 'armasm', 'llvm', 'glsl', 'awk',
1266
+ 'dos', 'pgsql', 'verilog', 'vhdl', 'zephir', 'crystal', 'd', 'nim', 'haxe', 'smalltalk', 'stata', 'coq', 'isabelle']);
1267
+ async function hlLang(lang) {
1268
+ if (!window.hljs || !lang) return null;
1269
+ const L = ALIAS[lang] || lang;
1270
+ if (!hljs.getLanguage(L) && EXTRA.has(L)) await soft(loadScript(CDN.hljsLang.replace('{}', L)));
1271
+ return hljs.getLanguage(L) ? L : null;
1272
+ }
1273
+
1274
+ // ---- code view ----
1275
+ function splitLines(h) {
1276
+ const lines = []; const open = []; let cur = '';
1277
+ const re = /(<span[^>]*>)|(<\/span>)|(\n)|([^<\n]+|<)/g; let m;
1278
+ while ((m = re.exec(h))) {
1279
+ if (m[1]) { open.push(m[1]); cur += m[1]; }
1280
+ else if (m[2]) { open.pop(); cur += m[2]; }
1281
+ else if (m[3]) { lines.push(cur + '</span>'.repeat(open.length)); cur = open.join(''); }
1282
+ else cur += m[4];
1283
+ }
1284
+ lines.push(cur + '</span>'.repeat(open.length));
1285
+ return lines;
1286
+ }
1287
+ function codeRows(el, h, src) {
1288
+ const lines = splitLines(h);
1289
+ if (src.endsWith('\n') && lines.length > 1) lines.pop();
1290
+ const wrap = store.get('pp-wrap') === '1';
1291
+ const w = String(lines.length).length;
1292
+ let out = '<div class="code' + (wrap ? ' wrap' : '') + '" style="--lnw:' + w + 'ch"><div class="rows">';
1293
+ for (let i = 0; i < lines.length; i++)
1294
+ out += '<div class="row" id="L' + (i + 1) + '"><a class="ln" href="#L' + (i + 1) + '">' + (i + 1) + '</a><span class="lc">' + (lines[i] || ' ') + '</span></div>';
1295
+ el.innerHTML = out + '</div></div>';
1296
+ const b = $('#pp-wrap'); if (b) b.classList.toggle('on', wrap);
1297
+ }
1298
+ async function renderCode(el, src, lang, upgrade) {
1299
+ if (!upgrade) { codeRows(el, esc(src), src); return; }
1300
+ await libs;
1301
+ let h = null;
1302
+ const L = src.length <= 500000 ? await hlLang(lang) : null;
1303
+ if (L) { try { h = hljs.highlight(src, {language: L, ignoreIllegals: true}).value; } catch (e) {} }
1304
+ codeRows(el, h === null ? esc(src) : h, src);
1305
+ }
1306
+ function markLine(scroll) {
1307
+ document.querySelectorAll('.row.hl').forEach(r => r.classList.remove('hl'));
1308
+ const m = /^#L(\d+)$/.exec(location.hash); if (!m) return;
1309
+ const r = document.getElementById('L' + m[1]);
1310
+ if (r) { r.classList.add('hl'); if (scroll) r.scrollIntoView({block: 'center'}); }
1311
+ }
1312
+ window.addEventListener('hashchange', () => markLine(true));
1313
+
1314
+ // ---- markdown ----
1315
+ const MATH = /(`+)(?!`)[\s\S]*?(?<!`)\1(?!`)|\$\$[\s\S]+?\$\$|\\\[[\s\S]+?\\\]|\\\([\s\S]+?\\\)|\\begin\{([a-zA-Z]+\*?)\}[\s\S]+?\\end\{\2\}|(?<![\\$\w])\$(?=[^\s$])(?:[^$\\\n]|\\[\s\S])+?(?<=[^\s\\])\$(?![\d$])/g;
1316
+ function frontMatter(src) {
1317
+ const m = /^---\n([\s\S]*?)\n---\n/.exec(src);
1318
+ return m ? '```yaml\n' + m[1] + '\n```\n' + src.slice(m[0].length) : src;
1319
+ }
1320
+ function protect(src) {
1321
+ // fenced code stays verbatim; outside it, inline code stays verbatim and math becomes a placeholder marked can't mangle
1322
+ const math = [], parts = []; let buf = [], fence = null;
1323
+ for (const line of src.split('\n')) {
1324
+ if (fence) {
1325
+ buf.push(line);
1326
+ const m = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line);
1327
+ if (m && m[1][0] === fence[0] && m[1].length >= fence.length) { parts.push([true, buf.join('\n')]); buf = []; fence = null; }
1328
+ } else {
1329
+ const m = /^ {0,3}(`{3,}|~{3,})/.exec(line);
1330
+ if (m) { if (buf.length) parts.push([false, buf.join('\n')]); buf = [line]; fence = m[1]; }
1331
+ else buf.push(line);
1332
+ }
1333
+ }
1334
+ if (buf.length) parts.push([fence !== null, buf.join('\n')]);
1335
+ const text = parts.map(([code, t]) => code ? t : t.replace(MATH, (m, ticks) => {
1336
+ if (ticks) return m;
1337
+ math.push(m); return 'PPMATH' + (math.length - 1) + 'X';
1338
+ })).join('\n');
1339
+ return [text, math];
1340
+ }
1341
+ function renderMath(root, math) {
1342
+ root.querySelectorAll('span.pp-math').forEach(sp => {
1343
+ const raw = math[+sp.dataset.i]; let tex = raw, display = false;
1344
+ if (raw.startsWith('$$') || raw.startsWith('\\[')) { tex = raw.slice(2, -2); display = true; }
1345
+ else if (raw.startsWith('\\(')) tex = raw.slice(2, -2);
1346
+ else if (raw.startsWith('\\begin')) display = true;
1347
+ else tex = raw.slice(1, -1);
1348
+ if (window.katex) {
1349
+ try { katex.render(tex, sp, {displayMode: display, throwOnError: false, strict: 'ignore'}); return; } catch (e) {}
1350
+ }
1351
+ sp.textContent = raw; sp.classList.add('raw');
1352
+ });
1353
+ }
1354
+ function fixLinks(root) {
1355
+ // as on GitHub, a link starting with one / is relative to the tab (the repo), not to this server
1356
+ const tab = v => typeof PAGE.root === 'string' && /^\/(?!\/)/.test(v) ? '/t/' + encodeURIComponent(PAGE.root) + v : v;
1357
+ root.querySelectorAll('img[src],source[src],video[src],audio[src]').forEach(n => {
1358
+ try {
1359
+ const u = new URL(tab(n.getAttribute('src')), location.href);
1360
+ if (u.origin === location.origin && u.pathname.startsWith('/t/')) { u.pathname = '/raw/' + u.pathname.slice(3); n.setAttribute('src', u.href); }
1361
+ } catch (e) {}
1362
+ });
1363
+ root.querySelectorAll('a[href]').forEach(a => {
1364
+ try {
1365
+ a.setAttribute('href', tab(a.getAttribute('href')));
1366
+ if (new URL(a.getAttribute('href'), location.href).origin !== location.origin) { a.target = '_blank'; a.rel = 'noopener'; }
1367
+ } catch (e) {}
1368
+ });
1369
+ }
1370
+ function anchors(el) {
1371
+ const seen = {}; const hs = [...el.querySelectorAll('h1,h2,h3,h4,h5,h6')];
1372
+ for (const h of hs) {
1373
+ let id = h.textContent.trim().toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu, '').trim().replace(/\s+/g, '-') || 'section';
1374
+ if (id in seen) id += '-' + (++seen[id]); else seen[id] = 0;
1375
+ h.id = id;
1376
+ const a = document.createElement('a'); a.href = '#' + id; a.className = 'hanchor'; a.textContent = '#'; h.appendChild(a);
1377
+ }
1378
+ return hs;
1379
+ }
1380
+ function toc(hs) {
1381
+ const old = $('#pp-toc'); if (old) old.remove();
1382
+ const items = hs.filter(h => /^H[123]$/.test(h.tagName));
1383
+ if (PAGE.kind !== 'md' || items.length < 4) return;
1384
+ const nav = document.createElement('nav'); nav.id = 'pp-toc';
1385
+ nav.innerHTML = items.map(h => '<a class="l' + h.tagName[1] + '" href="#' + h.id + '">' + esc(h.textContent.replace(/#$/, '')) + '</a>').join('');
1386
+ document.body.appendChild(nav);
1387
+ }
1388
+ async function mermaidBlocks(el) {
1389
+ const blocks = el.querySelectorAll('pre > code.language-mermaid');
1390
+ if (!blocks.length || !(await soft(loadScript(CDN.mermaid)))) return;
1391
+ const nodes = [];
1392
+ blocks.forEach(c => { const d = document.createElement('div'); d.className = 'mermaid'; d.textContent = c.textContent; c.parentElement.replaceWith(d); nodes.push(d); });
1393
+ try {
1394
+ mermaid.initialize({startOnLoad: false, securityLevel: 'strict', theme: matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'default'});
1395
+ await mermaid.run({nodes});
1396
+ } catch (e) {}
1397
+ }
1398
+ function plain(el, src) { el.innerHTML = '<pre class="plain">' + esc(src) + '</pre>'; }
1399
+ async function renderMd(el, src, upgrade) {
1400
+ if (!upgrade) { plain(el, src); return; }
1401
+ await libs;
1402
+ if (!window.marked) { plain(el, src); return; }
1403
+ const [prot, math] = protect(frontMatter(src));
1404
+ let h = marked.parse(prot, {gfm: true});
1405
+ h = h.replace(/<[^>]*>|PPMATH(\d+)X/g, (m, i) => i === undefined
1406
+ ? m.replace(/PPMATH(\d+)X/g, (_, j) => esc(math[+j])) : '<span class="pp-math" data-i="' + i + '"></span>');
1407
+ const tpl = document.createElement('template'); tpl.innerHTML = h; // inert: relative images don't load before fixLinks
1408
+ fixLinks(tpl.content); renderMath(tpl.content, math);
1409
+ el.replaceChildren(tpl.content); el.classList.add('md');
1410
+ toc(anchors(el));
1411
+ if (window.hljs) for (const c of el.querySelectorAll('pre code')) {
1412
+ const m = /language-([\w+#-]+)/.exec(c.className);
1413
+ if (!m || m[1] === 'mermaid') continue;
1414
+ const L = await hlLang(m[1]);
1415
+ if (L) { try { c.innerHTML = hljs.highlight(c.textContent, {language: L, ignoreIllegals: true}).value; c.classList.add('hljs'); } catch (e) {} }
1416
+ }
1417
+ mermaidBlocks(el);
1418
+ }
1419
+
1420
+ // ---- page ----
1421
+ let mode = 'rendered';
1422
+ async function render(upgrade) {
1423
+ const el = $('#pp-body'); if (!el || typeof SRC !== 'string') return;
1424
+ if (PAGE.kind === 'code') await renderCode(el, SRC, PAGE.lang, upgrade);
1425
+ else if (PAGE.kind === 'md' && mode === 'source') await renderCode(el, SRC, 'markdown', upgrade);
1426
+ else if (wantMd) await renderMd(el, SRC, upgrade);
1427
+ }
1428
+ render(false); markLine(true);
1429
+ libs.then(() => render(true)).then(() => { if (location.hash) { markLine(true); const t = document.getElementById(decodeURIComponent(location.hash.slice(1))); if (t && !/^L\d+$/.test(t.id)) t.scrollIntoView(); } });
1430
+
1431
+ const wrapBtn = $('#pp-wrap');
1432
+ if (wrapBtn) wrapBtn.addEventListener('click', () => {
1433
+ const c = $('.code'); if (!c) return; const on = !c.classList.contains('wrap');
1434
+ c.classList.toggle('wrap', on); wrapBtn.classList.toggle('on', on); store.set('pp-wrap', on ? '1' : '0');
1435
+ });
1436
+ const srcBtn = $('#pp-source');
1437
+ if (srcBtn) srcBtn.addEventListener('click', () => {
1438
+ mode = mode === 'source' ? 'rendered' : 'source'; srcBtn.classList.toggle('on', mode === 'source');
1439
+ const t = $('#pp-toc'); if (t) t.hidden = mode === 'source';
1440
+ render(true);
1441
+ });
1442
+ document.addEventListener('click', e => {
1443
+ const a = e.target.closest && e.target.closest('a.ln'); if (!a) return;
1444
+ e.preventDefault(); history.replaceState(null, '', a.getAttribute('href')); markLine(false);
1445
+ });
1446
+
1447
+ function applyFilter() {
1448
+ const f = $('#pp-filter'); if (!f) return;
1449
+ const v = f.value.trim().toLowerCase(); let n = 0;
1450
+ document.querySelectorAll('#pp-main tr[data-k]').forEach(tr => { const show = !v || tr.dataset.k.includes(v); tr.hidden = !show; if (show) n++; });
1451
+ const c = $('#pp-count'); if (c) c.textContent = n;
1452
+ }
1453
+ const filt = $('#pp-filter');
1454
+ if (filt) { filt.addEventListener('input', applyFilter); if (!filt.value) filt.focus(); }
1455
+
1456
+ // ---- live reload ----
1457
+ let last = {mtime: PAGE.mtime, size: PAGE.size}, updatedAt = 0, busy = false;
1458
+ function tick() {
1459
+ const b = $('#pp-upd'); if (!b || !updatedAt) return;
1460
+ const s = Math.round((Date.now() - updatedAt) / 1000);
1461
+ b.textContent = 'updated ' + (s < 60 ? s + 's' : Math.round(s / 60) + 'm') + ' ago'; b.hidden = false;
1462
+ }
1463
+ async function swapMain() {
1464
+ const r = await fetch(location.href, {cache: 'no-store'}); if (!r.ok) return false;
1465
+ const doc = new DOMParser().parseFromString(await r.text(), 'text/html');
1466
+ const m = doc.getElementById('pp-main'); if (!m) return false;
1467
+ document.getElementById('pp-main').replaceWith(m);
1468
+ if (PAGE.kind === 'dir') { SRC = readSrc(doc); PAGE.readme = typeof SRC === 'string'; if (PAGE.readme) await renderMd($('#pp-body'), SRC, true); }
1469
+ applyFilter();
1470
+ return true;
1471
+ }
1472
+ async function check() {
1473
+ if (busy || document.visibilityState !== 'visible') return;
1474
+ busy = true; const y = window.scrollY;
1475
+ try {
1476
+ if (PAGE.kind === 'recent') { await swapMain(); window.scrollTo(0, y); return; }
1477
+ if (!PAGE.root && PAGE.root !== '') return;
1478
+ const r = await fetch('/_pp/stat?root=' + encodeURIComponent(PAGE.root) + '&path=' + encodeURIComponent(PAGE.path), {cache: 'no-store'});
1479
+ if (!r.ok) { const b = $('#pp-upd'); if (b) { b.textContent = 'gone'; b.hidden = false; } return; }
1480
+ const st = await r.json();
1481
+ if (st.mtime === last.mtime && st.size === last.size) return;
1482
+ last = st;
1483
+ if (PAGE.kind === 'md' || PAGE.kind === 'code') {
1484
+ if (PAGE.truncated) return;
1485
+ const fr = await fetch(PAGE.raw, {cache: 'no-store'}); if (!fr.ok) return;
1486
+ SRC = (await fr.text()).replace(/\r\n/g, '\n'); await render(true);
1487
+ } else if (PAGE.kind === 'image') { const img = $('#pp-main img'); if (img) img.src = PAGE.raw + '?v=' + st.mtime; }
1488
+ else if (PAGE.kind === 'dir') await swapMain();
1489
+ else return;
1490
+ window.scrollTo(0, y); updatedAt = Date.now(); tick();
1491
+ } catch (e) {} finally { busy = false; }
1492
+ }
1493
+ if (['md', 'code', 'image', 'dir', 'recent'].includes(PAGE.kind)) {
1494
+ setInterval(check, PAGE.kind === 'recent' ? 10000 : 2000);
1495
+ setInterval(tick, 1000);
1496
+ document.addEventListener('visibilitychange', check);
1497
+ }
1498
+ })();
1499
+ """