@tacone/prosey 0.3.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tacone/prosey",
3
- "version": "0.3.0",
3
+ "version": "0.5.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
  }
@@ -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("&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;");
24
+ });
25
+ });
package/src/html.ts ADDED
@@ -0,0 +1,142 @@
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, "&amp;")
32
+ .replace(/</g, "&lt;")
33
+ .replace(/>/g, "&gt;")
34
+ .replace(/"/g, "&quot;");
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, #theme-btn:focus, #theme-btn:active, #theme-btn:hover { box-shadow: none !important; }
95
+ #theme-btn:focus, #theme-btn:active { outline: none !important; }
96
+ #theme-btn:hover, #theme-btn:focus, #theme-btn:active { opacity: 1 !important; }
97
+ img[alt="Prosey"] { filter: grayscale(100%); }
98
+ img[alt="Prosey"]:hover, img[alt="Prosey"]:active, img[alt="Prosey"]:focus { filter: grayscale(0%); }
99
+ </style>
100
+ </head>
101
+ <body>
102
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;padding:1rem 1rem 0">
103
+ <img src="${LOGO_DATA_URI}" alt="Prosey" title="Prosey" style="vertical-align:top">
104
+ <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>
105
+ </div>
106
+ <main style="max-width:720px;margin:0 auto;padding:1rem">
107
+ ${body}
108
+ </main>
109
+ <script>
110
+ (function(){var b=document.getElementById('theme-btn'),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',d=window.matchMedia('(prefers-color-scheme:dark)').matches;apply(d?{auto:'light',light:'dark',dark:'auto'}[m]:{auto:'dark',dark:'light',light:'auto'}[m])});apply(localStorage.getItem('prosey-theme')||'auto')})();
111
+ </script>
112
+ </body>
113
+ </html>`;
114
+ }
115
+
116
+ export function openInBrowser(htmlPath: string): Promise<void> {
117
+ return new Promise((resolve, reject) => {
118
+ let proc: ChildProcess;
119
+ const { platform } = process;
120
+
121
+ if (platform === "darwin") {
122
+ proc = spawn("open", [htmlPath], { stdio: "ignore" });
123
+ } else if (platform === "win32") {
124
+ proc = spawn("cmd", ["/c", "start", "", htmlPath], {
125
+ stdio: "ignore",
126
+ shell: true,
127
+ });
128
+ } else {
129
+ proc = spawn("xdg-open", [htmlPath], { stdio: "ignore" });
130
+ }
131
+
132
+ proc.on("error", () => {
133
+ resolve();
134
+ });
135
+
136
+ proc.on("exit", () => {
137
+ resolve();
138
+ });
139
+
140
+ setTimeout(() => resolve(), 5000);
141
+ });
142
+ }
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 json.
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 markdown");
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
- await outputText(formatted + "\n");
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
- await outputText(formatted + "\n");
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