@liascript/exporter
Version:
A generic exporter for LiaScript
532 lines (477 loc) • 14.7 kB
text/typescript
import * as helper from './helper'
import * as RDF from './rdf'
import * as COLOR from '../colorize'
const path = require('path')
const fs = require('fs-extra')
const archiver = require('archiver')
export function help() {
console.log('')
console.log(COLOR.heading('H5P settings:'), '\n')
COLOR.info(
'H5P is a free and open-source content collaboration framework based on JavaScript that makes it easy to create, share and reuse interactive HTML5 content.'
)
console.log('\nLearn more: https://h5p.org/\n')
COLOR.command(
null,
'--h5p-embed',
' embed the Markdown into the JS code'
)
COLOR.command(
null,
'--h5p-iframe',
' use an iframe for content display'
)
COLOR.command(
null,
'--h5p-zip',
' package the output as a .h5p file'
)
}
export async function exporter(
argument: {
input: string
readme: string
output: string
format: string
path: string
key?: string
style?: string
// special cases for H5P
'h5p-embed'?: string
'h5p-iframe'?: boolean
'h5p-zip'?: boolean
},
json: any
) {
// make temp folder
let tmp = await helper.tmpDir()
const dirname = helper.dirname()
// Create a standard H5P package structure - this MUST be at the root level, not in subdirectories
let h5pRoot = path.join(tmp, 'h5p_package')
await fs.ensureDir(h5pRoot)
// Extract the markdown content
const markdownContent =
argument['h5p-embed'] ||
(fs.existsSync(path.join(argument.path, argument.readme))
? fs.readFileSync(path.join(argument.path, argument.readme), 'utf8')
: '# LiaScript Content\n\nThis content was exported from LiaScript.')
// Save the markdown content directly to the root with .md extension (allowed in whitelist)
await helper.writeFile(
path.join(h5pRoot, 'liascript_content.md'),
markdownContent
)
// Create h5p.json metadata file (required for all H5P packages)
await helper.writeFile(
path.join(h5pRoot, 'h5p.json'),
JSON.stringify(
{
title: json.lia.str_title || 'LiaScript Course',
language: json.lia.definition.language || 'en',
mainLibrary: 'H5P.Text',
embedTypes: ['iframe'],
preloadedDependencies: [
{
machineName: 'H5P.Text',
majorVersion: 1,
minorVersion: 1,
},
],
author: json.lia.definition.author || '',
license: 'CC BY-SA',
licenseVersion: '4.0',
description: json.lia.comment || '',
contentType: 'Interactive Course',
},
null,
2
)
)
// Parse the markdown content to create HTML
const htmlContent = convertMarkdownToHTML(markdownContent)
// First, copy the LiaScript assets to the temp directory
let liaAssetPath = path.join(tmp, 'lia_assets')
await fs.copy(path.join(dirname, './assets/h5p'), liaAssetPath)
// await fs.copy(path.join(dirname, './assets/common'), liaAssetPath)
// Generate the self-contained HTML
const indexPath = path.join(liaAssetPath, 'index.html')
const selfContainedHtml = await createSelfContainedHtml(
indexPath,
liaAssetPath,
markdownContent // Pass the markdown content as a parameter
)
// Create content.json with the self-contained HTML
const contentJson = {
text: selfContainedHtml,
metadata: {
contentType: 'Text',
license: 'U',
title: json.lia.str_title || 'LiaScript Course',
authors: [
{
name: json.lia.definition.author || 'LiaScript Author',
role: 'Author',
},
],
},
}
// Save content.json directly to the root
await helper.writeFile(
path.join(h5pRoot, 'content.json'),
JSON.stringify(contentJson, null, 2)
)
// Now copy the H5P assets AFTER creating the basic structure
try {
// Copy the content files from the H5P templates - these should be pre-structured correctly
await fs.copy(path.join(dirname, './assets/h5p'), h5pRoot, {
overwrite: false, // Don't overwrite existing files (like content.json)
filter: (src) => {
const ext = path.extname(src).toLowerCase()
// Only copy files with extensions, not directories alone
if (fs.statSync(src).isDirectory()) {
return true // Allow directories in copying
}
// Ensure the file has a valid extension from the whitelist
return ext !== '' // Only copy files with extensions
},
})
console.log('Copied H5P template/assets to package')
} catch (error) {
console.warn('Error copying H5P assets:', error)
}
// List of allowed extensions for H5P
const allowedExtensions = [
'json',
'png',
'jpg',
'jpeg',
'gif',
'bmp',
'tif',
'tiff',
'eot',
'ttf',
'woff',
'woff2',
'otf',
'webm',
'mp4',
'ogg',
'mp3',
'm4a',
'wav',
'txt',
'pdf',
'rtf',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'odt',
'ods',
'odp',
'csv',
'diff',
'patch',
'swf',
'md',
'textile',
'vtt',
'webvtt',
'gltf',
'glb',
'js',
'css',
'svg',
'xml',
]
// Copy user's content files with allowed extensions
if (fs.existsSync(argument.path)) {
try {
// Get all files to copy - only files, not directories
const getAllFiles = function (
dirPath: string,
arrayOfFiles: { source: string; target: string }[] = []
) {
const files = fs.readdirSync(dirPath)
files.forEach(function (file) {
const filePath = path.join(dirPath, file)
const stats = fs.statSync(filePath)
if (stats.isDirectory()) {
// Recurse into subdirectories
arrayOfFiles = getAllFiles(filePath, arrayOfFiles)
} else {
const ext = path.extname(file).toLowerCase().substring(1)
if (allowedExtensions.includes(ext)) {
// Use flat structure - put all files at root with prefixed names to avoid collisions
// This avoids directory whitelist issues
const flatFileName = path
.relative(argument.path, filePath)
.replace(/\//g, '_') // Replace slashes with underscores
.replace(/\\/g, '_') // Handle Windows paths too
arrayOfFiles.push({
source: filePath,
target: path.join(h5pRoot, flatFileName),
})
}
}
})
return arrayOfFiles
}
const filesToCopy = getAllFiles(argument.path)
// Copy each file to the root level (no directories)
for (const file of filesToCopy) {
await fs.copy(file.source, file.target)
}
console.log(`Copied ${filesToCopy.length} resource files to H5P package`)
} catch (error) {
console.warn('Error copying content files:', error)
}
}
// Package as .h5p file
if (argument['h5p-zip'] !== false) {
// Default to creating .h5p file
const output = fs.createWriteStream(argument.output + '.h5p')
const archive = archiver('zip', {
zlib: { level: 9 },
})
output.on('close', function () {
console.log(archive.pointer() + ' total bytes')
console.log(
'H5P package created successfully: ' + argument.output + '.h5p'
)
})
archive.on('error', function (err) {
throw err
})
archive.pipe(output)
// Add all files from the h5pRoot directory to the archive
archive.directory(h5pRoot, false)
archive.finalize()
} else {
// Otherwise just copy the directory
await fs.copy(h5pRoot, argument.output)
console.log('H5P content created successfully at: ' + argument.output)
}
}
// Helper function to convert markdown to HTML
function convertMarkdownToHTML(markdown) {
// This is a very basic markdown to HTML converter
// In a real implementation, you would use a proper markdown parser
let html = markdown
// Convert headers
html = html.replace(/^# (.*?)$/gm, '<h1>$1</h1>')
html = html.replace(/^## (.*?)$/gm, '<h2>$1</h2>')
html = html.replace(/^### (.*?)$/gm, '<h3>$1</h3>')
html = html.replace(/^#### (.*?)$/gm, '<h4>$1</h4>')
html = html.replace(/^##### (.*?)$/gm, '<h5>$1</h5>')
html = html.replace(/^###### (.*?)$/gm, '<h6>$1</h6>')
// Convert bold and italic
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>')
// Convert links
html = html.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2">$1</a>')
// Convert code blocks
html = html.replace(/```(.*?)```/gs, '<pre><code>$1</code></pre>')
// Convert inline code
html = html.replace(/`(.*?)`/g, '<code>$1</code>')
// Convert lists
const lines = html.split('\n')
let inList = false
let listType = ''
for (let i = 0; i < lines.length; i++) {
if (lines[i].match(/^- /)) {
if (!inList || listType !== 'ul') {
lines[i] = inList
? '</ul><ul><li>' + lines[i].substring(2) + '</li>'
: '<ul><li>' + lines[i].substring(2) + '</li>'
inList = true
listType = 'ul'
} else {
lines[i] = '<li>' + lines[i].substring(2) + '</li>'
}
} else if (lines[i].match(/^\d+\. /)) {
if (!inList || listType !== 'ol') {
lines[i] = inList
? '</ol><ol><li>' +
lines[i].substring(lines[i].indexOf('. ') + 2) +
'</li>'
: '<ol><li>' +
lines[i].substring(lines[i].indexOf('. ') + 2) +
'</li>'
inList = true
listType = 'ol'
} else {
lines[i] =
'<li>' + lines[i].substring(lines[i].indexOf('. ') + 2) + '</li>'
}
} else if (inList && lines[i].trim() === '') {
lines[i] = listType === 'ul' ? '</ul>' : '</ol>'
inList = false
} else if (inList) {
// This line is part of a list item
lines[i] = lines[i]
} else {
// Regular paragraph
if (lines[i].trim() !== '') {
if (i === 0 || lines[i - 1].trim() === '') {
lines[i] = '<p>' + lines[i] + '</p>'
} else if (!lines[i - 1].endsWith('</p>')) {
// Continue paragraph
lines[i] = lines[i]
} else {
// New paragraph
lines[i] = '<p>' + lines[i] + '</p>'
}
}
}
}
if (inList) {
lines.push(listType === 'ul' ? '</ul>' : '</ol>')
}
html = lines.join('\n')
// Add CSS for styling
html =
`<style>
.liascript-content {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.markdown-content {
background-color: #f9f9f9;
border: 1px solid #ddd;
border-radius: 5px;
padding: 20px;
margin-bottom: 20px;
}
.download-section {
background-color: #e9f7fe;
border: 1px solid #a8d7f3;
border-radius: 5px;
padding: 15px;
margin-top: 30px;
}
code {
background-color: #f0f0f0;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
}
pre {
background-color: #f0f0f0;
padding: 10px;
border-radius: 5px;
overflow-x: auto;
}
pre code {
background-color: transparent;
padding: 0;
}
a {
color: #0066cc;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>` + html
return html
}
// Update the function signature to accept markdownContent
async function createSelfContainedHtml(indexPath, basePath, markdownContent) {
const cheerio = require('cheerio')
const fs = require('fs-extra')
const path = require('path')
const mime = require('mime-types')
// Read the index HTML file
const htmlContent = await fs.readFile(indexPath, 'utf8')
const $ = cheerio.load(htmlContent)
// Process and inline JavaScript files
$('script[src]').each(function () {
const src = $(this).attr('src')
if (
src.startsWith('http:') ||
src.startsWith('https:') ||
src.startsWith('//')
) {
// Skip external scripts
return
}
try {
const scriptPath = path.resolve(path.dirname(indexPath), src)
const scriptContent = fs.readFileSync(scriptPath, 'utf8')
$(this).removeAttr('src').text(scriptContent)
console.log(`Inlined script: ${src}`)
} catch (error) {
console.warn(`Could not inline script ${src}:`, error.message)
}
})
// Process and inline CSS files
$('link[rel="stylesheet"]').each(function () {
const href = $(this).attr('href')
if (
href.startsWith('http:') ||
href.startsWith('https:') ||
href.startsWith('//')
) {
// Skip external stylesheets
return
}
try {
const cssPath = path.resolve(path.dirname(indexPath), href)
const cssContent = fs.readFileSync(cssPath, 'utf8')
$(this).replaceWith(`<style>${cssContent}</style>`)
console.log(`Inlined CSS: ${href}`)
} catch (error) {
console.warn(`Could not inline CSS ${href}:`, error.message)
}
})
// Process and inline images (convert to data URLs)
$('img[src]').each(function () {
const src = $(this).attr('src')
if (
src.startsWith('data:') ||
src.startsWith('http:') ||
src.startsWith('https:') ||
src.startsWith('//')
) {
// Skip already inlined or external images
return
}
try {
const imgPath = path.resolve(path.dirname(indexPath), src)
const imgBuffer = fs.readFileSync(imgPath)
const mimeType = mime.lookup(imgPath) || 'application/octet-stream'
const dataUrl = `data:${mimeType};base64,${imgBuffer.toString('base64')}`
$(this).attr('src', dataUrl)
console.log(`Inlined image: ${src}`)
} catch (error) {
console.warn(`Could not inline image ${src}:`, error.message)
}
})
// Inject the markdown content directly
$('#container').append(`
<div id="embedded-markdown" style="display:none;">
${markdownContent.replace(/</g, '<').replace(/>/g, '>')}
</div>
`)
// Add a script to process the embedded markdown on page load
$('body').append(`
<script>
document.addEventListener('DOMContentLoaded', function() {
if (window.LIA && typeof window.LIA.loadMarkdown === 'function') {
const markdown = document.getElementById('embedded-markdown').innerHTML
.replace(/</g, '<')
.replace(/>/g, '>');
window.LIA.loadMarkdown(markdown);
}
});
</script>
`)
return $.html()
}