promaster 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # promaster
2
2
 
3
- Interactive CLI to browse and download Markdown from a **public GitHub repo**,
4
- grouped into three categories: **blog**, **memory**, **books**.
3
+ Interactive CLI to browse Markdown from a **public GitHub repo**, grouped into
4
+ three categories: **blog**, **memory**, **books**. Each selected file is
5
+ rendered to a styled, standalone **HTML** page — click it to open in a browser.
5
6
 
6
7
  The package ships only the CLI. Your Markdown content lives in your own GitHub
7
8
  repo and is fetched at runtime — it is never bundled into npm.
@@ -32,7 +33,12 @@ promaster list
32
33
  1. Pick a category: `blog` / `memory` / `books`.
33
34
  2. Files are listed newest → oldest (by last git commit date).
34
35
  3. Toggle one or more with space, confirm with enter.
35
- 4. Selected files download to `./promaster-data/<category>/`, where your app reads them.
36
+ 4. Each selection is converted to HTML and saved to
37
+ `./promaster-data/<category>/<name>.html`.
38
+ 5. The CLI then offers to open the saved file(s) in your default browser.
39
+
40
+ Open any saved `.html` later by double-clicking it — it renders fully offline
41
+ (styles are inlined, with light/dark support).
36
42
 
37
43
  ## Configuration
38
44
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "promaster",
3
- "version": "1.1.0",
4
- "description": "Interactive CLI to browse and download Markdown (blog/memory/books) from a public GitHub repo.",
3
+ "version": "1.3.0",
4
+ "description": "Interactive CLI to browse Markdown (blog/memory/books) from a public GitHub repo and save it as browser-ready HTML.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "promaster": "./bin/promaster.js"
@@ -23,10 +23,12 @@
23
23
  "markdown",
24
24
  "github",
25
25
  "blog",
26
- "notes"
26
+ "notes",
27
+ "html"
27
28
  ],
28
29
  "license": "MIT",
29
30
  "dependencies": {
30
- "@inquirer/prompts": "^7.0.0"
31
+ "@inquirer/prompts": "^7.0.0",
32
+ "marked": "^14.0.0"
31
33
  }
32
34
  }
@@ -1,8 +1,11 @@
1
- import { select, checkbox } from "@inquirer/prompts";
1
+ import { select, checkbox, confirm, input } from "@inquirer/prompts";
2
2
  import { resolve, dirname } from "node:path";
3
+ import { rmSync } from "node:fs";
4
+ import { rm } from "node:fs/promises";
3
5
  import { resolveRepo, CATEGORIES } from "../config.js";
4
6
  import { listMarkdown, lastCommitDate, fetchRaw, HttpError } from "../github.js";
5
- import { save, OUTPUT_ROOT } from "../download.js";
7
+ import { save, saveTemp, OUTPUT_ROOT } from "../download.js";
8
+ import { openInBrowser } from "../open.js";
6
9
 
7
10
  const CONCURRENCY = 5;
8
11
 
@@ -53,15 +56,72 @@ export async function runList() {
53
56
  return;
54
57
  }
55
58
 
59
+ // books: open immediately, never persist — delete the temp files on exit.
60
+ if (category === "books") {
61
+ await openEphemeral(picked, ctx);
62
+ return;
63
+ }
64
+
56
65
  const saved = [];
57
66
  for (const file of picked) {
58
67
  const content = await fetchRaw(file.download_url, ctx);
59
68
  saved.push(await save(category, file, content));
60
69
  }
61
70
 
62
- console.log("\nSaved:");
71
+ console.log("\nSaved (HTML — click to open in a browser):");
63
72
  for (const p of saved) console.log(` ${p}`);
64
73
  console.log(`\nOutput dir: ${resolve(process.cwd(), OUTPUT_ROOT, category)}`);
