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 +9 -3
- package/package.json +6 -4
- package/src/commands/list.js +63 -3
- package/src/download.js +38 -5
- package/src/open.js +30 -0
- package/src/render.js +77 -0
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# promaster
|
|
2
2
|
|
|
3
|
-
Interactive CLI to browse
|
|
4
|
-
|
|
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.
|
|
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.
|
|
4
|
-
"description": "Interactive CLI to browse
|
|
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
|
}
|
package/src/commands/list.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
24
|
-
|
|
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 = { "&": "&", "<": "<", ">": ">", '"': """ };
|
|
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
|
+
}
|