ippon-slides 0.1.0 → 0.1.2

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/assets/style.css CHANGED
@@ -424,7 +424,8 @@ body.overview #deck,
424
424
  body.overview #progress,
425
425
  body.overview #counter,
426
426
  body.overview #timer,
427
- body.overview #hint {
427
+ body.overview #hint,
428
+ body.overview #presenter {
428
429
  display: none;
429
430
  }
430
431
  .ov-item {
@@ -466,6 +467,128 @@ body.overview #hint {
466
467
  font-weight: 600;
467
468
  }
468
469
 
470
+ /* ---------- presenter(p / 別ウィンドウ) ---------- */
471
+
472
+ /* 発表者ノートは本番・一覧・PDF には出さない(発表者ビューが innerHTML を別途読む) */
473
+ .notes {
474
+ display: none;
475
+ }
476
+
477
+ #presenter {
478
+ position: fixed;
479
+ inset: 0;
480
+ z-index: 30;
481
+ display: none;
482
+ box-sizing: border-box;
483
+ padding: 24px;
484
+ gap: 24px;
485
+ background: #1c1c1f;
486
+ color: #fff;
487
+ font-family: system-ui, -apple-system, 'Hiragino Sans', sans-serif;
488
+ }
489
+ body.presenter #presenter {
490
+ display: grid;
491
+ grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
492
+ }
493
+ body.presenter #deck,
494
+ body.presenter #progress,
495
+ body.presenter #counter,
496
+ body.presenter #timer,
497
+ body.presenter #hint,
498
+ body.presenter #overview {
499
+ display: none;
500
+ }
501
+
502
+ .pv-left {
503
+ display: flex;
504
+ flex-direction: column;
505
+ gap: 18px;
506
+ min-width: 0;
507
+ }
508
+ .pv-slot:last-child {
509
+ opacity: 0.85;
510
+ }
511
+ .pv-slot:last-child .pv-frame {
512
+ width: 62%;
513
+ }
514
+ .pv-label {
515
+ font-size: 0.85rem;
516
+ color: #9a9aa0;
517
+ margin-bottom: 6px;
518
+ font-variant-numeric: tabular-nums;
519
+ }
520
+ .pv-frame {
521
+ position: relative;
522
+ overflow: hidden;
523
+ border-radius: 10px;
524
+ border: 1px solid rgba(255, 255, 255, 0.14);
525
+ background: var(--slide-bg);
526
+ box-shadow: 0 2px 14px rgba(0, 0, 0, 0.4);
527
+ }
528
+ .pv-scale {
529
+ position: relative;
530
+ transform-origin: 0 0;
531
+ }
532
+ #presenter .slide {
533
+ display: flex;
534
+ animation: none;
535
+ overflow: hidden;
536
+ }
537
+
538
+ .pv-right {
539
+ display: flex;
540
+ flex-direction: column;
541
+ gap: 18px;
542
+ min-width: 0;
543
+ }
544
+ .pv-clock {
545
+ display: flex;
546
+ align-items: baseline;
547
+ gap: 16px;
548
+ }
549
+ .pv-elapsed {
550
+ font-size: 2.8rem;
551
+ font-weight: 700;
552
+ font-variant-numeric: tabular-nums;
553
+ line-height: 1;
554
+ }
555
+ .pv-wall {
556
+ font-size: 1rem;
557
+ color: #9a9aa0;
558
+ font-variant-numeric: tabular-nums;
559
+ }
560
+ .pv-controls {
561
+ display: flex;
562
+ gap: 10px;
563
+ }
564
+ .pv-controls button {
565
+ flex: 1;
566
+ padding: 10px 0;
567
+ font-size: 1.1rem;
568
+ color: #fff;
569
+ background: #2e2e33;
570
+ border: 1px solid rgba(255, 255, 255, 0.16);
571
+ border-radius: 8px;
572
+ cursor: pointer;
573
+ }
574
+ .pv-controls button:hover {
575
+ background: var(--slide-accent);
576
+ border-color: var(--slide-accent);
577
+ }
578
+ .pv-notes {
579
+ flex: 1;
580
+ overflow-y: auto;
581
+ padding: 16px 18px;
582
+ background: #242428;
583
+ border-radius: 10px;
584
+ border: 1px solid rgba(255, 255, 255, 0.1);
585
+ font-size: 1.05rem;
586
+ line-height: 1.7;
587
+ }
588
+ .pv-notes .pv-empty {
589
+ color: #76767c;
590
+ }
591
+
469
592
  /* ---------- PDF(ブラウザの印刷 → PDF に保存) ---------- */