74
+
75
+ const open = await confirm({
76
+ message: `Open ${saved.length === 1 ? "it" : "them"} in your browser now?`,
77
+ default: true,
78
+ });
79
+ if (open) {
80
+ for (const p of saved) openInBrowser(p);
81
+ }
82
+ }
83
+
84
+ // Render picks to OS temp, auto-open in the browser, then delete once the
85
+ // user is done (Enter) or aborts (Ctrl+C). Nothing is left on disk.
86
+ async function openEphemeral(picked, ctx) {
87
+ const dirs = [];
88
+ const cleanup = () => {
89
+ while (dirs.length) {
90
+ try {
91
+ rmSync(dirs.pop(), { recursive: true, force: true });
92
+ } catch {
93
+ /* best effort */
94
+ }
95
+ }
96
+ };
97
+
98
+ const onSigint = () => {
99
+ cleanup();
100
+ process.exit(130);
101
+ };
102
+ process.on("SIGINT", onSigint);
103
+
104
+ try {
105
+ for (const file of picked) {
106
+ const content = await fetchRaw(file.download_url, ctx);
107
+ const { path, dir } = await saveTemp(file, content);
108
+ dirs.push(dir);
109
+ openInBrowser(path);
110
+ console.log(`Opened in browser: ${file.name}`);
111
+ }
112
+
113
+ console.log("\nThese are temporary — nothing is saved to disk.");
114
+ await input({
115
+ message: `Press Enter when you're done to delete the temporary file${
116
+ picked.length === 1 ? "" : "s"
117
+ }`,
118
+ });
119
+ } finally {
120
+ process.off("SIGINT", onSigint);
121
+ for (const dir of dirs.splice(0)) {
122
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
123
+ }
124
+ }
65
125
  }
66
126
 
67
127
  export { HttpError };
package/src/download.js CHANGED
@@ -1,5 +1,7 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
2
- import { basename, resolve } from "node:path";
1
+ import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
2
+ import { basename, extname, resolve } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { mdToHtml } from "./render.js";
3
5
 
4
6
  export const OUTPUT_ROOT = "promaster-data";
5
7
 
@@ -15,12 +17,43 @@ export function safeName(name) {
15
17
  }
16
18
 
17
19
  /**
18
- * Save raw content under ./promaster-data/<category>/<name>. Returns absolute path.
20
+ * Safe basename with its extension replaced by ".html".
21
+ */
22
+ export function htmlName(name) {
23
+ const base = safeName(name);
24
+ const ext = extname(base);
25
+ const stem = ext ? base.slice(0, -ext.length) : base;
26
+ return `${stem}.html`;
27
+ }
28
+
29
+ /**
30
+ * Render markdown to a styled HTML document and save it under
31
+ * ./promaster-data/<category>/<name>.html. Returns the absolute path.
19
32
  */
20
33
  export async function save(category, file, content) {
21
34
  const dir = resolve(process.cwd(), OUTPUT_ROOT, safeName(category));
22
35
  await mkdir(dir, { recursive: true });
23
- const dest = resolve(dir, safeName(file.name));
24
- await writeFile(dest, content, "utf8");
36
+ const base = safeName(file.name);
37
+ const ext = extname(base);
38
+ const title = ext ? base.slice(0, -ext.length) : base;
39
+ const html = mdToHtml(content, { title });
40
+ const dest = resolve(dir, htmlName(file.name));
41
+ await writeFile(dest, html, "utf8");
25
42
  return dest;
26
43
  }
