@@ -74,6 +74,23 @@ const MMDC_PATH = (() => {
7474let mermaidCount = 0 ;
7575const mermaidPngFiles = [ ] ; // relative hrefs: ['images/mermaid-01.png', …]
7676
77+ // Detect Puppeteer (bundled with mmdc) for syntax-highlighted code rendering.
78+ const PUPPETEER_DIR = ( ( ) => {
79+ if ( ! MMDC_PATH ) return null ;
80+ try {
81+ const real = fs . realpathSync ( MMDC_PATH ) ;
82+ const p = path . join ( path . dirname ( path . dirname ( real ) ) , 'node_modules' , 'puppeteer' ) ;
83+ return fs . existsSync ( path . join ( p , 'package.json' ) ) ? p : null ;
84+ } catch { return null ; }
85+ } ) ( ) ;
86+
87+ // Module-level state for deferred code block rendering.
88+ // Populated during mdToXhtml(); resolved by batchRenderCodeBlocks() in build().
89+ const pendingCodeBlocks = [ ] ; // { id, code, lang }
90+ let codeRenderResults = { } ; // id → { html } | { href }
91+ let codeImgCount = 0 ;
92+ const codePngFiles = [ ] ; // PNG hrefs (only when RENDER_CODE_AS_PNG = true)
93+
7794// Renders a Mermaid diagram to PNG and saves it into the EPUB images/ folder.
7895// Returns the image href (e.g. 'images/mermaid-01.png'), or null on failure.
7996//
@@ -145,6 +162,141 @@ function renderMermaidToPng(src) {
145162
146163// ─────────────────────────────────────────────────────────────────────────────
147164
165+ // ── Code block syntax highlighting helpers ──────────────────────────────────
166+
167+ // Build the one-time HTML page that hosts highlight.js for reuse across all
168+ // code blocks in a single Puppeteer session.
169+ function buildCodeRendererHtml ( ) {
170+ return [
171+ '<!DOCTYPE html><html><head><meta charset="UTF-8">' ,
172+ '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">' ,
173+ '<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>' ,
174+ '<style>' ,
175+ '* { margin:0; padding:0; box-sizing:border-box; }' ,
176+ `body { background:${ THEME . pageBg } ; padding:16px; font-family:monospace; }` ,
177+ '.wrap { border-radius:8px; overflow:hidden; box-shadow:0 2px 16px rgba(0,0,0,.18);' ,
178+ ' display:inline-block; min-width:400px; max-width:900px; }' ,
179+ '.lang-label { background:#282c34; color:#888; font-size:11px; padding:8px 16px 4px; }' ,
180+ 'pre { overflow:visible; }' ,
181+ "code { font-family:'SF Mono','Fira Code',Menlo,Consolas,monospace; font-size:13px; line-height:1.55; }" ,
182+ '.hljs { padding:12px 16px 16px; border-radius:0; }' ,
183+ '</style></head>' ,
184+ '<body><div class="wrap" id="wrap"></div></body></html>' ,
185+ ] . join ( '\n' ) ;
186+ }
187+
188+ // Build the Node.js batch-runner script (executed as a child process).
189+ // It launches ONE Puppeteer browser, renders all tasks, and writes results.json.
190+ function buildBatchRunnerScript ( tasksFile , resultsFile , initHtmlFile , pngDir ) {
191+ const renderAsPng = RENDER_CODE_AS_PNG ;
192+ return [
193+ `const puppeteer = require(${ JSON . stringify ( PUPPETEER_DIR ) } );` ,
194+ `const fs = require('fs'), path = require('path');` ,
195+ `const tasks = JSON.parse(fs.readFileSync(${ JSON . stringify ( tasksFile ) } , 'utf8'));` ,
196+ `const results = {};` ,
197+ `(async () => {` ,
198+ ` const br = await puppeteer.launch({` ,
199+ ` args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-web-security'],` ,
200+ ` });` ,
201+ ` const pg = await br.newPage();` ,
202+ ` await pg.setViewport({ width: 960, height: 600, deviceScaleFactor: 2 });` ,
203+ ` // Load via file:// so CDN resources resolve correctly` ,
204+ ` await pg.goto(${ JSON . stringify ( 'file://' + initHtmlFile ) } , { waitUntil: 'networkidle2', timeout: 20000 });` ,
205+ ` for (const task of tasks) {` ,
206+ ` const escaped = task.code` ,
207+ ` .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');` ,
208+ ` const lbl = task.lang` ,
209+ ` ? '<div class="lang-label">' + task.lang + '</div>' : '';` ,
210+ ` try {` ,
211+ ` if (${ renderAsPng } ) {` ,
212+ ` // PNG mode: screenshot the rendered code block` ,
213+ ` await pg.evaluate((esc, lang, lbl) => {` ,
214+ ` document.getElementById('wrap').innerHTML =` ,
215+ ` lbl + '<pre><code class="language-' + lang + '">' + esc + '</code></pre>';` ,
216+ ` hljs.highlightAll();` ,
217+ ` }, escaped, task.lang, lbl);` ,
218+ ` const pngFile = path.join(${ JSON . stringify ( pngDir ) } , task.id + '.png');` ,
219+ ` const el = await pg.$('#wrap');` ,
220+ ` await el.screenshot({ path: pngFile });` ,
221+ ` results[task.id] = { success: true, pngFile };` ,
222+ ` } else {` ,
223+ ` // Inline-HTML mode: extract highlighted HTML with inline styles` ,
224+ ` // (keeps text selectable + searchable in EPUB readers)` ,
225+ ` const html = await pg.evaluate((code, lang) => {` ,
226+ ` const el = document.createElement('code');` ,
227+ ` el.textContent = code;` ,
228+ ` el.className = 'language-' + (lang || 'plaintext');` ,
229+ ` document.body.appendChild(el);` ,
230+ ` if (typeof hljs !== 'undefined') hljs.highlightElement(el);` ,
231+ ` el.querySelectorAll('[class]').forEach(span => {` ,
232+ ` const cs = window.getComputedStyle(span);` ,
233+ ` let s = '';` ,
234+ ` if (cs.color) s += 'color:' + cs.color + ';';` ,
235+ ` if (cs.fontStyle && cs.fontStyle !== 'normal')` ,
236+ ` s += 'font-style:' + cs.fontStyle + ';';` ,
237+ ` if (cs.fontWeight && cs.fontWeight !== '400')` ,
238+ ` s += 'font-weight:' + cs.fontWeight + ';';` ,
239+ ` if (s) span.setAttribute('style', s);` ,
240+ ` span.removeAttribute('class');` ,
241+ ` });` ,
242+ ` const h = el.innerHTML;` ,
243+ ` el.remove();` ,
244+ ` return h;` ,
245+ ` }, task.code, task.lang);` ,
246+ ` results[task.id] = { success: true, html };` ,
247+ ` }` ,
248+ ` } catch (e) {` ,
249+ ` results[task.id] = { success: false, error: e.message };` ,
250+ ` }` ,
251+ ` }` ,
252+ ` await br.close();` ,
253+ ` fs.writeFileSync(${ JSON . stringify ( resultsFile ) } , JSON.stringify(results), 'utf8');` ,
254+ `})().catch(e => { process.stderr.write(e.message); process.exit(1); });` ,
255+ ] . join ( '\n' ) ;
256+ }
257+
258+ // Batch-render all pendingCodeBlocks using a single Puppeteer session.
259+ // Populates codeRenderResults with { html } (inline mode) or { href } (PNG mode).
260+ function batchRenderCodeBlocks ( ) {
261+ if ( ! PUPPETEER_DIR || pendingCodeBlocks . length === 0 ) return ;
262+ const tmpDir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'code-batch-' ) ) ;
263+ try {
264+ const tasksFile = path . join ( tmpDir , 'tasks.json' ) ;
265+ const resultsFile = path . join ( tmpDir , 'results.json' ) ;
266+ const initHtmlFile = path . join ( tmpDir , 'init.html' ) ;
267+ const jsFile = path . join ( tmpDir , 'runner.js' ) ;
268+ fs . writeFileSync ( tasksFile , JSON . stringify ( pendingCodeBlocks ) , 'utf8' ) ;
269+ fs . writeFileSync ( initHtmlFile , buildCodeRendererHtml ( ) , 'utf8' ) ;
270+ fs . writeFileSync ( jsFile , buildBatchRunnerScript ( tasksFile , resultsFile , initHtmlFile , tmpDir ) , 'utf8' ) ;
271+ execSync ( 'node ' + JSON . stringify ( jsFile ) , { stdio : 'pipe' , timeout : 120000 } ) ;
272+ if ( ! fs . existsSync ( resultsFile ) ) return ;
273+ const raw = JSON . parse ( fs . readFileSync ( resultsFile , 'utf8' ) ) ;
274+ for ( const [ id , r ] of Object . entries ( raw ) ) {
275+ if ( ! r . success ) {
276+ console . warn ( ` ⚠ code render failed (${ id } ): ${ r . error } ` ) ;
277+ continue ;
278+ }
279+ if ( RENDER_CODE_AS_PNG && r . pngFile && fs . existsSync ( r . pngFile ) ) {
280+ codeImgCount ++ ;
281+ const imgName = `code-${ String ( codeImgCount ) . padStart ( 2 , '0' ) } .png` ;
282+ const imgHref = `images/${ imgName } ` ;
283+ fs . copyFileSync ( r . pngFile , path . join ( IMAGES , imgName ) ) ;
284+ codePngFiles . push ( imgHref ) ;
285+ codeRenderResults [ id ] = { href : imgHref } ;
286+ } else if ( r . html ) {
287+ codeRenderResults [ id ] = { html : r . html } ;
288+ }
289+ }
290+ } catch ( e ) {
291+ console . warn ( ' ⚠ Batch code rendering failed:' , e . message ,
292+ '\n Falling back to plain <pre><code>' ) ;
293+ } finally {
294+ try { fs . rmSync ( tmpDir , { recursive : true } ) ; } catch { }
295+ }
296+ }
297+
298+ // ─────────────────────────────────────────────────────────────────────────────
299+
148300// ─────────────────────────────────────────────
149301// 1. BOOK INFO — fill in before running
150302// ─────────────────────────────────────────────
@@ -209,7 +361,18 @@ const THEME = {
209361// };
210362
211363// ─────────────────────────────────────────────
212- // 4. PATHS — usually no need to change
364+ // 4. CODE BLOCK RENDERING
365+ // Requires mmdc (which bundles Puppeteer + Chrome).
366+ //
367+ // false (default) — inline-styled HTML via highlight.js.
368+ // Text stays selectable & searchable. Recommended.
369+ // true — PNG image (carbon-style). Beautiful but code is
370+ // NOT copyable or searchable. Use sparingly.
371+ // ─────────────────────────────────────────────
372+ const RENDER_CODE_AS_PNG = false ;
373+
374+ // ─────────────────────────────────────────────
375+ // 5. PATHS — usually no need to change
213376// ─────────────────────────────────────────────
214377const ROOT = process . cwd ( ) ;
215378const CHAPTERS_DIR = path . join ( ROOT , 'output' , 'chapters' , 'final' ) ;
@@ -294,6 +457,11 @@ function mdToXhtml(md) {
294457 `</div>`
295458 ) ;
296459 }
460+ } else if ( PUPPETEER_DIR ) {
461+ // Defer rendering: Puppeteer batch-renders all blocks at once in build()
462+ const cbId = `cb-${ pendingCodeBlocks . length + 1 } ` ;
463+ pendingCodeBlocks . push ( { id : cbId , code : code . trimEnd ( ) , lang : lang || '' } ) ;
464+ result . push ( `\x00CBID:${ cbId } \x00` ) ;
297465 } else {
298466 const cls = lang ? ` class="language-${ lang } "` : '' ;
299467 result . push ( `<pre><code${ cls } >${ esc ( code . trimEnd ( ) ) } </code></pre>` ) ;
@@ -433,6 +601,21 @@ hr { border: none; border-top: 1px solid #D8D2C8; margin: 1.5em 0; }
433601.mermaid-source { border-left: 4px solid #D8D2C8; opacity: 0.7; }
434602.diagram { margin: 1.2em 0; text-align: center; }
435603.diagram svg { max-width: 100%; height: auto; }
604+ pre.highlighted {
605+ background: #282c34;
606+ padding: 1.2em 1.4em;
607+ border-radius: 6px;
608+ overflow-x: auto;
609+ }
610+ pre.highlighted code {
611+ font-family: "Source Code Pro","SF Mono","Fira Code","Courier New",monospace;
612+ font-size: 0.87em;
613+ line-height: 1.55;
614+ color: #abb2bf;
615+ background: none;
616+ }
617+ .code-img { margin: 1.2em 0; text-align: left; }
618+ .code-img img { max-width: 100%; border-radius: 6px; }
436619` . trim ( ) ;
437620}
438621
@@ -577,6 +760,10 @@ function buildContentOpf(chapters) {
577760 const id = `mermaid-img-${ String ( i + 1 ) . padStart ( 2 , '0' ) } ` ;
578761 return ` <item id="${ id } " href="${ href } " media-type="image/png"/>` ;
579762 } ) . join ( '\n' ) ;
763+ const codeItems = codePngFiles . map ( ( href , i ) => {
764+ const id = `code-img-${ String ( i + 1 ) . padStart ( 2 , '0' ) } ` ;
765+ return ` <item id="${ id } " href="${ href } " media-type="image/png"/>` ;
766+ } ) . join ( '\n' ) ;
580767 const spineItems = chapters . map ( ch =>
581768 ` <itemref idref="${ ch . id } "/>`
582769 ) . join ( '\n' ) ;
@@ -598,7 +785,7 @@ function buildContentOpf(chapters) {
598785 <item id="cover-image" href="images/cover.svg" media-type="image/svg+xml" properties="cover-image"/>
599786 <item id="stylesheet" href="../style.css" media-type="text/css"/>
600787${ manifestItems }
601- ${ mermaidItems ? mermaidItems + '\n' : '' } </manifest>
788+ ${ mermaidItems ? mermaidItems + '\n' : '' } ${ codeItems ? codeItems + '\n' : '' } </manifest>
602789 <spine toc="ncx">
603790 <itemref idref="cover-page" linear="no"/>
604791${ spineItems }
@@ -630,9 +817,13 @@ function build() {
630817 console . log ( ` Author : ${ BOOK_AUTHOR } ` ) ;
631818 console . log ( ` Output : ${ EPUB_OUT } \n` ) ;
632819
633- // Reset Mermaid PNG state for this build run
820+ // Reset all per- build state
634821 mermaidCount = 0 ;
635822 mermaidPngFiles . length = 0 ;
823+ pendingCodeBlocks . length = 0 ;
824+ codeRenderResults = { } ;
825+ codeImgCount = 0 ;
826+ codePngFiles . length = 0 ;
636827
637828 // Clean temp dir; ensure output dir exists
638829 if ( fs . existsSync ( BUILD_DIR ) ) fs . rmSync ( BUILD_DIR , { recursive : true } ) ;
@@ -643,7 +834,12 @@ function build() {
643834 console . log ( ' ℹ mmdc not found — Mermaid blocks will be preserved as <pre>' ) ;
644835 console . log ( ' Install: npm install -g @mermaid-js/mermaid-cli\n' ) ;
645836 } else {
646- console . log ( ` ✓ mmdc found — Mermaid diagrams will be pre-rendered to PNG\n` ) ;
837+ console . log ( ` ✓ mmdc found — Mermaid diagrams → PNG` ) ;
838+ if ( PUPPETEER_DIR ) {
839+ const mode = RENDER_CODE_AS_PNG ? 'PNG (carbon-style)' : 'inline-styled HTML (copyable)' ;
840+ console . log ( ` ✓ Puppeteer found — code blocks → ${ mode } ` ) ;
841+ }
842+ console . log ( '' ) ;
647843 }
648844
649845 // Static files
@@ -653,8 +849,8 @@ function build() {
653849 fs . writeFileSync ( path . join ( IMAGES , 'cover.svg' ) , buildCoverSvg ( ) , 'utf8' ) ;
654850 fs . writeFileSync ( path . join ( OEBPS , 'cover.xhtml' ) , buildCoverXhtml ( ) , 'utf8' ) ;
655851
656- // Process chapters
657- const builtChapters = [ ] ;
852+ // Phase 1: parse all chapters to XHTML (code blocks become placeholders)
853+ const pendingChapters = [ ] ;
658854 for ( let i = 0 ; i < CHAPTERS . length ; i ++ ) {
659855 const { file, title : configTitle } = CHAPTERS [ i ] ;
660856 const srcPath = path . join ( CHAPTERS_DIR , file ) ;
@@ -665,23 +861,42 @@ function build() {
665861 }
666862
667863 const md = fs . readFileSync ( srcPath , 'utf8' ) ;
668- // Title: prefer first # heading in file; fall back to config title
669864 const title = extractMdTitle ( md ) || configTitle ;
670865 const bodyXhtml = mdToXhtml ( md ) ;
671866 const num = String ( i + 1 ) . padStart ( 2 , '0' ) ;
672- const xhtmlFile = `ch${ num } .xhtml` ;
673- const id = `ch${ num } ` ;
674-
675- fs . writeFileSync ( path . join ( OEBPS , xhtmlFile ) , buildChapterXhtml ( title , bodyXhtml ) , 'utf8' ) ;
676- builtChapters . push ( { id, title, xhtmlFile } ) ;
677- console . log ( ` ✓ ${ xhtmlFile } ${ title } ` ) ;
867+ pendingChapters . push ( { id : `ch${ num } ` , title, xhtmlFile : `ch${ num } .xhtml` , bodyXhtml } ) ;
678868 }
679869
680- if ( builtChapters . length === 0 ) {
870+ if ( pendingChapters . length === 0 ) {
681871 console . error ( '\n✖ No chapters found. Check CHAPTERS_DIR:' , CHAPTERS_DIR ) ;
682872 process . exit ( 1 ) ;
683873 }
684874
875+ // Phase 2: batch-render all code blocks in a single Puppeteer session
876+ if ( PUPPETEER_DIR && pendingCodeBlocks . length > 0 ) {
877+ const mode = RENDER_CODE_AS_PNG ? 'PNG' : 'inline HTML' ;
878+ console . log ( ` 🎨 Rendering ${ pendingCodeBlocks . length } code block(s) → ${ mode } ...` ) ;
879+ batchRenderCodeBlocks ( ) ;
880+ }
881+
882+ // Phase 3: write chapter files (resolve code block placeholders)
883+ const builtChapters = [ ] ;
884+ for ( const { id, title, xhtmlFile, bodyXhtml } of pendingChapters ) {
885+ const resolved = bodyXhtml . replace ( / \x00 C B I D : ( [ ^ \x00 ] + ) \x00 / g, ( _ , cbId ) => {
886+ const r = codeRenderResults [ cbId ] ;
887+ if ( r && r . href ) {
888+ return `<div class="code-img"><img src="${ r . href } " alt="code" /></div>` ;
889+ }
890+ if ( r && r . html ) {
891+ return `<pre class="highlighted"><code>${ r . html } </code></pre>` ;
892+ }
893+ return `<pre><code>[code block render failed]</code></pre>` ;
894+ } ) ;
895+ fs . writeFileSync ( path . join ( OEBPS , xhtmlFile ) , buildChapterXhtml ( title , resolved ) , 'utf8' ) ;
896+ builtChapters . push ( { id, title, xhtmlFile } ) ;
897+ console . log ( ` ✓ ${ xhtmlFile } ${ title } ` ) ;
898+ }
899+
685900 // Navigation files
686901 fs . writeFileSync ( path . join ( OEBPS , 'nav.xhtml' ) , buildNavXhtml ( builtChapters ) , 'utf8' ) ;
687902 fs . writeFileSync ( path . join ( OEBPS , 'toc.ncx' ) , buildTocNcx ( builtChapters ) , 'utf8' ) ;
0 commit comments