470
593
 
471
594
  @media print {
@@ -511,7 +634,8 @@ body.overview #hint {
511
634
  #counter,
512
635
  #timer,
513
636
  #hint,
514
- #overview {
637
+ #overview,
638
+ #presenter {
515
639
  display: none !important;
516
640
  }
517
641
  }
package/bin/ippon.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { main } from '../src/cli.js'
2
+ import { main } from '../dist/cli.js'
3
3
 
4
4
  main(process.argv.slice(2)).catch((e) => {
5
5
  console.error(`✗ ${e.message}`)
package/dist/cli.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ export interface Options {
2
+ /** 位置引数 */
3
+ _: string[];
4
+ html?: boolean;
5
+ pdf?: boolean;
6
+ watch?: boolean;
7
+ help?: boolean;
8
+ version?: boolean;
9
+ output?: string;
10
+ css?: string;
11
+ browserPath?: string;
12
+ }
13
+ export declare function parseArgs(argv: string[]): Options;
14
+ /** 出力先を決める */
15
+ export type Format = 'html' | 'pdf';
16
+ export declare function outputs(input: string, { html, pdf, output }: Pick<Options, 'html' | 'pdf' | 'output'>): {
17
+ format: Format;
18
+ path: string;
19
+ }[];
20
+ export declare function main(argv: string[]): Promise<void>;
package/dist/cli.js ADDED
@@ -0,0 +1,241 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync, watch, writeFileSync } from 'node:fs';
2
+ import { basename, dirname, extname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { toPdf } from './pdf.js';
5
+ import { render } from './render.js';
6
+ const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
7
+ const HELP = `ippon v${pkg.version} — 1 枚 1 メッセージのスライドを Markdown から作る
8
+
9
+ 使い方:
10
+ ippon build <input.md> [options] スライドをビルドする
11
+ ippon <input.md> [options] build の省略形
12
+ ippon init [file] サンプルの Markdown を作る(デフォルト: slide.md)
13
+
14
+ build のオプション:
15
+ --html HTML を出力する(--pdf がなければデフォルト)
16
+ --pdf PDF を出力する(Chrome / Chromium / Edge が必要)
17
+ -o, --output <path> 出力先。--html と --pdf を両方指定したときは拡張子だけ差し替える
18
+ ディレクトリを指定するとその中に <input>.html / .pdf を作る
19
+ -w, --watch Markdown と --css の変更を監視して再ビルドする
20
+ --css <file> 追加の CSS(色やフォントの上書きなど)
21
+ --browser-path <path> PDF に使うブラウザ(環境変数 CHROME_PATH でも可)
22
+
23
+ -h, --help ヘルプ
24
+ -v, --version バージョン
25
+
26
+ 例:
27
+ ippon build slide.md --html
28
+ ippon build slide.md --pdf -o dist/
29
+ ippon build slide.md --html --pdf -o dist/talk
30
+ ippon build slide.md --html -w
31
+ `;
32
+ const FLAGS = { '--html': 'html', '--pdf': 'pdf', '-w': 'watch', '--watch': 'watch', '-h': 'help', '--help': 'help', '-v': 'version', '--version': 'version' };
33
+ const VALUES = { '-o': 'output', '--output': 'output', '--css': 'css', '--browser-path': 'browserPath' };
34
+ export function parseArgs(argv) {
35
+ const opts = { _: [] };
36
+ for (let i = 0; i < argv.length; i++) {
37
+ let a = argv[i];
38
+ let v;
39
+ const eq = a.match(/^(--[^=]+)=(.*)$/);
40
+ if (eq)
41
+ [, a, v] = eq;
42
+ const flag = FLAGS[a];
43
+ const value = VALUES[a];
44
+ if (flag)
45
+ opts[flag] = true;
46
+ else if (value) {
47
+ if (v === undefined)
48
+ v = argv[++i];
49
+ if (v === undefined)
50
+ throw new Error(`${a} には値が必要です`);
51
+ opts[value] = v;
52
+ }
53
+ else if (a.startsWith('-') && a !== '-')
54
+ throw new Error(`不明なオプション: ${a}`);
55
+ else
56
+ opts._.push(a);
57
+ }
58
+ return opts;
59
+ }
60
+ export function outputs(input, { html, pdf, output }) {
61
+ const formats = [];
62
+ if (html || !pdf)
63
+ formats.push('html');
64
+ if (pdf)
65
+ formats.push('pdf');
66
+ const name = basename(input, extname(input));
67
+ let base = ''; // 拡張子なしのパス
68
+ let exact = null; // 1 形式だけのときに -o をそのまま使う
69
+ if (!output)
70
+ base = join(dirname(input), name);
71
+ else if (output.endsWith('/') || output.endsWith('\\') || (existsSync(output) && statSync(output).isDirectory()))
72
+ base = join(output, name);
73
+ else if (formats.length === 1 && extname(output))
74
+ exact = output;
75
+ else
76
+ base = extname(output) ? output.slice(0, -extname(output).length) : output;
77
+ return formats.map((f) => ({ format: f, path: resolve(exact || `${base}.${f}`) }));
78
+ }
79
+ // ---------------------------------------------------------------
80
+ // build
81
+ // ---------------------------------------------------------------
82
+ const time = () => new Date().toLocaleTimeString();
83
+ async function build(input, opts) {
84
+ const md = readFileSync(input, 'utf8');
85
+ const css = opts.css ? readFileSync(opts.css, 'utf8') : '';
86
+ const baseDir = dirname(resolve(input));
87
+ for (const { format, path } of outputs(input, opts)) {
88
+ mkdirSync(dirname(path), { recursive: true });
89
+ if (format === 'html') {
90
+ const { html, count } = render(md, { baseDir, outDir: dirname(path), css });
91
+ writeFileSync(path, html);
92
+ console.log(`${time()} ✓ ${count} slides → ${path}`);
93
+ }
94
+ else {
95
+ const { html, count } = render(md, { baseDir, css });
96
+ await toPdf(html, path, { browserPath: opts.browserPath });
97
+ console.log(`${time()} ✓ ${count} slides → ${path}`);
98
+ }
99
+ }
100
+ }
101
+ function watchBuild(input, opts) {
102
+ let timer;
103
+ let running = Promise.resolve();
104
+ const rebuild = () => {
105
+ clearTimeout(timer);
106
+ timer = setTimeout(() => {
107
+ running = running.then(() => build(input, opts)).catch((e) => console.error(`${time()} ✗ ${e.message}`));
108
+ }, 80);
109
+ };
110
+ // エディタによってはファイルを置き換えて保存するので、ディレクトリごと監視する
111
+ const files = [input, opts.css].filter((f) => f !== undefined).map((f) => resolve(f));
112
+ for (const dir of new Set(files.map(dirname))) {
113
+ watch(dir, (_, file) => {
114
+ if (file && files.includes(join(dir, file.toString())))
115
+ rebuild();
116
+ });
117
+ }
118
+ console.log(`watching ${files.join(', ')} ...`);
119
+ }
120
+ // ---------------------------------------------------------------
121
+ // init
122
+ // ---------------------------------------------------------------
123
+ const SAMPLE = `---
124
+ title: はじめての ippon
125
+ author: あなたの名前
126
+ ---
127
+
128
+ ::title::
129
+
130
+ # はじめての ippon
131
+
132
+ サブタイトル
133
+
134
+ あなたの名前 ・ 2026-01-01
135
+
136
+ ---
137
+
138
+ ::center::
139
+
140
+ 1 枚に
141
+
142
+ 1 メッセージ
143
+
144
+ ---
145
+
146
+ ::center size=2::
147
+
148
+ **大きく**
149
+
150
+ ---
151
+
152
+ ::center bg=blue::
153
+
154
+ 背景色も変えられる
155
+
156
+ -# blue / orange / purple / red / green / black / white、または #rrggbb
157
+
158
+ ---
159
+
160
+ ::center size=1.5 bg=white::
161
+
162
+ セクション
163
+
164
+ ---
165
+
166
+ ## 箇条書き
167
+
168
+ - **太字** はアクセントカラーになる
169
+ - \`inline code\` も書ける
170
+ - ネストもできる
171
+ - 矢印 → は少し薄くなる
172
+
173
+ ---
174
+
175
+ ## 2 カラム
176
+
177
+ - 左に説明
178
+ - 右にコード
179
+
180
+ ::right::
181
+
182
+ \`\`\`ts
183
+ const app = new Hono()
184
+ !! app.get('/', (c) => c.text('Hello!'))
185
+ \`\`\`
186
+
187
+ -# 行頭に "!! " を付けるとその行が強調される
188
+
189
+ ::note::
190
+
191
+ p キーで発表者ビューが開きます。
192
+ この ::note:: 以降は本番に出ず、手元のメモとして見えます。
193
+
194
+ ---
195
+
196
+ \`\`\`console
197
+ $ npx ippon-slides build slide.md --html --pdf
198
+ ✓ 9 slides → slide.html
199
+ ✓ 9 slides → slide.pdf
200
+ \`\`\`
201
+
202
+ ---
203
+
204
+ ::title::
205
+
206
+ # ありがとうございました
207
+
208
+ あなたの名前
209
+ `;
210
+ function init(file = 'slide.md') {
211
+ if (existsSync(file))
212
+ throw new Error(`${file} はすでにあります`);
213
+ writeFileSync(file, SAMPLE);
214
+ console.log(`✓ ${file} を作りました → ippon build ${file} --html -w`);
215
+ }
216
+ // ---------------------------------------------------------------
217
+ // main
218
+ // ---------------------------------------------------------------
219
+ export async function main(argv) {
220
+ const opts = parseArgs(argv);
221
+ if (opts.version)
222
+ return console.log(pkg.version);
223
+ const [cmd, ...args] = opts._;
224
+ if (opts.help || cmd === undefined)
225
+ return console.log(HELP);
226
+ let rest = args;
227
+ if (cmd === 'init')
228
+ return init(rest[0]);
229
+ if (cmd !== 'build')
230
+ rest = [cmd, ...rest]; // `ippon slide.md` は build の省略形
231
+ const input = rest[0];
232
+ if (!input)
233
+ throw new Error('入力の Markdown を指定してください(例: ippon build slide.md --html)');
234
+ if (rest.length > 1)
235
+ throw new Error(`入力は 1 つだけ指定してください: ${rest.join(' ')}`);
236
+ if (!existsSync(input))
237
+ throw new Error(`${input} が見つかりません`);
238
+ await build(input, opts);
239
+ if (opts.watch)
240
+ watchBuild(input, opts);
241
+ }
@@ -0,0 +1,2 @@
1
+ export declare const esc: (s: string) => string;
2
+ export declare function highlight(code: string, lang?: string): string;
@@ -0,0 +1,105 @@
1
+ // コードのハイライト。1 行ずつ処理する(複数行にまたがるトークンは扱わない)。
2
+ // 行頭の "!! " はその行の強調マーカー。
3
+ export const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
4
+ function tokenize(line, rules) {
5
+ let out = '';
6
+ let rest = line;
7
+ outer: while (rest.length) {
8
+ for (const [re, cls] of rules) {
9
+ const m = rest.match(re);
10
+ if (m && m[0].length) {
11
+ out += cls ? `<span class="${cls}">${esc(m[0])}</span>` : esc(m[0]);
12
+ rest = rest.slice(m[0].length);
13
+ continue outer;
14
+ }
15
+ }
16
+ out += esc(rest[0]);
17
+ rest = rest.slice(1);
18
+ }
19
+ return out;
20
+ }
21
+ const HCL_KEYWORDS = 'resource|data|variable|module|output|locals|terraform|provisioner|lifecycle|precondition|postcondition|backend|for|in|if|for_each|count|depends_on|when';
22
+ const HCL = [
23
+ [/^(#|\/\/).*/, 'hl-c'],
24
+ [/^"(?:[^"\\]|\\.)*"/, 'hl-s'],
25
+ [/^<<-?EOT/, 'hl-s'],
26
+ [/^(?:var|local|module|each|self|path|data)(?=\.)/, 'hl-i'],
27
+ [new RegExp(`^(?:${HCL_KEYWORDS})\\b`), 'hl-k'],
28
+ [/^(?:true|false|null)\b/, 'hl-n'],
29
+ [/^\d+(\.\d+)?\b/, 'hl-n'],
30
+ [/^[a-z_][a-z0-9_]*(?=\()/, 'hl-f'],
31
+ [/^[a-z_][a-z0-9_]*(?=\s*=[^=>])/, 'hl-t'],
32
+ [/^[A-Za-z_][A-Za-z0-9_]*/, null],
33
+ ];
34
+ const SH = [
35
+ [/^#.*/, 'hl-c'],
36
+ [/^"(?:[^"\\]|\\.)*"/, 'hl-s'],
37
+ [/^'[^']*'/, 'hl-s'],
38
+ [/^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/, 'hl-i'],
39
+ [/^(?:if|then|else|elif|fi|for|while|do|done|case|esac|set|echo|exit|export)\b/, 'hl-k'],
40
+ [/^--?[a-z][a-z-]*/, 'hl-t'],
41
+ [/^[A-Za-z_][A-Za-z0-9_]*/, null],
42
+ ];
43
+ const JSON_ = [
44
+ [/^"(?:[^"\\]|\\.)*"(?=\s*:)/, 'hl-t'],
45
+ [/^"(?:[^"\\]|\\.)*"/, 'hl-s'],
46
+ [/^(?:true|false|null)\b/, 'hl-n'],
47
+ [/^-?\d+(\.\d+)?/, 'hl-n'],
48
+ ];
49
+ const JS_KEYWORDS = 'const|let|var|function|return|if|else|for|while|of|in|new|class|extends|import|from|export|default|async|await|try|catch|throw|type|interface|as';
50
+ const JS = [
51
+ [/^\/\/.*/, 'hl-c'],
52
+ [/^"(?:[^"\\]|\\.)*"/, 'hl-s'],
53
+ [/^'(?:[^'\\]|\\.)*'/, 'hl-s'],
54
+ [/^`(?:[^`\\]|\\.)*`/, 'hl-s'],
55
+ [new RegExp(`^(?:${JS_KEYWORDS})\\b`), 'hl-k'],
56
+ [/^(?:true|false|null|undefined)\b/, 'hl-n'],
57
+ [/^\d+(\.\d+)?\b/, 'hl-n'],
58
+ [/^[A-Za-z_$][\w$]*(?=\()/, 'hl-f'],
59
+ [/^[A-Z][\w$]*/, 'hl-t'],
60
+ [/^[A-Za-z_$][\w$]*/, null],
61
+ ];
62
+ const LANGS = {
63
+ hcl: HCL,
64
+ tf: HCL,
65
+ terraform: HCL,
66
+ sh: SH,
67
+ bash: SH,
68
+ shell: SH,
69
+ zsh: SH,
70
+ json: JSON_,
71
+ js: JS,
72
+ javascript: JS,
73
+ ts: JS,
74
+ typescript: JS,
75
+ jsx: JS,
76
+ tsx: JS,
77
+ };
78
+ function consoleLine(line) {
79
+ const m = line.match(/^(\$ )(.*)$/);
80
+ if (m)
81
+ return `<span class="hl-prompt">$ </span><span class="hl-cmd">${esc(m[2])}</span>`;
82
+ if (/(^|\s)(Error|panic)\b|^make: \*\*\*/.test(line))
83
+ return `<span class="hl-err">${esc(line)}</span>`;
84
+ if (/^(✅|Apply complete|Destroy complete)/.test(line))
85
+ return `<span class="hl-ok">${esc(line)}</span>`;
86
+ return `<span class="hl-out">${esc(line)}</span>`;
87
+ }
88
+ export function highlight(code, lang = '') {
89
+ const rules = LANGS[lang.toLowerCase()];
90
+ return code
91
+ .split('\n')
92
+ .map((raw) => {
93
+ const marked = raw.startsWith('!! ');
94
+ const line = marked ? raw.slice(3) : raw;
95
+ let html;
96
+ if (lang === 'console')
97
+ html = consoleLine(line);
98
+ else if (rules)
99
+ html = tokenize(line, rules);
100
+ else
101
+ html = esc(line);
102
+ return marked ? `<span class="hl-mark">${html}</span>` : html;
103
+ })
104
+ .join('\n');
105
+ }
@@ -0,0 +1,3 @@
1
+ export { parse, type Meta, type Parsed } from './markdown.ts';
2
+ export { render, type RenderOptions, type RenderResult } from './render.ts';
3
+ export { toPdf, findBrowser, type PdfOptions } from './pdf.ts';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { parse } from './markdown.js';
2
+ export { render } from './render.js';
3
+ export { toPdf, findBrowser } from './pdf.js';
@@ -0,0 +1,15 @@
1
+ export declare const BG: Record<string, string>;
2
+ /** frontmatter(`key: value` だけの簡易 YAML) */
3
+ export type Meta = Record<string, string>;
4
+ export interface Parsed {
5
+ meta: Meta;
6
+ /** スライドごとの `<section>` */
7
+ sections: string[];
8
+ }
9
+ export declare function inline(text: string): string;
10
+ export declare function blocks(src: string): string;
11
+ export declare function splitSlides(md: string): string[];
12
+ export declare function slide(src: string): string;
13
+ export declare function frontmatter(md: string): [Meta, string];
14
+ /** Markdown 全体 → { meta, sections } */
15
+ export declare function parse(md: string): Parsed;