@zkov/pi-md-viewer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,114 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { builtInDefaultConfig, } from "./types.js";
5
+ function pathApi(platform) {
6
+ return platform === "win32" ? path.win32 : path.posix;
7
+ }
8
+ export function userConfigCandidates(options) {
9
+ const p = pathApi(options.platform);
10
+ const result = [];
11
+ const explicit = options.env.PI_MD_VIEWER_CONFIG?.trim();
12
+ if (explicit)
13
+ result.push(explicit);
14
+ if (options.platform === "win32" && options.env.APPDATA) {
15
+ result.push(p.join(options.env.APPDATA, "pi-md-viewer", "config.json"));
16
+ }
17
+ if (options.platform === "darwin") {
18
+ result.push(p.join(options.homeDir, "Library", "Application Support", "pi-md-viewer", "config.json"));
19
+ }
20
+ if (options.platform !== "win32" && options.env.XDG_CONFIG_HOME) {
21
+ result.push(p.join(options.env.XDG_CONFIG_HOME, "pi-md-viewer", "config.json"));
22
+ }
23
+ result.push(p.join(options.homeDir, ".config", "pi-md-viewer", "config.json"));
24
+ return [...new Set(result)];
25
+ }
26
+ function parseConfig(text, source) {
27
+ let value;
28
+ try {
29
+ value = JSON.parse(text);
30
+ }
31
+ catch (error) {
32
+ throw new Error(`Invalid JSON in ${source}: ${error instanceof Error ? error.message : String(error)}`);
33
+ }
34
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
35
+ throw new Error(`Invalid config in ${source}: expected object`);
36
+ }
37
+ const raw = value;
38
+ const config = {};
39
+ if (raw.defaultViewer !== undefined) {
40
+ if (typeof raw.defaultViewer !== "string" || raw.defaultViewer.trim() === "") {
41
+ throw new Error(`Invalid config in ${source}: defaultViewer must be a non-empty string`);
42
+ }
43
+ config.defaultViewer = raw.defaultViewer;
44
+ }
45
+ if (raw.playwright !== undefined) {
46
+ if (typeof raw.playwright !== "object" || raw.playwright === null || Array.isArray(raw.playwright)) {
47
+ throw new Error(`Invalid config in ${source}: playwright must be an object`);
48
+ }
49
+ const browser = raw.playwright.browser;
50
+ if (browser !== undefined && browser !== "chromium" && browser !== "firefox" && browser !== "webkit") {
51
+ throw new Error(`Invalid config in ${source}: playwright.browser must be chromium, firefox, or webkit`);
52
+ }
53
+ config.playwright = { browser: (browser ?? "chromium") };
54
+ }
55
+ if (raw.viewers !== undefined) {
56
+ if (typeof raw.viewers !== "object" || raw.viewers === null || Array.isArray(raw.viewers)) {
57
+ throw new Error(`Invalid config in ${source}: viewers must be an object`);
58
+ }
59
+ const viewers = {};
60
+ for (const [name, viewer] of Object.entries(raw.viewers)) {
61
+ if (typeof viewer !== "object" || viewer === null || Array.isArray(viewer)) {
62
+ throw new Error(`Invalid config in ${source}: viewers.${name} must be an object`);
63
+ }
64
+ const command = viewer.command;
65
+ if (!Array.isArray(command) || command.length === 0 || !command.every((item) => typeof item === "string")) {
66
+ throw new Error(`Invalid config in ${source}: viewers.${name}.command must be a non-empty string array`);
67
+ }
68
+ viewers[name] = { command };
69
+ }
70
+ config.viewers = viewers;
71
+ }
72
+ return config;
73
+ }
74
+ function mergeConfig(base, next) {
75
+ return {
76
+ defaultViewer: next.defaultViewer ?? base.defaultViewer,
77
+ playwright: { ...base.playwright, ...next.playwright },
78
+ viewers: { ...base.viewers, ...next.viewers },
79
+ };
80
+ }
81
+ function readConfig(pathname, options) {
82
+ const reader = options.readFile ?? ((file) => (existsSync(file) ? readFileSync(file, "utf8") : undefined));
83
+ const content = reader(pathname);
84
+ return content === undefined ? undefined : parseConfig(content, pathname);
85
+ }
86
+ export function loadEffectiveConfig(options) {
87
+ const p = pathApi(options.platform);
88
+ let config = builtInDefaultConfig;
89
+ for (const candidate of userConfigCandidates(options)) {
90
+ const loaded = readConfig(candidate, options);
91
+ if (loaded !== undefined) {
92
+ config = mergeConfig(config, loaded);
93
+ break;
94
+ }
95
+ }
96
+ config = mergeConfig(config, readConfig(p.join(options.cwd, ".md-viewer.json"), options) ?? {});
97
+ if (options.launchedFromPi && options.projectTrusted) {
98
+ config = mergeConfig(config, readConfig(p.join(options.cwd, ".pi", "pi-md-viewer.json"), options) ?? {});
99
+ }
100
+ const plugin = options.launchedFromPi ? options.piOverrides : undefined;
101
+ const viewerCommand = options.cliOverrides?.viewerCommand ?? plugin?.viewerCommand;
102
+ const viewer = options.cliOverrides?.viewer ?? plugin?.viewer ?? config.defaultViewer;
103
+ return { viewer, viewerCommand, config };
104
+ }
105
+ export function loadDefaultEffectiveConfig(overrides = {}) {
106
+ return loadEffectiveConfig({
107
+ cwd: process.cwd(),
108
+ env: process.env,
109
+ platform: process.platform,
110
+ homeDir: os.homedir(),
111
+ launchedFromPi: false,
112
+ cliOverrides: overrides,
113
+ });
114
+ }
@@ -0,0 +1,95 @@
1
+ export const DEFAULT_PORTS = Array.from({ length: 10 }, (_, index) => 18765 + index);
2
+ export const PROBE_TIMEOUT_MS = 150;
3
+ export function portRangeFromEnv(env = process.env) {
4
+ const value = env.PI_MD_VIEWER_PORT_RANGE;
5
+ if (!value)
6
+ return DEFAULT_PORTS;
7
+ const ports = [];
8
+ for (const part of value.split(",")) {
9
+ const trimmed = part.trim();
10
+ if (!trimmed)
11
+ continue;
12
+ if (trimmed.includes("-")) {
13
+ const [startText, endText] = trimmed.split("-", 2);
14
+ const start = Number(startText);
15
+ const end = Number(endText);
16
+ for (let port = start; port <= end; port += 1)
17
+ ports.push(port);
18
+ }
19
+ else {
20
+ ports.push(Number(trimmed));
21
+ }
22
+ }
23
+ if (ports.length === 0 || ports.some((port) => !Number.isInteger(port) || port < 1 || port > 65535)) {
24
+ throw new Error("PI_MD_VIEWER_PORT_RANGE contains invalid ports");
25
+ }
26
+ return [...new Set(ports)];
27
+ }
28
+ async function requestJson(method, url, options) {
29
+ const controller = new AbortController();
30
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
31
+ try {
32
+ const response = await fetch(url, {
33
+ method,
34
+ body: options.payload === undefined ? undefined : JSON.stringify(options.payload),
35
+ headers: {
36
+ ...(options.payload === undefined ? {} : { "Content-Type": "application/json" }),
37
+ ...(options.headers ?? {}),
38
+ },
39
+ signal: controller.signal,
40
+ });
41
+ const value = await response.json().catch(() => undefined);
42
+ if (!response.ok) {
43
+ if (options.raise)
44
+ throw new Error(String(value?.error?.message ?? response.statusText));
45
+ return undefined;
46
+ }
47
+ return typeof value === "object" && value !== null ? value : undefined;
48
+ }
49
+ catch (error) {
50
+ if (options.raise)
51
+ throw error;
52
+ return undefined;
53
+ }
54
+ finally {
55
+ clearTimeout(timeout);
56
+ }
57
+ }
58
+ export async function discover(ports = portRangeFromEnv(), _env = process.env) {
59
+ for (const port of ports) {
60
+ const payload = await requestJson("GET", `http://127.0.0.1:${port}/api/v1/status`, { timeoutMs: PROBE_TIMEOUT_MS });
61
+ if (!payload)
62
+ continue;
63
+ const protocol = String(payload.protocolVersion ?? "");
64
+ if (payload.application !== "pi-md-viewer" || protocol.split(".")[0] !== "1")
65
+ continue;
66
+ return {
67
+ host: "127.0.0.1",
68
+ port,
69
+ url: String(payload.url),
70
+ pid: Number(payload.pid),
71
+ viewerClients: Number(payload.viewerClients ?? 0),
72
+ files: Number(payload.files ?? 0),
73
+ };
74
+ }
75
+ return undefined;
76
+ }
77
+ export async function sendFiles(instance, paths) {
78
+ const payload = await requestJson("POST", `${instance.url}api/v1/files`, {
79
+ payload: { paths },
80
+ headers: { "X-Pi-Md-Viewer-CLI": "1" },
81
+ timeoutMs: 3000,
82
+ raise: true,
83
+ });
84
+ if (!payload)
85
+ throw new Error("Viewer did not return a response");
86
+ return payload;
87
+ }
88
+ export async function shutdownInstance(instance) {
89
+ await requestJson("POST", `${instance.url}api/v1/shutdown`, {
90
+ payload: {},
91
+ headers: { "X-Pi-Md-Viewer-CLI": "1" },
92
+ timeoutMs: 3000,
93
+ raise: true,
94
+ });
95
+ }
@@ -0,0 +1,54 @@
1
+ export const HEARTBEAT_INTERVAL_SECONDS = 2;
2
+ export const CLIENT_STALE_SECONDS = 8;
3
+ export const LAST_CLIENT_GRACE_SECONDS = 5;
4
+ export const STARTUP_IDLE_SECONDS = 30;
5
+ export class ClientLifecycle {
6
+ startedAt;
7
+ clients = new Map();
8
+ everHadClient = false;
9
+ lastClientLeftAt;
10
+ constructor(startedAt) {
11
+ this.startedAt = startedAt;
12
+ }
13
+ get clientCount() {
14
+ return this.clients.size;
15
+ }
16
+ open(clientId, now) {
17
+ this.clients.set(clientId, now);
18
+ this.everHadClient = true;
19
+ this.lastClientLeftAt = undefined;
20
+ }
21
+ heartbeat(clientId, now) {
22
+ if (!this.clients.has(clientId))
23
+ return false;
24
+ this.clients.set(clientId, now);
25
+ return true;
26
+ }
27
+ close(clientId, now) {
28
+ if (!this.clients.delete(clientId))
29
+ return false;
30
+ if (this.clients.size === 0)
31
+ this.lastClientLeftAt = now;
32
+ return true;
33
+ }
34
+ decision(now) {
35
+ if (this.clients.size > 0) {
36
+ for (const [clientId, lastSeen] of [...this.clients]) {
37
+ if (now >= lastSeen + CLIENT_STALE_SECONDS)
38
+ this.clients.delete(clientId);
39
+ }
40
+ if (this.clients.size > 0)
41
+ return { shouldShutdown: false };
42
+ this.lastClientLeftAt ??= now;
43
+ }
44
+ if (!this.everHadClient) {
45
+ return now >= this.startedAt + STARTUP_IDLE_SECONDS
46
+ ? { shouldShutdown: true, reason: "startup_idle" }
47
+ : { shouldShutdown: false };
48
+ }
49
+ this.lastClientLeftAt ??= now;
50
+ return now >= this.lastClientLeftAt + LAST_CLIENT_GRACE_SECONDS
51
+ ? { shouldShutdown: true, reason: "last_client" }
52
+ : { shouldShutdown: false };
53
+ }
54
+ }
@@ -0,0 +1,5 @@
1
+ import { main } from "./cli.js";
2
+ main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (error) => {
3
+ console.error(error instanceof Error ? error.message : String(error));
4
+ process.exitCode = 1;
5
+ });
@@ -0,0 +1,26 @@
1
+ export function isTermux(platform, env) {
2
+ return (platform === "linux" || platform === "android")
3
+ && typeof env.PREFIX === "string"
4
+ && env.PREFIX.includes("/com.termux/");
5
+ }
6
+ export function systemOpenCommand(platform, env) {
7
+ if (isTermux(platform, env))
8
+ return ["termux-open-url", "{url}"];
9
+ if (platform === "win32")
10
+ return ["rundll32", "url.dll,FileProtocolHandler", "{url}"];
11
+ if (platform === "darwin")
12
+ return ["open", "{url}"];
13
+ if (platform === "linux")
14
+ return ["xdg-open", "{url}"];
15
+ return null;
16
+ }
17
+ export function headedPlaywrightUnavailableReason(platform, env) {
18
+ if (isTermux(platform, env))
19
+ return "unsupported-platform";
20
+ if (platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) {
21
+ return "headed browser requires DISPLAY or WAYLAND_DISPLAY";
22
+ }
23
+ if (platform === "win32" || platform === "darwin" || platform === "linux")
24
+ return null;
25
+ return "unsupported-platform";
26
+ }
@@ -0,0 +1,81 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { accessSync, constants, realpathSync, statSync } from "node:fs";
3
+ import path from "node:path";
4
+ const allowedSuffixes = new Set([".md", ".markdown", ".mdown", ".mkd"]);
5
+ export class FileRegistry {
6
+ records = new Map();
7
+ idsByPath = new Map();
8
+ nextOrder = 0;
9
+ addMany(paths) {
10
+ const added = [];
11
+ const existing = [];
12
+ const rejected = [];
13
+ for (const supplied of paths) {
14
+ let canonical;
15
+ let stat;
16
+ try {
17
+ canonical = realpathSync(supplied);
18
+ stat = statSync(canonical);
19
+ }
20
+ catch {
21
+ rejected.push({ path: supplied, code: "not_found", message: "File does not exist" });
22
+ continue;
23
+ }
24
+ if (!stat.isFile()) {
25
+ rejected.push({ path: supplied, code: "not_file", message: "Path is not a file" });
26
+ continue;
27
+ }
28
+ if (!allowedSuffixes.has(path.extname(canonical).toLowerCase())) {
29
+ rejected.push({ path: supplied, code: "unsupported", message: "Unsupported Markdown file extension" });
30
+ continue;
31
+ }
32
+ try {
33
+ accessSync(canonical, constants.R_OK);
34
+ }
35
+ catch {
36
+ rejected.push({ path: supplied, code: "unreadable", message: "File is not readable" });
37
+ continue;
38
+ }
39
+ const known = this.idsByPath.get(canonical);
40
+ if (known) {
41
+ const record = this.records.get(known);
42
+ if (record)
43
+ existing.push(record);
44
+ continue;
45
+ }
46
+ const record = {
47
+ id: randomUUID().replaceAll("-", ""),
48
+ path: canonical,
49
+ displayName: path.basename(canonical),
50
+ parentLabel: path.basename(path.dirname(canonical)) || null,
51
+ size: stat.size,
52
+ mtimeMs: stat.mtimeMs,
53
+ order: this.nextOrder++,
54
+ };
55
+ this.records.set(record.id, record);
56
+ this.idsByPath.set(canonical, record.id);
57
+ added.push(record);
58
+ }
59
+ return { added, existing, rejected };
60
+ }
61
+ list() {
62
+ return [...this.records.values()].sort((a, b) => a.order - b.order);
63
+ }
64
+ get(id) {
65
+ return this.records.get(id);
66
+ }
67
+ remove(id) {
68
+ const record = this.records.get(id);
69
+ if (record) {
70
+ this.records.delete(id);
71
+ this.idsByPath.delete(record.path);
72
+ }
73
+ return record;
74
+ }
75
+ clear() {
76
+ const removed = this.list();
77
+ this.records.clear();
78
+ this.idsByPath.clear();
79
+ return removed;
80
+ }
81
+ }
@@ -0,0 +1,67 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import hljs from "highlight.js";
3
+ import MarkdownIt from "markdown-it";
4
+ import anchor from "markdown-it-anchor";
5
+ import taskLists from "markdown-it-task-lists";
6
+ export class RenderError extends Error {
7
+ code;
8
+ constructor(code, message) {
9
+ super(message);
10
+ this.code = code;
11
+ this.name = "RenderError";
12
+ }
13
+ }
14
+ function slugify(value) {
15
+ return value
16
+ .toLowerCase()
17
+ .trim()
18
+ .replace(/[^\p{Letter}\p{Number}]+/gu, "-")
19
+ .replace(/^-+|-+$/g, "") || "section";
20
+ }
21
+ export class MarkdownRenderer {
22
+ options;
23
+ constructor(options = {}) {
24
+ this.options = options;
25
+ }
26
+ render(file) {
27
+ const maxBytes = this.options.maxBytes ?? 1024 * 1024;
28
+ const stat = statSync(file);
29
+ if (stat.size > maxBytes)
30
+ throw new RenderError("too_large", "Markdown file is too large");
31
+ const buffer = readFileSync(file);
32
+ let text;
33
+ try {
34
+ text = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
35
+ }
36
+ catch (error) {
37
+ throw new RenderError("invalid_utf8", "Markdown file is not valid UTF-8");
38
+ }
39
+ const toc = [];
40
+ const seen = new Map();
41
+ const md = new MarkdownIt({
42
+ html: false,
43
+ linkify: true,
44
+ typographer: false,
45
+ highlight(code, language) {
46
+ const valid = language && hljs.getLanguage(language);
47
+ const highlighted = valid ? hljs.highlight(code, { language }).value : hljs.highlightAuto(code).value;
48
+ return `<pre class="highlight"><code class="hljs">${highlighted}</code></pre>`;
49
+ },
50
+ })
51
+ .use(taskLists, { enabled: false, label: false })
52
+ .use(anchor, {
53
+ slugify,
54
+ uniqueSlugStartIndex: 1,
55
+ callback(token, info) {
56
+ const base = slugify(info.title);
57
+ const count = (seen.get(base) ?? 0) + 1;
58
+ seen.set(base, count);
59
+ const id = count === 1 ? base : `${base}-${count}`;
60
+ token.attrSet("id", id);
61
+ toc.push({ level: Number(token.tag.slice(1)), id, title: info.title });
62
+ },
63
+ });
64
+ const html = md.render(text);
65
+ return { html, toc, revision: `${stat.mtimeMs}:${stat.size}` };
66
+ }
67
+ }