44
+
45
+ /**
46
+ * Render markdown to HTML in a fresh OS temp directory (never under
47
+ * promaster-data). Returns { path, dir } so the caller can delete `dir`
48
+ * once the user is done viewing — nothing is persisted.
49
+ */
50
+ export async function saveTemp(file, content) {
51
+ const dir = await mkdtemp(resolve(tmpdir(), "promaster-"));
52
+ const base = safeName(file.name);
53
+ const ext = extname(base);
54
+ const title = ext ? base.slice(0, -ext.length) : base;
55
+ const html = mdToHtml(content, { title });
56
+ const path = resolve(dir, htmlName(file.name));
57
+ await writeFile(path, html, "utf8");
58
+ return { path, dir };
59
+ }
package/src/open.js ADDED
@@ -0,0 +1,30 @@
1
+ import { spawn } from "node:child_process";
2
+ import { platform } from "node:process";
3
+
4
+ /**
5
+ * Open a file/path in the OS default application (browser for .html).
6
+ * Non-blocking and best-effort: failures are reported but never throw.
7
+ */
8
+ export function openInBrowser(filePath) {
9
+ let cmd, args;
10
+ if (platform === "win32") {
11
+ // empty "" is the window title arg required by `start`
12
+ cmd = "cmd";
13
+ args = ["/c", "start", "", filePath];
14
+ } else if (platform === "darwin") {
15
+ cmd = "open";
16
+ args = [filePath];
17
+ } else {
18
+ cmd = "xdg-open";
19
+ args = [filePath];
20
+ }
21
+ try {
22
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
23
+ child.on("error", (err) => {
24
+ console.error(`Could not open ${filePath}: ${err.message}`);
25
+ });
26
+ child.unref();
27
+ } catch (err) {
28
+ console.error(`Could not open ${filePath}: ${err.message}`);
29
+ }
30
+ }
package/src/render.js ADDED
@@ -0,0 +1,77 @@
1
+ import { marked } from "marked";
2
+
3
+ const ESCAPE = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
4
+
5
+ function escapeHtml(str) {
6
+ return String(str).replace(/[&<>"]/g, (ch) => ESCAPE[ch]);
7
+ }
8
+
9
+ const STYLE = `
10
+ :root { color-scheme: light dark; }
11
+ body {
12
+ max-width: 760px;
13
+ margin: 2.5rem auto;
14
+ padding: 0 1.25rem;
15
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
16
+ line-height: 1.65;
17
+ color: #1a1a1a;
18
+ background: #ffffff;
19
+ }
20
+ h1, h2, h3, h4 { line-height: 1.25; margin-top: 1.8em; }
21
+ h1 { border-bottom: 1px solid #e1e4e8; padding-bottom: .3em; }
22
+ a { color: #0969da; }
23
+ code {
24
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
25
+ font-size: .9em;
26
+ background: rgba(127,127,127,.15);
27
+ padding: .15em .35em;
28
+ border-radius: 4px;
29
+ }
30
+ pre {
31
+ background: #f6f8fa;
32
+ padding: 1rem;
33
+ border-radius: 8px;
34
+ overflow-x: auto;
35
+ }
36
+ pre code { background: none; padding: 0; }
37
+ blockquote {
38
+ margin: 0;
39
+ padding: 0 1em;
40
+ color: #57606a;
41
+ border-left: .25em solid #d0d7de;
42
+ }
43
+ table { border-collapse: collapse; width: 100%; }
44
+ th, td { border: 1px solid #d0d7de; padding: .5em .75em; }
45
+ img { max-width: 100%; }
46
+ @media (prefers-color-scheme: dark) {
47
+ body { color: #e6edf3; background: #0d1117; }
48
+ h1 { border-bottom-color: #30363d; }
49
+ a { color: #4493f8; }
50
+ pre { background: #161b22; }
51
+ blockquote { color: #8b949e; border-left-color: #30363d; }
52
+ th, td { border-color: #30363d; }
53
+ }
54
+ `;
55
+
56
+ /**
57
+ * Convert markdown text into a standalone, styled HTML document.
58
+ * @param {string} markdown raw markdown source
59
+ * @param {{ title?: string }} [opts]
60
+ * @returns {string} full HTML document
61
+ */
62
+ export function mdToHtml(markdown, { title = "Document" } = {}) {
63
+ const body = marked.parse(String(markdown));
64
+ return `<!DOCTYPE html>
65
+ <html lang="en">
66
+ <head>
67
+ <meta charset="utf-8">
68
+ <meta name="viewport" content="width=device-width, initial-scale=1">
69
+ <title>${escapeHtml(title)}</title>
70
+ <style>${STYLE}</style>
71
+ </head>
72
+ <body>
73
+ ${body}
74
+ </body>
75
+ </html>
76
+ `;
77
+ }