Skip to content

Commit 10d85e0

Browse files
add missing dependencies
1 parent 8c15391 commit 10d85e0

3 files changed

Lines changed: 857 additions & 1 deletion

File tree

src/export/epub.ts

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
import * as helper from './helper'
2+
import * as COLOR from '../colorize'
3+
const path = require('path')
4+
const fs = require('fs-extra')
5+
import puppeteer from 'puppeteer'
6+
const Epub = require('epub-gen')
7+
8+
export function help() {
9+
console.log('')
10+
console.log(COLOR.heading('EPUB settings:'), '\n')
11+
12+
COLOR.info(
13+
'EPUB export generates eBook documents from your LiaScript course using Puppeteer. The content is extracted and packaged into a standard EPUB format compatible with most eBook readers.'
14+
)
15+
16+
console.log('\nLearn more: https://pptr.dev/ \n')
17+
18+
COLOR.command(
19+
null,
20+
'--epub-author',
21+
' Author name for the EPUB metadata'
22+
)
23+
COLOR.command(
24+
null,
25+
'--epub-publisher',
26+
' Publisher name for the EPUB metadata'
27+
)
28+
COLOR.command(null, '--epub-cover', ' Path to cover image file')
29+
COLOR.command(
30+
null,
31+
'--epub-stylesheet',
32+
' Inject a local CSS for changing the appearance'
33+
)
34+
COLOR.command(
35+
null,
36+
'--epub-theme',
37+
' LiaScript themes: default, turquoise, blue, red, yellow'
38+
)
39+
COLOR.command(
40+
null,
41+
'--epub-timeout',
42+
' Set an additional time horizon to wait until finished'
43+
)
44+
COLOR.command(
45+
null,
46+
'--epub-preview',
47+
' Open preview-browser (default false)'
48+
)
49+
}
50+
51+
export interface EpubExportArguments {
52+
input: string
53+
readme: string
54+
output: string
55+
format: string
56+
path: string
57+
key?: string
58+
'epub-author'?: string
59+
'epub-publisher'?: string
60+
'epub-cover'?: string
61+
'epub-stylesheet'?: string
62+
'epub-theme'?: string
63+
'epub-timeout'?: number
64+
'epub-preview'?: boolean
65+
}
66+
67+
export const format = 'epub'
68+
69+
export async function exporter(argument: EpubExportArguments, data?: any) {
70+
const dirname = helper.dirname()
71+
72+
let url = `file://${dirname}/assets/pdf/index.html?`
73+
74+
if (helper.isURL(argument.input)) {
75+
url += argument.input
76+
} else {
77+
url += 'file://' + path.resolve(argument.input)
78+
}
79+
80+
const browser = await puppeteer.launch({
81+
pipe: true,
82+
args: [
83+
'--no-sandbox',
84+
'--disable-web-security',
85+
'--disable-features=IsolateOrigins',
86+
'--disable-site-isolation-trials',
87+
'--unhandled-rejections=strict',
88+
'--disable-features=BlockInsecurePrivateNetworkRequests',
89+
'--allow-file-access-from-files',
90+
'--enable-local-file-accesses',
91+
'--enable-features=ExperimentalJavaScript',
92+
],
93+
headless: argument['epub-preview'] ? false : true,
94+
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
95+
channel: process.env.PUPPETEER_EXECUTABLE_PATH ? undefined : 'chrome',
96+
})
97+
const page = await browser.newPage()
98+
99+
console.warn(
100+
'depending on the size of the course, this can take a while, please be patient...'
101+
)
102+
103+
// Handle alert boxes so they don't block
104+
page.on('dialog', async (dialog) => {
105+
console.log(dialog.type())
106+
console.log(dialog.message())
107+
await dialog.accept()
108+
})
109+
110+
try {
111+
await page.goto(url, {
112+
waitUntil: 'networkidle2',
113+
timeout: 300000,
114+
})
115+
} catch (e) {
116+
console.warn('epub generation failed:', e)
117+
}
118+
119+
if (argument['epub-stylesheet']) {
120+
const href = path.resolve(dirname + '/../', argument['epub-stylesheet'])
121+
122+
await page.evaluate(async (href) => {
123+
const link = document.createElement('link')
124+
link.rel = 'stylesheet'
125+
link.href = href
126+
127+
const promise = new Promise((resolve, reject) => {
128+
link.onload = resolve
129+
link.onerror = reject
130+
})
131+
document.head.appendChild(link)
132+
await promise
133+
}, href)
134+
}
135+
136+
if (argument['epub-theme']) {
137+
await page.evaluate(async (theme) => {
138+
document.documentElement.classList.remove('lia-theme-default')
139+
document.documentElement.classList.add('lia-theme-' + theme)
140+
}, argument['epub-theme'])
141+
}
142+
143+
if (!argument['epub-preview']) {
144+
await helper.sleep(argument['epub-timeout'] || 10000)
145+
await toEPUB(argument, browser, page, data)
146+
}
147+
}
148+
149+
async function toEPUB(argument: any, browser: any, page: any, data: any) {
150+
try {
151+
// Extract content from the page
152+
const content = await page.evaluate(() => {
153+
const title =
154+
document.querySelector('h1')?.textContent ||
155+
document.title ||
156+
'LiaScript Course'
157+
158+
// Helper function to clone element with computed styles as inline styles
159+
const cloneWithComputedStyles = (element: Element): string => {
160+
const clone = element.cloneNode(true) as Element
161+
162+
// Simplify Ace editor elements for EPUB compatibility
163+
const aceEditors = clone.querySelectorAll('.ace_editor, .ace-editor')
164+
aceEditors.forEach((editor) => {
165+
const codeContent =
166+
editor.querySelector('.ace_text-layer')?.textContent ||
167+
editor.textContent ||
168+
''
169+
170+
// Replace complex Ace editor with simple pre/code block
171+
const pre = document.createElement('pre')
172+
const code = document.createElement('code')
173+
code.textContent = codeContent
174+
code.style.cssText =
175+
'display: block; font-family: monospace; white-space: pre-wrap; padding: 10px; background-color: #f5f5f5; border: 1px solid #ccc; overflow-x: auto;'
176+
pre.appendChild(code)
177+
pre.style.cssText = 'margin: 1em 0; padding: 0;'
178+
179+
editor.parentNode?.replaceChild(pre, editor)
180+
})
181+
182+
const allElements = [clone, ...Array.from(clone.querySelectorAll('*'))]
183+
184+
allElements.forEach((el, index) => {
185+
const original =
186+
index === 0 ? element : element.querySelectorAll('*')[index - 1]
187+
if (original) {
188+
const computedStyle = window.getComputedStyle(original as Element)
189+
const importantStyles = [
190+
'color',
191+
'background-color',
192+
'font-size',
193+
'font-family',
194+
'font-weight',
195+
'text-align',
196+
'margin',
197+
'padding',
198+
'border',
199+
'width',
200+
'height',
201+
'display',
202+
'position',
203+
'top',
204+
'left',
205+
'right',
206+
'bottom',
207+
'line-height',
208+
'white-space',
209+
]
210+
211+
let inlineStyle = (el as HTMLElement).getAttribute('style') || ''
212+
importantStyles.forEach((prop) => {
213+
const value = computedStyle.getPropertyValue(prop)
214+
if (value && value !== 'none' && value !== 'auto') {
215+
if (!inlineStyle.includes(prop)) {
216+
inlineStyle += `${prop}: ${value}; `
217+
}
218+
}
219+
})
220+
221+
if (inlineStyle) {
222+
;(el as HTMLElement).setAttribute('style', inlineStyle)
223+
}
224+
}
225+
})
226+
227+
return clone.innerHTML
228+
}
229+
230+
// Try to extract sections as chapters
231+
const sections = Array.from(document.querySelectorAll('section'))
232+
233+
if (sections.length > 0) {
234+
return {
235+
title,
236+
chapters: sections.map((section, index) => {
237+
const heading =
238+
section.querySelector('h1, h2, h3')?.textContent ||
239+
`Chapter ${index + 1}`
240+
return {
241+
title: heading,
242+
data: cloneWithComputedStyles(section),
243+
}
244+
}),
245+
}
246+
} else {
247+
// Fallback: use the entire body content
248+
return {
249+
title,
250+
chapters: [
251+
{
252+
title: title,
253+
data: cloneWithComputedStyles(document.body),
254+
},
255+
],
256+
}
257+
}
258+
})
259+
260+
// Extract styles from the page
261+
const styles = await page.evaluate(() => {
262+
const allStyles = []
263+
264+
// 1. Extract from <style> tags
265+
const styleTags = Array.from(document.querySelectorAll('style'))
266+
styleTags.forEach((tag) => {
267+
allStyles.push(tag.textContent)
268+
})
269+
270+
// 2. Extract from styleSheets (includes <link> stylesheets)
271+
for (const sheet of Array.from(document.styleSheets)) {
272+
try {
273+
if (sheet.cssRules) {
274+
const rules = Array.from(sheet.cssRules)
275+
allStyles.push(rules.map((rule) => rule.cssText).join('\n'))
276+
}
277+
} catch (e) {
278+
// Cross-origin stylesheets can't be accessed
279+
if (sheet.href) {
280+
console.warn('CORS restricted stylesheet:', sheet.href)
281+
}
282+
}
283+
}
284+
285+
return allStyles.join('\n\n')
286+
})
287+
288+
// Prepare EPUB options
289+
const options: any = {
290+
title: data?.definition?.title || content.title,
291+
author: argument['epub-author'] || data?.definition?.author || 'Unknown',
292+
publisher: argument['epub-publisher'] || 'LiaScript',
293+
content: content.chapters,
294+
css: styles, // Add extracted styles
295+
verbose: true,
296+
}
297+
298+
// Add cover if provided
299+
if (argument['epub-cover']) {
300+
const coverPath = path.resolve(argument['epub-cover'])
301+
if (fs.existsSync(coverPath)) {
302+
options.cover = coverPath
303+
}
304+
}
305+
306+
// Add description if available
307+
if (data?.definition?.comment) {
308+
options.description = data.definition.comment
309+
}
310+
311+
console.warn(`Extracted ${styles.length} characters of CSS`)
312+
313+
// Generate EPUB
314+
console.warn('Generating EPUB file...')
315+
await new Epub(options, argument.output + '.epub').promise
316+
317+
console.warn(`EPUB created: ${argument.output}.epub`)
318+
} catch (e) {
319+
console.warn('Failed to generate EPUB:', e)
320+
}
321+
322+
await browser.close()
323+
}

0 commit comments

Comments
 (0)