@tacone/prosey 0.3.0 → 0.4.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 +21 -27
- package/bin/prosey +16893 -15398
- package/package.json +2 -1
- package/src/html.test.ts +25 -0
- package/src/html.ts +141 -0
- package/src/index.ts +42 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tacone/prosey",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Download YouTube video transcripts from the CLI",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
60
|
"js-toml": "^1.1.2",
|
|
61
|
+
"marked": "^18.0.5",
|
|
61
62
|
"prettier": "^3.8.4",
|
|
62
63
|
"youtube-transcript-plus": "^2.0.0"
|
|
63
64
|
}
|
package/src/html.test.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { generateHtml } from "./html";
|
|
3
|
+
|
|
4
|
+
describe("generateHtml", () => {
|
|
5
|
+
test("wraps markdown in HTML with PicoCSS", async () => {
|
|
6
|
+
const html = await generateHtml("# Hello\n\nWorld", "Test");
|
|
7
|
+
expect(html).toStartWith("<!DOCTYPE html>");
|
|
8
|
+
expect(html).toContain("<title>Test</title>");
|
|
9
|
+
expect(html).toContain("<style>");
|
|
10
|
+
expect(html).toContain("--pico-");
|
|
11
|
+
expect(html).toContain("<h1>Hello</h1>");
|
|
12
|
+
expect(html).toContain("<p>World</p>");
|
|
13
|
+
expect(html).toContain("</html>");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("uses default title when none given", async () => {
|
|
17
|
+
const html = await generateHtml("hello");
|
|
18
|
+
expect(html).toContain("<title>Prosey</title>");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("escapes HTML in title", async () => {
|
|
22
|
+
const html = await generateHtml("hello", '<script>alert("xss")</script>');
|
|
23
|
+
expect(html).toContain("<script>alert("xss")</script>");
|
|
24
|
+
});
|
|
25
|
+
});
|
package/src/html.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { marked } from "marked";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import type { ChildProcess } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
const PICO_CSS_URL = "https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css";
|
|
6
|
+
|
|
7
|
+
let cachedCss: string | null = null;
|
|
8
|
+
|
|
9
|
+
async function getPicoCss(): Promise<string> {
|
|
10
|
+
if (cachedCss) return cachedCss;
|
|
11
|
+
const res = await fetch(PICO_CSS_URL);
|
|
12
|
+
cachedCss = await res.text();
|
|
13
|
+
return cachedCss;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const LOGO_SVG = `<svg width="100" viewBox="92 120 264 68" role="img" title="Prosey" xmlns="http://www.w3.org/2000/svg">
|
|
17
|
+
<title>Prosey</title>
|
|
18
|
+
<path d="M100,545 a4,4 0 0 0-4,4 v50 a4,4 0 0 0 6,3.46 l43,-25 a4,4 0 0 0 0,-6.93 l-43,-25 a4,4 0 0 0-2,-0.53 Z" transform="translate(0,-405)" fill="#9B8BF4" opacity="0.25"/>
|
|
19
|
+
<path d="M96,130 C96,126 99,124 102,126 L145,149 C148,151 148,155 145,157 L102,180 C99,182 96,180 96,176 Z" fill="none" stroke="#9B8BF4" stroke-width="2.5" stroke-linejoin="round"/>
|
|
20
|
+
<path d="M96,130 C96,126 99,124 102,126 L145,149 C148,151 148,155 145,157 L102,180 C99,182 96,180 96,176 Z" fill="#9B8BF4" opacity="0.18"/>
|
|
21
|
+
<path d="M162,142 L178,154 L162,166" fill="none" stroke="#9B8BF4" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"/>
|
|
22
|
+
<path d="M176,142 L192,154 L176,166" fill="none" stroke="#9B8BF4" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"/>
|
|
23
|
+
<rect x="212" y="138" width="140" height="12" rx="6" fill="#9B8BF4" opacity="0.85"/>
|
|
24
|
+
<rect x="212" y="155" width="116" height="12" rx="6" fill="#9B8BF4" opacity="0.55"/>
|
|
25
|
+
<rect x="212" y="172" width="130" height="12" rx="6" fill="#9B8BF4" opacity="0.3"/>
|
|
26
|
+
</svg>`;
|
|
27
|
+
const LOGO_DATA_URI = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(LOGO_SVG)}`;
|
|
28
|
+
|
|
29
|
+
function escapeHtml(text: string): string {
|
|
30
|
+
return text
|
|
31
|
+
.replace(/&/g, "&")
|
|
32
|
+
.replace(/</g, "<")
|
|
33
|
+
.replace(/>/g, ">")
|
|
34
|
+
.replace(/"/g, """);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function generateHtml(markdown: string, title?: string): Promise<string> {
|
|
38
|
+
const [css, body] = await Promise.all([getPicoCss(), marked.parse(markdown)]);
|
|
39
|
+
|
|
40
|
+
return `<!DOCTYPE html>
|
|
41
|
+
<html lang="en">
|
|
42
|
+
<head>
|
|
43
|
+
<meta charset="UTF-8">
|
|
44
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
45
|
+
<title>${escapeHtml(title ?? "Prosey")}</title>
|
|
46
|
+
<script>(function(){var m=localStorage.getItem('prosey-theme'),t=m||'auto';if(t==='auto')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';document.documentElement.setAttribute('data-theme',t);if(!m)localStorage.setItem('prosey-theme','auto')})();</script>
|
|
47
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
48
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
49
|
+
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300;0,400;0,500;1,300&display=swap" rel="stylesheet">
|
|
50
|
+
<style>
|
|
51
|
+
${css}
|
|
52
|
+
:root {
|
|
53
|
+
--pico-font-family: 'Plus Jakarta Sans', sans-serif;
|
|
54
|
+
--pico-font-size: 120%;
|
|
55
|
+
--pico-line-height: 1.78;
|
|
56
|
+
--pico-font-weight: 300;
|
|
57
|
+
--pico-blockquote-border-color: #9B8BF4;
|
|
58
|
+
}
|
|
59
|
+
@media (max-width: 767px) {
|
|
60
|
+
:root {
|
|
61
|
+
--pico-font-size: 110%;
|
|
62
|
+
--pico-line-height: 1.50;
|
|
63
|
+
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
h1 { --pico-font-size: 1.9rem; }
|
|
67
|
+
h2 { --pico-font-size: 1.5rem; }
|
|
68
|
+
h3 { --pico-font-size: 1.25rem; }
|
|
69
|
+
h4 { --pico-font-size: 1.1rem; }
|
|
70
|
+
h5 { --pico-font-size: 1rem; }
|
|
71
|
+
h6 { --pico-font-size: 0.85rem; }
|
|
72
|
+
h1, h2, h3, h4, h5, h6 {
|
|
73
|
+
--pico-font-weight: 500;
|
|
74
|
+
--pico-line-height: 1.25;
|
|
75
|
+
margin-top: 0;
|
|
76
|
+
}
|
|
77
|
+
h1 {
|
|
78
|
+
letter-spacing: -0.03em;
|
|
79
|
+
margin-bottom: calc(var(--pico-typography-spacing-vertical) * 2);
|
|
80
|
+
}
|
|
81
|
+
li:last-child {
|
|
82
|
+
margin-bottom: 0px;
|
|
83
|
+
}
|
|
84
|
+
blockquote {
|
|
85
|
+
font-style: italic;
|
|
86
|
+
font-size: 1.05rem;
|
|
87
|
+
color: var(--pico-muted-color);
|
|
88
|
+
padding-top: 0;
|
|
89
|
+
padding-bottom: 0;
|
|
90
|
+
}
|
|
91
|
+
blockquote:first-child { margin-top: 0; }
|
|
92
|
+
blockquote:last-child { margin-bottom: 0; }
|
|
93
|
+
* { transition: all 0.3s; }
|
|
94
|
+
#theme-btn:focus, #theme-btn:active { outline: none !important; }
|
|
95
|
+
#theme-btn:hover, #theme-btn:focus, #theme-btn:active { opacity: 1 !important; }
|
|
96
|
+
img[alt="Prosey"] { filter: grayscale(100%); }
|
|
97
|
+
img[alt="Prosey"]:hover, img[alt="Prosey"]:active, img[alt="Prosey"]:focus { filter: grayscale(0%); }
|
|
98
|
+
</style>
|
|
99
|
+
</head>
|
|
100
|
+
<body>
|
|
101
|
+
<div style="display:flex;justify-content:space-between;align-items:flex-start;padding:1rem 1rem 0">
|
|
102
|
+
<img src="${LOGO_DATA_URI}" alt="Prosey" title="Prosey" style="vertical-align:top">
|
|
103
|
+
<button id="theme-btn" type="button" style="background:none;border:none;cursor:pointer;padding:0;line-height:1;opacity:.5;filter:grayscale(100%);transition:all 0.3s">💡</button>
|
|
104
|
+
</div>
|
|
105
|
+
<main style="max-width:720px;margin:0 auto;padding:1rem">
|
|
106
|
+
${body}
|
|
107
|
+
</main>
|
|
108
|
+
<script>
|
|
109
|
+
(function(){var b=document.getElementById('theme-btn'),modes=['auto','light','dark'],icons={auto:'\u{1F4A1}',light:'\u2600\uFE0F',dark:'\u{1F319}'};function apply(m){var t=m;if(t==='auto')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';document.documentElement.setAttribute('data-theme',t);localStorage.setItem('prosey-theme',m);b.textContent=icons[m]}b.addEventListener('click',function(){var m=localStorage.getItem('prosey-theme')||'auto',i=modes.indexOf(m);apply(modes[(i+1)%modes.length])})})();
|
|
110
|
+
</script>
|
|
111
|
+
</body>
|
|
112
|
+
</html>`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function openInBrowser(htmlPath: string): Promise<void> {
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
let proc: ChildProcess;
|
|
118
|
+
const { platform } = process;
|
|
119
|
+
|
|
120
|
+
if (platform === "darwin") {
|
|
121
|
+
proc = spawn("open", [htmlPath], { stdio: "ignore" });
|
|
122
|
+
} else if (platform === "win32") {
|
|
123
|
+
proc = spawn("cmd", ["/c", "start", "", htmlPath], {
|
|
124
|
+
stdio: "ignore",
|
|
125
|
+
shell: true,
|
|
126
|
+
});
|
|
127
|
+
} else {
|
|
128
|
+
proc = spawn("xdg-open", [htmlPath], { stdio: "ignore" });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
proc.on("error", () => {
|
|
132
|
+
resolve();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
proc.on("exit", () => {
|
|
136
|
+
resolve();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
setTimeout(() => resolve(), 5000);
|
|
140
|
+
});
|
|
141
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { setLevel, info, debug, startTimer, resetTimer, hint } from "./debug";
|
|
|
4
4
|
import type { LogLevel } from "./debug";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { writeFile } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
7
8
|
import { detectPager } from "./pager";
|
|
8
9
|
import { fetchTranscript, listLanguages } from "youtube-transcript-plus";
|
|
9
10
|
import type { CaptionTrackInfo, VideoDetails, TranscriptSegment } from "youtube-transcript-plus";
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
} from "./config-resolve";
|
|
20
21
|
import { cacheDir, readCache, writeCache, extractVideoId } from "./cache";
|
|
21
22
|
import { extractChapters, formatChaptersAsText, formatChaptersAsJson } from "./extract-chapters";
|
|
23
|
+
import { generateHtml, openInBrowser } from "./html";
|
|
22
24
|
import { checkVersion } from "./version-check";
|
|
23
25
|
import pkg from "../package.json";
|
|
24
26
|
import prettier from "prettier";
|
|
@@ -71,10 +73,11 @@ Options:
|
|
|
71
73
|
-t, --timestamps Include timestamps [MM:SS] in output.
|
|
72
74
|
--list List available transcript languages and exit.
|
|
73
75
|
-o, --output <path> Write output to file instead of stdout.
|
|
74
|
-
--format <type> Output format: markdown (default), text, or
|
|
76
|
+
--format <type> Output format: markdown (default), text, json, or html.
|
|
75
77
|
--json Shortcut for --format json.
|
|
76
78
|
--text Shortcut for --format text.
|
|
77
79
|
--markdown Shortcut for --format markdown.
|
|
80
|
+
--html Shortcut for --format html (opens in browser).
|
|
78
81
|
--details Prepend video details to transcript (default, text only).
|
|
79
82
|
--no-details Suppress video details, transcript only.
|
|
80
83
|
--no-decode-entities Preserve HTML entities (decoded by default).
|
|
@@ -245,7 +248,7 @@ let timestamps = false;
|
|
|
245
248
|
let listOnly = false;
|
|
246
249
|
let outputPath: string | undefined;
|
|
247
250
|
let outputJson = false;
|
|
248
|
-
let format: "text" | "json" | "markdown" = "markdown";
|
|
251
|
+
let format: "text" | "json" | "markdown" | "html" = "markdown";
|
|
249
252
|
let noDecode = false;
|
|
250
253
|
let showDetails = true;
|
|
251
254
|
let noCache = false;
|
|
@@ -283,6 +286,8 @@ for (let i = 0; i < args.length; i++) {
|
|
|
283
286
|
format = "text";
|
|
284
287
|
} else if (arg === "--markdown") {
|
|
285
288
|
format = "markdown";
|
|
289
|
+
} else if (arg === "--html") {
|
|
290
|
+
format = "html";
|
|
286
291
|
} else if (arg === "--format") {
|
|
287
292
|
const val = args[++i];
|
|
288
293
|
if (val === "json") {
|
|
@@ -294,8 +299,11 @@ for (let i = 0; i < args.length; i++) {
|
|
|
294
299
|
} else if (val === "markdown") {
|
|
295
300
|
format = "markdown";
|
|
296
301
|
outputJson = false;
|
|
302
|
+
} else if (val === "html") {
|
|
303
|
+
format = "html";
|
|
304
|
+
outputJson = false;
|
|
297
305
|
} else {
|
|
298
|
-
console.error("Error: --format must be text, json, or
|
|
306
|
+
console.error("Error: --format must be text, json, markdown, or html");
|
|
299
307
|
exitProcess(1);
|
|
300
308
|
}
|
|
301
309
|
} else if (arg === "--details") {
|
|
@@ -489,7 +497,21 @@ try {
|
|
|
489
497
|
}
|
|
490
498
|
|
|
491
499
|
const formatted = noFormat ? summary : await formatMd(summary);
|
|
492
|
-
|
|
500
|
+
if (format === "html") {
|
|
501
|
+
const htmlContent = await generateHtml(formatted);
|
|
502
|
+
const htmlPath = join(dir, "summary.html");
|
|
503
|
+
await writeFile(htmlPath, htmlContent, "utf8");
|
|
504
|
+
debug("HTML written:", htmlPath);
|
|
505
|
+
if (outputPath) {
|
|
506
|
+
await writeFile(outputPath, htmlContent, "utf8");
|
|
507
|
+
} else if (!process.stdout.isTTY) {
|
|
508
|
+
process.stdout.write(htmlContent);
|
|
509
|
+
} else {
|
|
510
|
+
await openInBrowser(htmlPath);
|
|
511
|
+
}
|
|
512
|
+
} else {
|
|
513
|
+
await outputText(formatted + "\n");
|
|
514
|
+
}
|
|
493
515
|
exitProcess(0);
|
|
494
516
|
} else if (listOnly) {
|
|
495
517
|
const languages = await listLanguages(videoId);
|
|
@@ -497,7 +519,7 @@ try {
|
|
|
497
519
|
exitProcess(0);
|
|
498
520
|
}
|
|
499
521
|
|
|
500
|
-
if (format === "markdown") {
|
|
522
|
+
if (format === "markdown" || format === "html") {
|
|
501
523
|
const transcribeCmd = resolveTranscribeCmd(config);
|
|
502
524
|
if (!transcribeCmd) {
|
|
503
525
|
console.error(
|
|
@@ -635,7 +657,21 @@ try {
|
|
|
635
657
|
}
|
|
636
658
|
|
|
637
659
|
const formatted = noFormat ? md : await formatMd(md);
|
|
638
|
-
|
|
660
|
+
if (format === "html") {
|
|
661
|
+
const htmlContent = await generateHtml(formatted);
|
|
662
|
+
const htmlPath = join(dir, "transcript.html");
|
|
663
|
+
await writeFile(htmlPath, htmlContent, "utf8");
|
|
664
|
+
debug("HTML written:", htmlPath);
|
|
665
|
+
if (outputPath) {
|
|
666
|
+
await writeFile(outputPath, htmlContent, "utf8");
|
|
667
|
+
} else if (!process.stdout.isTTY) {
|
|
668
|
+
process.stdout.write(htmlContent);
|
|
669
|
+
} else {
|
|
670
|
+
await openInBrowser(htmlPath);
|
|
671
|
+
}
|
|
672
|
+
} else {
|
|
673
|
+
await outputText(formatted + "\n");
|
|
674
|
+
}
|
|
639
675
|
exitProcess(0);
|
|
640
676
|
}
|
|
641
677
|
|