@ywal123456/jskim 0.5.0 → 0.5.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.
@@ -0,0 +1,825 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const fsp = require('node:fs/promises');
5
+ const path = require('node:path');
6
+ const os = require('node:os');
7
+ const { spawnSync } = require('node:child_process');
8
+ const MarkdownIt = require('markdown-it');
9
+
10
+ const CHAPTER_SPECS = [
11
+ { id: '01', file: '01-introduction.md', title: 'JSKimとは' },
12
+ { id: '02', file: '02-getting-started.md', title: 'はじめ方' },
13
+ { id: '03', file: '03-project-structure.md', title: 'プロジェクト構成' },
14
+ { id: '04', file: '04-basic-workflow.md', title: '基本的な開発workflow' },
15
+ { id: '05', file: '05-cli-reference.md', title: 'CLIリファレンス' },
16
+ { id: '06', file: '06-configuration.md', title: '設定' },
17
+ { id: '07', file: '07-files-pipeline.md', title: 'files pipeline' },
18
+ { id: '08', file: '08-nunjucks.md', title: 'Nunjucksの使い方' },
19
+ { id: '09', file: '09-development-features.md', title: '開発機能' },
20
+ {
21
+ id: '10',
22
+ file: '10-errors-and-troubleshooting.md',
23
+ title: 'エラーとトラブルシュート',
24
+ },
25
+ { id: '11', file: '11-dashboard-example.md', title: 'Dashboard例' },
26
+ { id: '12', file: '12-crud-example.md', title: 'CRUD例' },
27
+ { id: '13', file: '13-wizard-example.md', title: 'Wizard例' },
28
+ { id: '14', file: '14-limitations.md', title: '制限事項' },
29
+ ];
30
+
31
+ const COVER_SECTIONS = [
32
+ { heading: 'このガイドについて', title: 'このガイドについて' },
33
+ { heading: '読み方', title: '読み方' },
34
+ ];
35
+
36
+ const WINDOWS_BROWSER_CANDIDATES = [
37
+ 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
38
+ 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
39
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
40
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
41
+ ];
42
+
43
+ const MAC_BROWSER_CANDIDATES = [
44
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
45
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
46
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
47
+ ];
48
+
49
+ const LINUX_BROWSER_NAMES = [
50
+ 'microsoft-edge',
51
+ 'microsoft-edge-stable',
52
+ 'google-chrome',
53
+ 'google-chrome-stable',
54
+ 'chromium',
55
+ 'chromium-browser',
56
+ ];
57
+
58
+ function createMarkdownIt() {
59
+ return new MarkdownIt({
60
+ html: false,
61
+ linkify: false,
62
+ typographer: false,
63
+ }).enable('table');
64
+ }
65
+
66
+ function getRepoRoot() {
67
+ return path.resolve(__dirname, '..', '..');
68
+ }
69
+
70
+ function getUserGuideDir(repoRoot = getRepoRoot()) {
71
+ return path.join(repoRoot, 'docs', 'user-guide');
72
+ }
73
+
74
+ function getPackageVersion(repoRoot = getRepoRoot()) {
75
+ const pkg = require(path.join(repoRoot, 'package.json'));
76
+ return String(pkg.version);
77
+ }
78
+
79
+ function extractTargetVersionFromReadme(readmeText) {
80
+ const match = String(readmeText).match(/対象 version は \*\*v(\d+\.\d+\.\d+)\*\*/);
81
+ if (!match) {
82
+ throw new Error(
83
+ '[JSKim] ユーザーガイド README から対象 version を読み取れません。\n' +
84
+ '「対象 version は **vX.Y.Z** です。」形式で記載してください。'
85
+ );
86
+ }
87
+ return match[1];
88
+ }
89
+
90
+ function assertGuideVersionMatchesPackage(readmeText, packageVersion) {
91
+ const guideVersion = extractTargetVersionFromReadme(readmeText);
92
+ if (guideVersion !== packageVersion) {
93
+ throw new Error(
94
+ '[JSKim] ユーザーガイドの対象 version と package.json の version が一致しません。\n' +
95
+ `README: v${guideVersion}\n` +
96
+ `package.json: ${packageVersion}`
97
+ );
98
+ }
99
+ }
100
+
101
+ function extractMarkdownSection(markdown, heading) {
102
+ const lines = String(markdown).replace(/\r\n/g, '\n').split('\n');
103
+ const target = `## ${heading}`;
104
+ let start = -1;
105
+ for (let i = 0; i < lines.length; i += 1) {
106
+ if (lines[i].trim() === target) {
107
+ start = i + 1;
108
+ break;
109
+ }
110
+ }
111
+ if (start < 0) {
112
+ throw new Error(
113
+ `[JSKim] README に H2「${heading}」が見つかりません。`
114
+ );
115
+ }
116
+
117
+ let end = lines.length;
118
+ for (let i = start; i < lines.length; i += 1) {
119
+ if (/^##\s+/.test(lines[i])) {
120
+ end = i;
121
+ break;
122
+ }
123
+ }
124
+
125
+ const body = lines.slice(start, end).join('\n').trim();
126
+ if (!body) {
127
+ throw new Error(
128
+ `[JSKim] README の H2「${heading}」が空です。`
129
+ );
130
+ }
131
+ return body;
132
+ }
133
+
134
+ function stripMarkdownLinksToText(markdown) {
135
+ return String(markdown).replace(
136
+ /\[([^\]]+)\]\(([^)\s]+)\)/g,
137
+ (_full, label) => label
138
+ );
139
+ }
140
+
141
+ function loadGuideSources(repoRoot = getRepoRoot()) {
142
+ const guideDir = getUserGuideDir(repoRoot);
143
+ const readmePath = path.join(guideDir, 'README.md');
144
+ if (!fs.existsSync(readmePath)) {
145
+ throw new Error(`[JSKim] README.md が見つかりません: ${readmePath}`);
146
+ }
147
+
148
+ const readmeText = fs.readFileSync(readmePath, 'utf8');
149
+ const packageVersion = getPackageVersion(repoRoot);
150
+ assertGuideVersionMatchesPackage(readmeText, packageVersion);
151
+
152
+ const chapters = CHAPTER_SPECS.map((spec) => {
153
+ const filePath = path.join(guideDir, spec.file);
154
+ if (!fs.existsSync(filePath)) {
155
+ throw new Error(`[JSKim] chapter が見つかりません: ${spec.file}`);
156
+ }
157
+ const text = fs.readFileSync(filePath, 'utf8');
158
+ const h1Match = text.match(/^#\s+(.+)$/m);
159
+ if (!h1Match) {
160
+ throw new Error(`[JSKim] ${spec.file} に H1 がありません。`);
161
+ }
162
+ const h1 = h1Match[1].trim();
163
+ if (h1 !== spec.title) {
164
+ throw new Error(
165
+ `[JSKim] ${spec.file} の H1 が期待と違います。\n` +
166
+ `期待: ${spec.title}\n` +
167
+ `実際: ${h1}`
168
+ );
169
+ }
170
+ return {
171
+ ...spec,
172
+ absolutePath: filePath,
173
+ text,
174
+ h1,
175
+ };
176
+ });
177
+
178
+ return {
179
+ repoRoot,
180
+ guideDir,
181
+ packageVersion,
182
+ readmeText,
183
+ chapters,
184
+ };
185
+ }
186
+
187
+ function parseGitHubOrigin(remoteUrl) {
188
+ if (remoteUrl == null) {
189
+ return null;
190
+ }
191
+ let url = String(remoteUrl).trim();
192
+ if (!url) {
193
+ return null;
194
+ }
195
+ if (url.startsWith('git+')) {
196
+ url = url.slice(4);
197
+ }
198
+ if (url.endsWith('.git')) {
199
+ url = url.slice(0, -4);
200
+ }
201
+
202
+ let match = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\/)?$/i);
203
+ if (match) {
204
+ return { owner: match[1], repo: match[2] };
205
+ }
206
+
207
+ match = url.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\/)?$/i);
208
+ if (match) {
209
+ return { owner: match[1], repo: match[2] };
210
+ }
211
+
212
+ match = url.match(/^ssh:\/\/git@github\.com\/([^/]+)\/([^/]+?)(?:\/)?$/i);
213
+ if (match) {
214
+ return { owner: match[1], repo: match[2] };
215
+ }
216
+
217
+ return null;
218
+ }
219
+
220
+ function runGit(repoRoot, args) {
221
+ const result = spawnSync('git', args, {
222
+ cwd: repoRoot,
223
+ encoding: 'utf8',
224
+ windowsHide: true,
225
+ });
226
+ if (result.error || result.status !== 0) {
227
+ return null;
228
+ }
229
+ return String(result.stdout || '').trim();
230
+ }
231
+
232
+ function resolveGitHubContext(repoRoot = getRepoRoot()) {
233
+ const remote =
234
+ runGit(repoRoot, ['remote', 'get-url', 'origin']) ||
235
+ (() => {
236
+ try {
237
+ const pkg = require(path.join(repoRoot, 'package.json'));
238
+ return pkg.repository && pkg.repository.url
239
+ ? String(pkg.repository.url)
240
+ : null;
241
+ } catch {
242
+ return null;
243
+ }
244
+ })();
245
+ const headSha = runGit(repoRoot, ['rev-parse', 'HEAD']);
246
+ const github = parseGitHubOrigin(remote);
247
+ if (!github || !headSha) {
248
+ return { github: null, headSha: headSha || null, remote };
249
+ }
250
+ return {
251
+ github,
252
+ headSha,
253
+ remote,
254
+ baseUrl: `https://github.com/${github.owner}/${github.repo}`,
255
+ };
256
+ }
257
+
258
+ function toPosix(relPath) {
259
+ return String(relPath).split(path.sep).join('/');
260
+ }
261
+
262
+ function rewriteHref(rawHref, context) {
263
+ const href = String(rawHref || '').trim();
264
+ if (!href) {
265
+ return { kind: 'plain', text: '' };
266
+ }
267
+ if (/^(https?:|mailto:)/i.test(href)) {
268
+ return { kind: 'external', href };
269
+ }
270
+ if (/^file:/i.test(href) || path.isAbsolute(href)) {
271
+ return { kind: 'plain', text: href };
272
+ }
273
+ if (href.startsWith('#')) {
274
+ return { kind: 'external', href };
275
+ }
276
+
277
+ const [pathPart, fragment] = href.split('#');
278
+ const fragmentSuffix = fragment ? `#${fragment}` : '';
279
+ const normalized = (pathPart || '').replace(/\\/g, '/');
280
+ const basename = path.posix.basename(normalized);
281
+ const chapterFile = CHAPTER_SPECS.find((c) => c.file === basename);
282
+ if (chapterFile) {
283
+ return { kind: 'chapter', href: `#chapter-${chapterFile.id}` };
284
+ }
285
+
286
+ const fromDir = path.dirname(context.fromFile);
287
+ const resolvedAbs = path.resolve(fromDir, pathPart || '.');
288
+ const relToRepo = path.relative(context.repoRoot, resolvedAbs);
289
+ if (
290
+ !relToRepo ||
291
+ relToRepo.startsWith('..') ||
292
+ path.isAbsolute(relToRepo)
293
+ ) {
294
+ return { kind: 'plain', text: href };
295
+ }
296
+ const repoPath = toPosix(relToRepo);
297
+ let isDir = false;
298
+ try {
299
+ isDir = fs.existsSync(resolvedAbs) && fs.statSync(resolvedAbs).isDirectory();
300
+ } catch {
301
+ isDir = false;
302
+ }
303
+
304
+ if (context.github && context.headSha) {
305
+ const kind = isDir ? 'tree' : 'blob';
306
+ return {
307
+ kind: 'github',
308
+ href: `${context.baseUrl}/${kind}/${context.headSha}/${repoPath}${fragmentSuffix}`,
309
+ repoPath,
310
+ };
311
+ }
312
+
313
+ return { kind: 'plain', text: repoPath + fragmentSuffix };
314
+ }
315
+
316
+ function rewriteMarkdownLinks(markdown, context) {
317
+ return String(markdown).replace(
318
+ /\[([^\]]+)\]\(([^)\s]+)\)/g,
319
+ (_full, label, href) => {
320
+ const rewritten = rewriteHref(href, context);
321
+ if (rewritten.kind === 'plain') {
322
+ return `\`${rewritten.text || label}\``;
323
+ }
324
+ return `[${label}](${rewritten.href})`;
325
+ }
326
+ );
327
+ }
328
+
329
+ function renderMarkdown(markdown, md = createMarkdownIt()) {
330
+ return md.render(String(markdown));
331
+ }
332
+
333
+ /**
334
+ * Cover 用に長すぎる section を短縮する。
335
+ * 「読み方」は最初の H3(初めて使う場合)までだけを使う。
336
+ */
337
+ function trimCoverSectionBody(heading, body) {
338
+ if (heading !== '読み方') {
339
+ return body;
340
+ }
341
+ const lines = String(body).replace(/\r\n/g, '\n').split('\n');
342
+ let firstH3 = -1;
343
+ let secondH3 = -1;
344
+ for (let i = 0; i < lines.length; i += 1) {
345
+ if (!/^###\s+/.test(lines[i])) {
346
+ continue;
347
+ }
348
+ if (firstH3 < 0) {
349
+ firstH3 = i;
350
+ continue;
351
+ }
352
+ secondH3 = i;
353
+ break;
354
+ }
355
+ if (firstH3 < 0) {
356
+ return body;
357
+ }
358
+ const end = secondH3 < 0 ? lines.length : secondH3;
359
+ return lines.slice(0, end).join('\n').trim();
360
+ }
361
+
362
+ function buildCoverHtml(readmeText, packageVersion, md) {
363
+ const sections = COVER_SECTIONS.map((spec) => {
364
+ const rawBody = extractMarkdownSection(readmeText, spec.heading);
365
+ const body = trimCoverSectionBody(spec.heading, rawBody);
366
+ const plainLinked = stripMarkdownLinksToText(body);
367
+ const html = renderMarkdown(plainLinked, md);
368
+ return `<section class="cover-section"><h2>${escapeHtml(
369
+ spec.title
370
+ )}</h2>${html}</section>`;
371
+ }).join('\n');
372
+
373
+ return `<section class="cover">
374
+ <h1>JSKim ユーザーガイド</h1>
375
+ <p class="version">v${escapeHtml(packageVersion)}</p>
376
+ <p class="doc-kind">一般公開ドキュメント</p>
377
+ ${sections}
378
+ </section>`;
379
+ }
380
+
381
+ function buildTocHtml(chapters) {
382
+ const items = chapters
383
+ .map(
384
+ (chapter) =>
385
+ `<li><a href="#chapter-${chapter.id}"><span class="toc-num">${escapeHtml(
386
+ chapter.id
387
+ )}.</span> ${escapeHtml(chapter.title)}</a></li>`
388
+ )
389
+ .join('\n');
390
+ return `<nav class="toc" aria-label="目次">
391
+ <h1>目次</h1>
392
+ <ol>
393
+ ${items}
394
+ </ol>
395
+ </nav>`;
396
+ }
397
+
398
+ function buildChapterHtml(chapter, context, md) {
399
+ const rewritten = rewriteMarkdownLinks(chapter.text, {
400
+ ...context,
401
+ fromFile: chapter.absolutePath,
402
+ });
403
+ const html = renderMarkdown(rewritten, md);
404
+ return `<section id="chapter-${chapter.id}" class="chapter">
405
+ ${html}
406
+ </section>`;
407
+ }
408
+
409
+ function escapeHtml(text) {
410
+ return String(text)
411
+ .replace(/&/g, '&amp;')
412
+ .replace(/</g, '&lt;')
413
+ .replace(/>/g, '&gt;')
414
+ .replace(/"/g, '&quot;');
415
+ }
416
+
417
+ function buildDocumentHtml(options) {
418
+ const repoRoot = options.repoRoot || getRepoRoot();
419
+ const sources = options.sources || loadGuideSources(repoRoot);
420
+ const githubContext = options.githubContext || resolveGitHubContext(repoRoot);
421
+ const md = options.md || createMarkdownIt();
422
+ const cssPath =
423
+ options.cssPath || path.join(__dirname, 'user-guide-print.css');
424
+ const css = fs.readFileSync(cssPath, 'utf8');
425
+
426
+ const linkContext = {
427
+ repoRoot,
428
+ github: githubContext.github,
429
+ headSha: githubContext.headSha,
430
+ baseUrl: githubContext.baseUrl,
431
+ };
432
+
433
+ const cover = buildCoverHtml(sources.readmeText, sources.packageVersion, md);
434
+ const toc = buildTocHtml(sources.chapters);
435
+ const chaptersHtml = sources.chapters
436
+ .map((chapter) => buildChapterHtml(chapter, linkContext, md))
437
+ .join('\n');
438
+
439
+ const title = `JSKim ユーザーガイド v${sources.packageVersion}`;
440
+ return `<!DOCTYPE html>
441
+ <html lang="ja">
442
+ <head>
443
+ <meta charset="UTF-8">
444
+ <meta name="viewport" content="width=device-width, initial-scale=1">
445
+ <title>${escapeHtml(title)}</title>
446
+ <style>
447
+ ${css}
448
+ </style>
449
+ </head>
450
+ <body>
451
+ ${cover}
452
+ ${toc}
453
+ ${chaptersHtml}
454
+ </body>
455
+ </html>
456
+ `;
457
+ }
458
+
459
+ function whichSync(command) {
460
+ const isWin = process.platform === 'win32';
461
+ const result = spawnSync(isWin ? 'where' : 'which', [command], {
462
+ encoding: 'utf8',
463
+ windowsHide: true,
464
+ });
465
+ if (result.status !== 0) {
466
+ return [];
467
+ }
468
+ return String(result.stdout || '')
469
+ .split(/\r?\n/)
470
+ .map((line) => line.trim())
471
+ .filter(Boolean);
472
+ }
473
+
474
+ function findBrowserExecutable(options = {}) {
475
+ const exists =
476
+ options.existsSync || ((p) => fs.existsSync(p) && fs.statSync(p).isFile());
477
+ const platform = options.platform || process.platform;
478
+ const env = options.env || process.env;
479
+ const tried = [];
480
+
481
+ function consider(candidate, source) {
482
+ if (!candidate) {
483
+ return null;
484
+ }
485
+ const resolved = path.resolve(candidate);
486
+ tried.push(`${source}: ${resolved}`);
487
+ if (exists(resolved)) {
488
+ return { executablePath: resolved, source, tried };
489
+ }
490
+ return null;
491
+ }
492
+
493
+ if (options.browserPath) {
494
+ const hit = consider(options.browserPath, '--browser');
495
+ if (hit) {
496
+ return hit;
497
+ }
498
+ throw new Error(
499
+ `[JSKim] --browser で指定された実行ファイルが見つかりません。\n` +
500
+ `パス: ${options.browserPath}`
501
+ );
502
+ }
503
+
504
+ if (env.JSKIM_PDF_BROWSER) {
505
+ const hit = consider(env.JSKIM_PDF_BROWSER, 'JSKIM_PDF_BROWSER');
506
+ if (hit) {
507
+ return hit;
508
+ }
509
+ throw new Error(
510
+ `[JSKim] 環境変数 JSKIM_PDF_BROWSER の実行ファイルが見つかりません。\n` +
511
+ `パス: ${env.JSKIM_PDF_BROWSER}`
512
+ );
513
+ }
514
+
515
+ const candidates = [];
516
+ if (platform === 'win32') {
517
+ candidates.push(
518
+ ...WINDOWS_BROWSER_CANDIDATES.map((p) => ({ path: p, source: 'windows' }))
519
+ );
520
+ } else if (platform === 'darwin') {
521
+ candidates.push(
522
+ ...MAC_BROWSER_CANDIDATES.map((p) => ({ path: p, source: 'macos' }))
523
+ );
524
+ } else {
525
+ for (const name of LINUX_BROWSER_NAMES) {
526
+ for (const found of whichSync(name)) {
527
+ candidates.push({ path: found, source: `path:${name}` });
528
+ }
529
+ }
530
+ }
531
+
532
+ for (const item of candidates) {
533
+ const hit = consider(item.path, item.source);
534
+ if (hit) {
535
+ return hit;
536
+ }
537
+ }
538
+
539
+ throw new Error(
540
+ '[JSKim] PDF 生成用の Chromium 系 browser が見つかりません。\n' +
541
+ 'Microsoft Edge または Google Chrome をインストールするか、\n' +
542
+ '--browser <path> または環境変数 JSKIM_PDF_BROWSER を指定してください。\n' +
543
+ '探索した候補:\n' +
544
+ tried.map((line) => `- ${line}`).join('\n')
545
+ );
546
+ }
547
+
548
+ function parseBuildArgs(argv) {
549
+ const args = Array.isArray(argv) ? argv.slice() : [];
550
+ const options = {
551
+ htmlOnly: false,
552
+ keepHtml: false,
553
+ packageOutput: false,
554
+ output: undefined,
555
+ browser: undefined,
556
+ };
557
+ const seen = new Set();
558
+
559
+ for (let i = 0; i < args.length; i += 1) {
560
+ const token = args[i];
561
+ if (token === '--') {
562
+ throw new Error('[JSKim] サポートされていない引数です: --');
563
+ }
564
+ if (!token.startsWith('-')) {
565
+ throw new Error(`[JSKim] 不明な引数です: ${token}`);
566
+ }
567
+ if (token.includes('=')) {
568
+ throw new Error(
569
+ `[JSKim] この書き方のoptionはサポートしていません: ${token}\n` +
570
+ '例: --output path(= は使えません)'
571
+ );
572
+ }
573
+
574
+ const takeValue = () => {
575
+ const value = args[i + 1];
576
+ if (value == null || value.startsWith('-')) {
577
+ throw new Error(`[JSKim] option ${token} の値がありません。`);
578
+ }
579
+ i += 1;
580
+ return value;
581
+ };
582
+
583
+ if (seen.has(token)) {
584
+ throw new Error(`[JSKim] optionが重複しています: ${token}`);
585
+ }
586
+
587
+ if (token === '--html-only') {
588
+ seen.add(token);
589
+ options.htmlOnly = true;
590
+ continue;
591
+ }
592
+ if (token === '--keep-html') {
593
+ seen.add(token);
594
+ options.keepHtml = true;
595
+ continue;
596
+ }
597
+ if (token === '--package-output') {
598
+ seen.add(token);
599
+ options.packageOutput = true;
600
+ continue;
601
+ }
602
+ if (token === '--output') {
603
+ seen.add(token);
604
+ options.output = takeValue();
605
+ continue;
606
+ }
607
+ if (token === '--browser') {
608
+ seen.add(token);
609
+ options.browser = takeValue();
610
+ continue;
611
+ }
612
+
613
+ throw new Error(
614
+ `[JSKim] 不明なoptionです: ${token}\n` +
615
+ '使えるoption: --html-only, --keep-html, --package-output, --output <path>, --browser <path>'
616
+ );
617
+ }
618
+
619
+ if (options.htmlOnly && options.output) {
620
+ throw new Error(
621
+ '[JSKim] --html-only と --output は同時に指定できません。\n' +
622
+ '--output は PDF 出力先専用です。HTML は OS の一時ディレクトリに生成されます。'
623
+ );
624
+ }
625
+ if (options.htmlOnly && options.packageOutput) {
626
+ throw new Error(
627
+ '[JSKim] --html-only と --package-output は同時に指定できません。\n' +
628
+ '--package-output は release / npm package 用 PDF の出力先指定です。'
629
+ );
630
+ }
631
+ if (options.packageOutput && options.output) {
632
+ throw new Error(
633
+ '[JSKim] --package-output と --output は同時に指定できません。\n' +
634
+ '--package-output は docs/JSKim_User_Guide_v${version}.pdf を自動決定します。'
635
+ );
636
+ }
637
+
638
+ return options;
639
+ }
640
+
641
+ function defaultPdfOutputPath(repoRoot, packageVersion) {
642
+ return path.join(
643
+ repoRoot,
644
+ 'dist',
645
+ 'docs',
646
+ `JSKim_User_Guide_v${packageVersion}.pdf`
647
+ );
648
+ }
649
+
650
+ function packagePdfOutputPath(repoRoot, packageVersion) {
651
+ return path.join(
652
+ repoRoot,
653
+ 'docs',
654
+ `JSKim_User_Guide_v${packageVersion}.pdf`
655
+ );
656
+ }
657
+
658
+ function resolvePdfOutputPath(repoRoot, packageVersion, options = {}) {
659
+ if (options.packageOutput) {
660
+ return packagePdfOutputPath(repoRoot, packageVersion);
661
+ }
662
+ if (options.output) {
663
+ return path.resolve(options.output);
664
+ }
665
+ return defaultPdfOutputPath(repoRoot, packageVersion);
666
+ }
667
+
668
+ function createTempHtmlPath(packageVersion) {
669
+ const dir = path.join(os.tmpdir(), 'jskim-user-guide-pdf');
670
+ fs.mkdirSync(dir, { recursive: true });
671
+ return {
672
+ dir,
673
+ htmlPath: path.join(dir, `JSKim_User_Guide_v${packageVersion}.html`),
674
+ };
675
+ }
676
+
677
+ function assertNoLocalPathLeak(text, label) {
678
+ const patterns = [
679
+ { re: /file:\/\//i, name: 'file://' },
680
+ { re: /AppData\\Local\\Temp/i, name: 'AppData\\Local\\Temp' },
681
+ ];
682
+ // HTML は絶対パス混入を厳しく見る。PDF binary は誤検出を避ける。
683
+ if (!/\.pdf$/i.test(label) && !/生成 PDF/.test(label)) {
684
+ patterns.push({ re: /C:\\Users\\/i, name: 'C:\\Users\\' });
685
+ }
686
+ for (const pattern of patterns) {
687
+ if (pattern.re.test(text)) {
688
+ throw new Error(
689
+ `[JSKim] ${label} に local path が混入しています: ${pattern.name}`
690
+ );
691
+ }
692
+ }
693
+ }
694
+
695
+ async function writeGuideHtml(options = {}) {
696
+ const repoRoot = options.repoRoot || getRepoRoot();
697
+ const sources = options.sources || loadGuideSources(repoRoot);
698
+ const html = buildDocumentHtml({
699
+ repoRoot,
700
+ sources,
701
+ githubContext: options.githubContext,
702
+ });
703
+ assertNoLocalPathLeak(html, '生成 HTML');
704
+
705
+ const temp = createTempHtmlPath(sources.packageVersion);
706
+ await fsp.writeFile(temp.htmlPath, html, 'utf8');
707
+ return {
708
+ html,
709
+ htmlPath: temp.htmlPath,
710
+ tempDir: temp.dir,
711
+ packageVersion: sources.packageVersion,
712
+ chapterCount: sources.chapters.length,
713
+ sources,
714
+ };
715
+ }
716
+
717
+ async function writeGuidePdf(options = {}) {
718
+ const repoRoot = options.repoRoot || getRepoRoot();
719
+ const htmlResult = await writeGuideHtml(options);
720
+ const browserInfo = findBrowserExecutable({
721
+ browserPath: options.browserPath,
722
+ env: options.env,
723
+ platform: options.platform,
724
+ });
725
+
726
+ const outputPath =
727
+ options.outputPath ||
728
+ defaultPdfOutputPath(repoRoot, htmlResult.packageVersion);
729
+ await fsp.mkdir(path.dirname(outputPath), { recursive: true });
730
+
731
+ const { chromium } = require('playwright-core');
732
+ let browser;
733
+ try {
734
+ browser = await chromium.launch({
735
+ executablePath: browserInfo.executablePath,
736
+ headless: true,
737
+ });
738
+ const page = await browser.newPage();
739
+ await page.emulateMedia({ media: 'print' });
740
+ await page.setContent(htmlResult.html, { waitUntil: 'load' });
741
+ await page.evaluate(async () => {
742
+ if (document.fonts && document.fonts.ready) {
743
+ await document.fonts.ready;
744
+ }
745
+ });
746
+
747
+ const footerTemplate = `<div style="width:100%;font-size:8px;padding:0 12mm;color:#444;font-family:'Yu Gothic',YuGothic,Meiryo,sans-serif;box-sizing:border-box;">
748
+ <span>JSKim ユーザーガイド v${htmlResult.packageVersion}</span>
749
+ <span style="float:right;"><span class="pageNumber"></span> / <span class="totalPages"></span></span>
750
+ </div>`;
751
+
752
+ await page.pdf({
753
+ path: outputPath,
754
+ format: 'A4',
755
+ printBackground: true,
756
+ preferCSSPageSize: true,
757
+ displayHeaderFooter: true,
758
+ headerTemplate: '<div></div>',
759
+ footerTemplate,
760
+ margin: {
761
+ top: '16mm',
762
+ right: '15mm',
763
+ bottom: '19mm',
764
+ left: '15mm',
765
+ },
766
+ });
767
+ } finally {
768
+ if (browser) {
769
+ await browser.close();
770
+ }
771
+ }
772
+
773
+ const stat = await fsp.stat(outputPath);
774
+ if (stat.size <= 0) {
775
+ throw new Error(`[JSKim] PDF が空です: ${outputPath}`);
776
+ }
777
+ const pdfBytes = await fsp.readFile(outputPath);
778
+ if (!pdfBytes.toString('utf8', 0, 5).startsWith('%PDF-')) {
779
+ throw new Error(`[JSKim] PDF header が不正です: ${outputPath}`);
780
+ }
781
+ const pdfText = pdfBytes.toString('latin1');
782
+ assertNoLocalPathLeak(pdfText, '生成 PDF');
783
+
784
+ return {
785
+ ...htmlResult,
786
+ pdfPath: outputPath,
787
+ pdfSize: stat.size,
788
+ browserPath: browserInfo.executablePath,
789
+ browserSource: browserInfo.source,
790
+ };
791
+ }
792
+
793
+ module.exports = {
794
+ CHAPTER_SPECS,
795
+ COVER_SECTIONS,
796
+ WINDOWS_BROWSER_CANDIDATES,
797
+ createMarkdownIt,
798
+ getRepoRoot,
799
+ getUserGuideDir,
800
+ getPackageVersion,
801
+ extractTargetVersionFromReadme,
802
+ assertGuideVersionMatchesPackage,
803
+ extractMarkdownSection,
804
+ stripMarkdownLinksToText,
805
+ trimCoverSectionBody,
806
+ loadGuideSources,
807
+ parseGitHubOrigin,
808
+ resolveGitHubContext,
809
+ rewriteHref,
810
+ rewriteMarkdownLinks,
811
+ renderMarkdown,
812
+ buildCoverHtml,
813
+ buildTocHtml,
814
+ buildChapterHtml,
815
+ buildDocumentHtml,
816
+ findBrowserExecutable,
817
+ parseBuildArgs,
818
+ defaultPdfOutputPath,
819
+ packagePdfOutputPath,
820
+ resolvePdfOutputPath,
821
+ createTempHtmlPath,
822
+ assertNoLocalPathLeak,
823
+ writeGuideHtml,
824
+ writeGuidePdf,
825
+ };