UNPKG

@liascript/exporter

Version:
823 lines (711 loc) 23.3 kB
import * as helper from './helper' import * as PDF from './pdf' import * as IMS from './ims' import * as SCORM12 from './scorm12' import * as SCORM2004 from './scorm2004' import * as ANDROID from './android' import * as RDF from './rdf' import * as COLOR from '../colorize' const fs = require('fs-extra') const path = require('path') const { execSync } = require('child_process') var Categories: Set<string> = new Set([]) export function getNext(collection: any): string | null { if (collection['collection']) { collection = collection['collection'] } if (collection['url'] && collection['data'] === undefined) { return collection['url'] } else { for (let i = 0; i < collection.length; i++) { let course = collection[i] if (course.collection) { let url = getNext(course) if (url) { return url } } else if (course.url && course.data === undefined) { return course.url } } } return null } export function storeNext(collection: any, data: any) { if (collection['collection']) { collection = collection['collection'] } for (let i = 0; i < collection.length; i++) { if (collection[i].collection) { for (let j = 0; j < collection[i].collection.length; j++) { if ( collection[i].collection[j].url && collection[i].collection[j].data === undefined ) { collection[i].collection[j].data = data return } } } else if (collection[i].url && collection[i].data === undefined) { collection[i].data = data return } } return } export function help() { console.log('') console.log(COLOR.heading('Project settings:'), '\n') COLOR.info( 'A project is a bundle for multiple LiaScript resource into a single project overview page, based on a provided yaml description.' ) console.log( '\nLearn more: https://www.npmjs.com/package/@liascript/exporter#project \n' ) console.log('Example:') console.log( '- Input: https://github.com/LiaBooks/liabooks.github.com/blob/main/project.yaml' ) console.log('- Output: https://liabooks.github.io') console.log('') COLOR.command( null, '--project-no-meta', ' Disable the generation of meta information for OpenGraph and Twitter-cards.' ) COLOR.command( null, '--project-no-rdf', ' Disable the generation of json-ld.' ) COLOR.command( null, '--project-no-categories', ' Disable the filter for categories/tags.' ) COLOR.command( null, '--project-category-blur', ' Enable this and the categories will be blurred instead of deleted.' ) COLOR.command( null, '--project-generate-scrom12', 'SCORM12 and pass additional scrom settings.' ) COLOR.command( null, '--project-generate-scrom2004', 'SCORM2004 and pass additional scrom settings.' ) COLOR.command( null, '--project-generate-ims', ' IMS resources with additional config settings.' ) COLOR.command( null, '--project-generate-pdf', ' PDFs are automatically generated and added to every card.' ) COLOR.command( null, '--project-generate-cache', ' Only generate new files, if they do not exist.' ) } export async function exporter( argument: { input: string readme: string output: string format: string path: string key?: string style?: string // special project settings 'project-no-meta'?: boolean 'project-no-categories'?: boolean 'project-category-blur'?: boolean 'project-generate-pdf'?: boolean 'project-generate-cache'?: boolean }, json ) { // make temp folder let cards = '' const output = argument.output const itemList: any[] = [] for (let i = 0; i < json.collection.length; i++) { let course = json.collection[i] if (course.collection) { let subCards = '' let subItemList: any[] = [] for (let j = 0; j < course.collection.length; j++) { if (course.collection[j].link) { subCards += `<div class='col-sm-6 col-md-4 col-lg-3 ${ course.grid ? 'mb-3' : '' }'> ${toLinkCard(argument, course.collection[j], true)} </div>` } else { let { html, json } = await toCard( argument, course.collection[j], true ) subCards += `<div class='col-sm-6 col-md-4 col-lg-3 ${ course.grid ? 'mb-3' : '' }'> ${html} </div>` subItemList.push(json) } } const itemListElement = { '@type': 'ItemList', itemListElement: subItemList, } if (course.title) { itemListElement['name'] = course.title } if (course.comment) { itemListElement['description'] = course.comment } itemList.push(itemListElement) cards += ` <div class="col-12"> <div class="card"> <div class="card-header"> ${course.title} </div> <div class="card-body"> <p class="card-text">${course.comment}</p> <div ${ course.grid ? 'class="row"' : 'style="display: flex; scroll-snap-type: x mandatory; overflow-x: auto; overflow-y: hidden; padding-bottom: 10px"' }> ${subCards} </div> </div> </div> </div>` } else if (course.html) { cards += "<div class='col-12'>" + course.html + '</div>' } else if (course.link) { cards += "<div class='col'>" + toLinkCard(argument, course) + '</div>' } else { let { html, json } = await toCard(argument, course) cards += "<div class='col'>" + html + '</div>' itemList.push(json) } } const background = json.logo ? `style="background-size: cover; background-image: url('${json.logo}'); background-position: center center; background-repeat: no-repeat;"` : '' let options = '' if (Categories.size > 0 && !argument['project-no-categories']) { const opt = [...Categories].sort() for (let i = 0; i < opt.length; i++) { options += `<option value="${opt[i]}">${opt[i]}</option>` } options = `<select id="categorySelect" class="form-select" aria-label="Default select example" onchange="addCategoryChip(this.value)"> <option value="" selected>All categories</option>` + options + '</select>' } const jsonLD = { '@context': 'http://schema.org/', '@type': 'ItemList', itemListElement: removeContext(itemList), } let title = json.title || 'LiaScript Course Index' if (json.title) { title = cleanHTML(title).replace(/\s+/g, ' ').trim() jsonLD['name'] = title } if (json.comment) { jsonLD['description'] = cleanHTML(json.comment).replace(/\s+/g, ' ').trim() } const html = `<!DOCTYPE html> <html> <head> <title>${title}</title> <script type="application/ld+json"> ${JSON.stringify(jsonLD, null, 2)} </script> ${ json.icon ? '<link rel="icon" type="image/x-icon" href="' + json.icon + '">' : '' } <meta name="viewport" content="width=device-width, initial-scale=1"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous"> <script> // Globale Variable zum Speichern der ausgewählten Kategorien window.selectedCategories = [] // Fügt einen Chip hinzu, wenn eine Kategorie gewählt wurde function addCategoryChip(category) { if (!category) return // falls "All categories" gewählt wurde, nichts tun if (window.selectedCategories.indexOf(category) === -1) { window.selectedCategories.push(category) // Option im Select-Menü deaktivieren document.querySelector('#categorySelect option[value="' + category + '"]').disabled = true updateChipsDisplay() filterCards() } // Setze das Select-Element zurück document.getElementById('categorySelect').value = '' } // Entfernt einen Chip function removeCategoryChip(category) { const index = window.selectedCategories.indexOf(category) if (index > -1) { window.selectedCategories.splice(index, 1) // Option im Select-Menü wieder aktivieren document.querySelector('#categorySelect option[value="' + category + '"]').disabled = false updateChipsDisplay() filterCards() } } // Aktualisiert die Anzeige der Chips function updateChipsDisplay() { const chipsContainer = document.getElementById('chipsContainer') chipsContainer.innerHTML = '' window.selectedCategories.forEach(function (cat) { const chip = document.createElement('span') chip.className = 'badge rounded-pill bg-primary me-2' chip.style.cursor = 'pointer' chip.style.fontSize = '1rem' chip.textContent = cat + ' ×' chip.onclick = function () { removeCategoryChip(cat) } chipsContainer.appendChild(chip) }) } function filterCards() { const cards = document.querySelectorAll('div[data-category]') cards.forEach(function (card) { // Falls keine Filterkategorien ausgewählt wurden, zeige alle Karten if (window.selectedCategories.length === 0) { ${ argument['project-category-blur'] ? 'card.style.filter = "";' : 'card.parentNode.style.display = "block";' } return } // Zerlege die im data-Attribut hinterlegten Kategorien const cardCategories = card.dataset.category.split('|') // Prüfe, ob alle ausgewählten Kategorien in der Karte vorhanden sind const show = window.selectedCategories.every(function (cat) { return cardCategories.indexOf(cat) !== -1 }) if (show) { ${ argument['project-category-blur'] ? 'card.style.filter = "";' : 'card.parentNode.style.display = "block";' } } else { ${ argument['project-category-blur'] ? 'card.style.filter = "blur(1px) opacity(35%)";' : 'card.parentNode.style.display = "none";' } } }) } </script> </head> <body> <main> <div class="container-fluid" ${background} > <section class="py-5 text-center container"> <div class="row py-lg-5"> <div class="col-lg-6 col-md-8 mx-auto"> <h1 class="fw-light">${ json.title || 'LiaScript Course Index' }</h1> <p class="lead text-muted">${json.comment || ''}</p> ${options} <div id="chipsContainer" class="mt-3"></div> </div> </div> </section> </div> <div class="album py-5 bg-light"> <div class="container"> <div class="row row-cols-1 row-cols-sm-1 row-cols-md-2 row-cols-xl-3 g-3"> ${cards} </div> </div> </div> </main> <footer class="text-muted py-3"> <div class="container"> <p class="float-end"> <a href="#">Back to top</a> </p> <p>${ json.footer || '<a href="https://liascript.github.io" target="_blank">LiaScript</a> is a Markdown dialect made for education. For more information checkout out <a href="https://www.youtube.com/channel/UCyiTe2GkW_u05HSdvUblGYg" target="_blank">YouTube-Channel</a> or follow us on <a href="https://twitter.com/LiaScript" target="_blank">Twitter</a>.' }</p> </div> </footer> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script> </body> </html> ` helper.writeFile(output + '.html', helper.prettify(helper.prettify(html))) } function removeContext(obj) { for (let key in obj) { if (obj.hasOwnProperty(key)) { if (key === '@context') { delete obj[key] } else if (typeof obj[key] === 'object') { removeContext(obj[key]) } } } return obj } async function moveFile(oldPath, newPath) { // 1. Create the destination directory if it does not exist // Set the `recursive` option to `true` to create all the subdirectories await fs.mkdir(path.dirname(newPath), { recursive: true }) // 2. Rename the file (move it to the new directory) // Return the promise return fs.rename(oldPath, newPath) } function cleanHTML(html: string) { return html.replace(/<[^>]+>/g, '') } function meta(json: any) { const title = json.meta?.title || cleanHTML(json.title) || 'LiaScript Course Index' const description = json.meta?.description || cleanHTML(json.comment) const image = json.meta?.image || json.logo return `<meta property="og:type" content="website"> <meta property="og:title" content="${title}"> <meta property="og:description" content="${description}"> <meta property="og:image" content="${image}"> <meta name="twitter:title" content="${title}"> <meta name="twitter:description" content="${description}"> <meta name="twitter:image" content="${image}"> ` } function overwrite(check, defaultsTo) { return check === null ? null : check || defaultsTo } function hash(url: string) { const value = url .split('') .map((v) => v.charCodeAt(0)) .reduce((a, v) => (a + ((a << 7) + (a << 3))) ^ v) .toString(16) return value.startsWith('-') ? '0' + value.slice(1) : value } function toLinkCard( argument: any, course: any, small: boolean = false ): string { if (course.arguments) { argument = course.arguments.reduce((a, b) => { return { ...a, ...b } }, argument) } let tags = [] const tagList = course.tags || tags for (let i = 0; i < tagList.length; i++) { Categories.add(tagList[i].toLowerCase()) } let comment = course.title ? course.comment : course.comment || course.link return card( small, '', course.title || '', comment || '', tagList, {}, course.logo, course.link ) } async function toCard( argument: any, course: any, small: boolean = false ): Promise<{ html: string; json: any }> { // if other parameters are defined for a specific course // then they are treated if (course.arguments) { argument = course.arguments.reduce((a, b) => { return { ...a, ...b } }, argument) } let tags try { tags = course.data.lia.definition.macro.tags .split(',') .map((e: string) => e.trim()) } catch (e) { tags = [] } const backupOutput = hash(course.data.lia.readme) const tagList = course.tags || tags for (let i = 0; i < tagList.length; i++) { Categories.add(tagList[i].toLowerCase()) } let downloads = {} if (argument['project-generate-pdf']) downloads['pdf'] = 'assets/pdf/' + backupOutput + '.pdf' if (argument['project-generate-ims']) downloads['ims'] = 'assets/ims/' + backupOutput + '.zip' if (argument['project-generate-scorm12']) downloads['scorm12'] = 'assets/scorm12/' + backupOutput + '.zip' if (argument['project-generate-scorm2004']) downloads['scorm2004'] = 'assets/scorm2004/' + backupOutput + '.zip' if (argument['project-generate-pdf']) { argument.input = course.data.lia.readme argument.output = backupOutput const file = argument.output + '.pdf' if ( argument['project-generate-cache'] && fs.existsSync(path.join(process.cwd(), 'assets/pdf/' + file)) ) { console.log('using cached file of ', argument.input, ' -> ', file) } else { console.log('generate pdf of', argument.input, ' -> ', file) await PDF.exporter(argument, {}) if (fs.existsSync(file)) { await moveFile(file, 'assets/pdf/' + file) } } } let repo if ( argument['project-generate-ims'] || argument['project-generate-scorm12'] || argument['project-generate-scorm2004'] || argument['project-generate-android'] ) { repo = helper.getRepository(course.url) if (repo) { execSync(repo.cmd) argument.input = path.join('tmp', repo.path) argument.path = 'tmp' argument.readme = path.join('./', repo.path) argument.output = backupOutput execSync('rm -rf tmp/.git') execSync('rm -rf tmp/.github') execSync('rm -rf tmp/.gitignore') } } // IMS if (repo && argument['project-generate-ims']) { argument.output = 'assets/ims/' + backupOutput const file = argument.output + '.zip' try { execSync('mkdir assets/ims') } catch (e) {} if ( argument['project-generate-cache'] && fs.existsSync(path.join(process.cwd(), file)) ) { console.log('using cached file of ', argument.input, ' -> ', file) } else { await IMS.exporter(argument, course.data) } } // SCORM12 if (repo && argument['project-generate-scorm12']) { argument.output = 'assets/scrom12/' + backupOutput const asset = argument.output + '.zip' if ( argument['project-generate-cache'] && fs.existsSync(path.join(process.cwd(), asset)) ) { console.log('using cached file of ', argument.input, ' -> ', asset) } else { await SCORM12.exporter(argument, course.data) } } // SCORM2004 if (repo && argument['project-generate-scorm2004']) { argument.output = 'assets/scorm2004/' + backupOutput const asset = argument.output + '.zip' if ( argument['project-generate-cache'] && fs.existsSync(path.join(process.cwd(), asset)) ) { console.log('using cached file of ', argument.input, ' -> ', asset) } else { await SCORM2004.exporter(argument, course.data) } } // Android if (repo && argument['project-generate-android']) { argument.output = backupOutput const file = argument.output + '.apk' const asset = 'assets/android/' + file if ( argument['project-generate-cache'] && fs.existsSync(path.join(process.cwd(), asset)) ) { downloads['apk'] = asset } else { await ANDROID.exporter(argument, course.data) if (fs.existsSync(file)) { await moveFile(file, asset) downloads['apk'] = asset } } } // clean up if (repo) { execSync('rm -rf tmp') } argument['rdf-url'] = course.data.lia.readme return { html: card( small, course.data.lia.readme, overwrite(course.title, course.data.lia.str_title), overwrite(course.comment, course.data.lia.comment), tagList, downloads, overwrite(course.logo, course.data.lia.definition.logo) ), json: await RDF.parse(argument, course.data), } } function card( small: boolean, url: string, title: string, comment: string, tags: string[], download: { pdf?: string scorm12?: string scorm2004?: string ims?: string apk?: string }, img_url?: string, link?: string ): string { let image = '' if (img_url) { if (!(img_url.startsWith('http:') || img_url.startsWith('https:'))) { const fullImageUrl = new URL(img_url, url) img_url = fullImageUrl.toString() } image = //`<img src="${img_url}" class="card-img-top" alt="">` `<div class="card-img-top" style="background-size: cover; height: 175px; background-image: url('${img_url}'); background-position: center center; background-repeat: no-repeat;"></div>` } /* else { image = `<svg class="bd-placeholder-img card-img-top" width="100%" height="175" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Placeholder: Thumbnail" preserveAspectRatio="xMidYMid slice" focusable="false"><title>Placeholder</title><rect width="100%" height="100%" fill="${stringToColor( title )}"></rect></svg>` } */ let tag_list = '' if (tags.length > 0) { for (let i = 0; i < tags.length; i++) { tag_list += `<span style="display: inline; white-space: break-spaces;" class="badge rounded-pill bg-light text-dark">${tags[i]}</span>` } tag_list = `<p>${tag_list}</p>` } if (small && comment) { comment = '<small>' + comment + '</small>' } let footer = '' if (Object.keys(download).length > 0) { footer = `<div class="card-footer"> <div class="dropdown"> <a class="btn btn-secondary btn-sm dropdown-toggle" href="#" role="button" id="dropdownMenuLink" data-bs-toggle="dropdown" aria-expanded="false"> Download as ... </a> <ul class="dropdown-menu" aria-labelledby="dropdownMenuLink" style=""> ${ download.pdf ? '<li><a class="dropdown-item btn-sm" target="_blank" href="' + download.pdf + '">PDF</a></li>' : '' } ${ download.scorm12 ? '<li><a class="dropdown-item btn-sm" href="' + download.scorm12 + '">SCORM 1.2</a></li>' : '' } ${ download.scorm2004 ? '<li><a class="dropdown-item btn-sm" href="' + download.scorm2004 + '">SCORM 2004</a></li>' : '' } ${ download.ims ? '<li><a class="dropdown-item btn-sm" href="' + download.ims + '">IMS Content Packaging</a></li>' : '' } ${ download.apk ? '<li><a class="dropdown-item btn-sm" href="' + download.apk + '">Android APK</a></li>' : '' } </ul> </div> </div> ` } return `<div class="card shadow-sm m-1" style="height: 100%" data-category="${tags .map((e) => e.toLowerCase()) .join('|')}"> ${image} <div class="card-body" style="transform: rotate(0);"> <a href="${link ? '' : 'https://liascript.github.io/course/?'}${ link || url }" target="${ link && !link.startsWith('http') ? '_self' : '_blank' }" class="link-dark stretched-link"> <h${small ? 6 : 5} class="card-title">${title}</h${small ? 6 : 5}> </a> <p class="card-text">${comment}</p> ${tag_list} </div> ${footer} </div>` } function stringToColor(str: string) { var hash = 0 for (var i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash) } var color = '#' for (var i = 0; i < 3; i++) { var value = (hash >> (i * 8)) & 0xff color += ('00' + value.toString(16)).substr(-2) } return color }