From e67ac3d03a7608112d8ce0e77a760119c9abf5bc Mon Sep 17 00:00:00 2001 From: Sebastian Rettig Date: Sun, 20 Dec 2020 20:27:44 +0100 Subject: [PATCH] feat(html export): add html exporter (#935) * feat(html export): added basic exporter * feat(html export): resources now inlined * feat(html export): added JS minification * feat(html export): added font stripper * refactor(post css plugin): moved remove fonts to own plugin * test(post css plugin): added tests * refactor(html export): style improvements * refactor(html exporter): improved style * refactor(html exporter): improved style * refactor(html export): parallelized promises * feat(html export): source file licenses added to output * test(html exporter): added tests and fixed errors * feat(html export): added unreferenced resources to export * test(html exporter): moved test to integration * test(html exporter): correct test imports * test(postcss plugin): added test for callback * refactor(html exporter): improved structure and documentation * test(html exporter): activated all tests again * ci(integration tests): added browsers to integration tests * refactor(html export): fixed exports and imports * test(html exporter): fixed improt * refactor(html export): moved dependencies * feat(html export): added option to keep content resources as files --- .circleci/config.yml | 2 +- examples/express.ts | 20 + examples/startPageRenderer.ts | 6 + jest.config.js | 2 +- package-lock.json | 138 +++- package.json | 16 +- src/H5PPlayer.ts | 10 +- src/HtmlExporter.ts | 732 ++++++++++++++++++ src/LibraryName.ts | 6 + src/helpers/LibrariesFilesList.ts | 40 + src/helpers/StreamHelpers.ts | 9 +- src/helpers/postCssRemoveRedundantFontUrls.ts | 120 +++ src/index.ts | 6 +- src/types.ts | 2 + .../postCssRemoveRedundantFonts.test.ts | 94 +++ test/integration/HtmlExporter.test.ts | 208 +++++ 16 files changed, 1395 insertions(+), 16 deletions(-) create mode 100644 src/HtmlExporter.ts create mode 100644 src/helpers/LibrariesFilesList.ts create mode 100644 src/helpers/postCssRemoveRedundantFontUrls.ts create mode 100644 test/helpers/postCssRemoveRedundantFonts.test.ts create mode 100644 test/integration/HtmlExporter.test.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index 1fd6a26be..5a3e688fb 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -68,7 +68,7 @@ jobs: integration-tests: docker: - - image: circleci/node:10 + - image: circleci/node:10-browsers steps: - checkout - attach_workspace: diff --git a/examples/express.ts b/examples/express.ts index fd31e4d6c..384849a5a 100644 --- a/examples/express.ts +++ b/examples/express.ts @@ -159,6 +159,26 @@ const start = async () => { contentTypeCacheExpressRouter(h5pEditor.contentTypeCache) ); + const htmlExporter = new H5P.HtmlExporter( + h5pEditor.libraryStorage, + h5pEditor.contentStorage, + h5pEditor.config, + path.resolve('h5p/core'), + path.resolve('h5p/editor') + ); + + server.get('/h5p/html/:contentId', async (req, res) => { + const html = await htmlExporter.createSingleBundle( + req.params.contentId, + (req as any).user + ); + res.setHeader( + 'Content-disposition', + `attachment; filename=${req.params.contentId}.html` + ); + res.status(200).send(html); + }); + // The startPageRenderer displays a list of content objects and shows // buttons to display, edit, delete and download existing content. server.get('/', startPageRenderer(h5pEditor)); diff --git a/examples/startPageRenderer.ts b/examples/startPageRenderer.ts index c54a17288..408f24ab8 100644 --- a/examples/startPageRenderer.ts +++ b/examples/startPageRenderer.ts @@ -67,6 +67,12 @@ export default function render( download +
+ + + download HTML + +
diff --git a/jest.config.js b/jest.config.js index 75520df0d..8534188c3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -173,7 +173,7 @@ module.exports = { // verbose: null, // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode - // watchPathIgnorePatterns: [], + watchPathIgnorePatterns: ['/node_modules/', '/test/data/', '/h5p/', '/build/'], // Whether to use watchman for file crawling // watchman: true, diff --git a/package-lock.json b/package-lock.json index 661ea5cd6..f0d7c4448 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1439,7 +1439,8 @@ "@types/prop-types": { "version": "15.7.3", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz", - "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==" + "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==", + "dev": true }, "@types/puppeteer": { "version": "5.4.2", @@ -1530,6 +1531,15 @@ "@types/superagent": "*" } }, + "@types/uglify-js": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.11.1.tgz", + "integrity": "sha512-7npvPKV+jINLu1SpSYVWG8KvyJBhBa8tmzMMdDoVc2pWUYHN8KIXlPJhjJ4LT97c4dXJA2SHL/q6ADbDriZN+Q==", + "dev": true, + "requires": { + "source-map": "^0.6.1" + } + }, "@types/yargs": { "version": "15.0.11", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.11.tgz", @@ -2309,6 +2319,15 @@ } } }, + "clean-css": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.3.tgz", + "integrity": "sha512-VcMWDN54ZN/DS+g58HYL5/n4Zrqe8vHJpGA8KdgUXFU4fuP/aHNw8eld9SyEIyabIMJX/0RaY/fplOo5hYLSFA==", + "dev": true, + "requires": { + "source-map": "~0.6.0" + } + }, "clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -2711,7 +2730,14 @@ "csstype": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.5.tgz", - "integrity": "sha512-uVDi8LpBUKQj6sdxNaTetL6FpeCqTjOvAQuQUa/qAqq8oOd4ivkbhgnqayl0dnPal8Tb/yB1tF+gOvCBiicaiQ==" + "integrity": "sha512-uVDi8LpBUKQj6sdxNaTetL6FpeCqTjOvAQuQUa/qAqq8oOd4ivkbhgnqayl0dnPal8Tb/yB1tF+gOvCBiicaiQ==", + "dev": true + }, + "cuint": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/cuint/-/cuint-0.2.2.tgz", + "integrity": "sha1-QICG1AlVDCYxFVYZ6fp7ytw7mRs=", + "dev": true }, "dargs": { "version": "7.0.0", @@ -9760,6 +9786,105 @@ "source-map": "^0.6.1" } }, + "postcss-clean": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/postcss-clean/-/postcss-clean-1.1.0.tgz", + "integrity": "sha512-83g3GqMbCM5NL6MlbbPLJ/m2NrUepBF44MoDk4Gt04QGXeXKh9+ilQa0DzLnYnvqYHQCw83nckuEzBFr2muwbg==", + "dev": true, + "requires": { + "clean-css": "^4.x", + "postcss": "^6.x" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "postcss": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", + "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "source-map": "^0.6.1", + "supports-color": "^5.4.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "postcss-safe-parser": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-5.0.2.tgz", + "integrity": "sha512-jDUfCPJbKOABhwpUKcqCVbbXiloe/QXMcbJ6Iipf3sDIihEzTqRCeMBfRaOHxhBuTYqtASrI1KJWxzztZU4qUQ==", + "requires": { + "postcss": "^8.1.0" + } + }, + "postcss-url": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/postcss-url/-/postcss-url-10.1.1.tgz", + "integrity": "sha512-cYeRNcXUMiM1sr3UgHkY+zMuqhSmJaLeP3VOZWWqShBDMB10DlrK5KfciLK0LGr7xKDPP5nH7Q2odvDHQSrP9A==", + "dev": true, + "requires": { + "make-dir": "3.1.0", + "mime": "2.4.6", + "minimatch": "3.0.4", + "xxhashjs": "0.2.2" + } + }, "prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", @@ -12445,6 +12570,15 @@ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "dev": true }, + "xxhashjs": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/xxhashjs/-/xxhashjs-0.2.2.tgz", + "integrity": "sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw==", + "dev": true, + "requires": { + "cuint": "^0.2.2" + } + }, "y18n": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.1.tgz", diff --git a/package.json b/package.json index 6be03c639..14ddd3416 100644 --- a/package.json +++ b/package.json @@ -69,9 +69,8 @@ } ], "dependencies": { - "@types/react": "^17.0.0", - "ajv": "^7.0.0", "ajv-keywords": "^4.0.0", + "ajv": "^7.0.0", "axios": "^0.21.0", "crc": "^3.8.0", "debug": "^4.1.1", @@ -83,11 +82,16 @@ "merge": "^2.0.0", "mime-types": "^2.1.26", "nanoid": "^3.1.10", + "postcss-clean": "^1.1.0", + "postcss-safe-parser": "^5.0.2", + "postcss-url": "^10.1.1", + "postcss": "^8.1.10", "promisepipe": "^3.0.0", "qs": "^6.9.3", "sanitize-html": "^2.1.1", "stream-buffers": "^3.0.2", "tmp-promise": "^3.0.0", + "uglify-js": "^3.12.0", "upath": "^2.0.0", "yauzl-promise": "^2.1.3", "yazl": "^2.5.1" @@ -104,21 +108,23 @@ "@types/mime-types": "2.1.0", "@types/mongodb": "3.6.3", "@types/puppeteer": "5.4.2", + "@types/react": "^17.0.0", "@types/sanitize-html": "1.27.0", "@types/stream-buffers": "3.0.3", "@types/supertest": "2.0.10", + "@types/uglify-js": "^3.11.1", "@types/yauzl-promise": "2.1.0", "@types/yazl": "2.4.2", "aws-sdk": "2.814.0", "axios-mock-adapter": "1.19.0", "body-parser": "1.19.0", "commitlint": "11.0.0", - "express": "4.17.1", "express-fileupload": "1.2.0", + "express": "4.17.1", "husky": "4.3.6", - "i18next": "19.8.4", "i18next-fs-backend": "1.0.7", "i18next-http-middleware": "3.0.6", + "i18next": "19.8.4", "jest": "26.6.3", "mockdate": "3.0.2", "mongodb": "3.6.3", @@ -131,9 +137,9 @@ "supertest": "6.0.1", "ts-jest": "26.4.4", "ts-node": "9.1.1", - "tslint": "6.1.3", "tslint-config-airbnb": "5.11.2", "tslint-config-prettier": "1.18.0", + "tslint": "6.1.3", "typescript": "4.1.3" }, "main": "./build/src/index.js", diff --git a/src/H5PPlayer.ts b/src/H5PPlayer.ts index 9873ea0ca..5ddf84b7a 100644 --- a/src/H5PPlayer.ts +++ b/src/H5PPlayer.ts @@ -12,7 +12,8 @@ import { ILibraryStorage, IPlayerModel, IUrlGenerator, - ILibraryMetadata + ILibraryMetadata, + IUser } from './types'; import UrlGenerator from './UrlGenerator'; import Logger from './helpers/Logger'; @@ -67,7 +68,8 @@ export default class H5PPlayer { public async render( contentId: ContentId, parameters?: ContentParameters, - metadata?: IContentMetadata + metadata?: IContentMetadata, + user?: IUser ): Promise { log.info(`rendering page for ${contentId}`); @@ -130,6 +132,7 @@ export default class H5PPlayer { const model: IPlayerModel = { contentId, + dependencies, downloadPath: this.getDownloadPath(contentId), integration: this.generateIntegration( contentId, @@ -143,7 +146,8 @@ export default class H5PPlayer { .concat(assets.scripts), styles: this.listCoreStyles().concat(assets.styles), translations: {}, - embedTypes: metadata.embedTypes // TODO: check if the library supports the embed type! + embedTypes: metadata.embedTypes, // TODO: check if the library supports the embed type! + user }; return this.renderer(model); diff --git a/src/HtmlExporter.ts b/src/HtmlExporter.ts new file mode 100644 index 000000000..bbb47483f --- /dev/null +++ b/src/HtmlExporter.ts @@ -0,0 +1,732 @@ +import fsExtra from 'fs-extra'; +import path from 'path'; +import postCss, { CssSyntaxError } from 'postcss'; +import postCssUrl from 'postcss-url'; +import postCssClean from 'postcss-clean'; +import mimetypes from 'mime-types'; +import uglifyJs from 'uglify-js'; +import postCssSafeParser from 'postcss-safe-parser'; + +import H5PPlayer from './H5PPlayer'; +import { streamToString } from './helpers/StreamHelpers'; +import LibraryName from './LibraryName'; +import { + ContentId, + IContentStorage, + IH5PConfig, + ILibraryName, + ILibraryStorage, + IPlayerModel, + IUser +} from './types'; +import { ContentFileScanner } from './ContentFileScanner'; +import LibraryManager from './LibraryManager'; +import postCssRemoveRedundantUrls from './helpers/postCssRemoveRedundantFontUrls'; +import LibrariesFilesList from './helpers/LibrariesFilesList'; + +/** + * This script is used to change the default behavior of H5P when it gets + * resources dynamically from JavaScript. This works in most cases, but there + * are some libraries (the H5P.SoundJS library used by single choice set) that + * can't be modified that way. + */ +const getLibraryFilePathOverrideScript = uglifyJs.minify( + `H5P.ContentType = function (isRootLibrary) { + function ContentType() {} + ContentType.prototype = new H5P.EventDispatcher(); + ContentType.prototype.isRoot = function () { + return isRootLibrary; + }; + ContentType.prototype.getLibraryFilePath = function (filePath) { + return furtherH5PInlineResources[this.libraryInfo.versionedNameNoSpaces + '/' + filePath]; + }; + return ContentType; + };` +).code; + +const getContentPathOverrideScript = uglifyJs.minify( + `H5P.getPath = function (path, contentId) { + return path; + }; + ` +).code; + +/** + * Creates standalone HTML packages that can be used to display H5P in a browser + * without having to use the full H5P server backend. + * + * The bundle includes all JavaScript files, stylesheets, fonts of the H5P core + * and all libraries used in the content. It also includes base64 encoded + * resources used in the content itself. This can make the files seriously big, + * if the content includes video files or lots of high-res images. + * + * The bundle does NOT internalize resources that are included in the content + * via absolute URLs but only resources that are part of the H5P package. + * + * The HTML exports work with all content types on the official H5P Hub, but + * there might be unexpected issues with other content types if they behave + * weirdly and in any kind of non-standard way. + * + * The exported bundle contains license information for each file put into the + * bundle in a shortened fashion (only includes author and license name and not + * full license text). + * + * (important!) You need to install these NPM packages for the exporter to work: + * postcss, postcss-clean, postcss-url, postcss-safe-parser, uglify-js + */ + +export default class HtmlExporter { + /** + * @param libraryStorage + * @param contentStorage + * @param config + * @param coreFilePath the path on the local filesystem at which the H5P + * core files can be found. (should contain a js and styles directory) + * @param editorFilePath the path on the local filesystem at which the H5P + * editor files can be found. (Should contain the scripts, styles and + * ckeditor directories). + */ + constructor( + protected libraryStorage: ILibraryStorage, + protected contentStorage: IContentStorage, + protected config: IH5PConfig, + protected coreFilePath: string, + protected editorFilePath: string + ) { + this.player = new H5PPlayer( + this.libraryStorage, + this.contentStorage, + this.config + ); + this.coreSuffix = `${this.config.baseUrl + this.config.coreUrl}/`; + this.editorSuffix = `${ + this.config.baseUrl + this.config.editorLibraryUrl + }/`; + this.contentFileScanner = new ContentFileScanner( + new LibraryManager(this.libraryStorage) + ); + } + + private contentFileScanner: ContentFileScanner; + private coreSuffix: string; + private defaultAdditionalScripts: string[] = [ + // The H5P core client creates paths to resource files using the + // hostname of the current URL, so we have to make sure data: URLs + // work. + `const realH5PGetPath = H5P.getPath; + H5P.getPath = function (path, contentId) { + if(path.startsWith('data:')){ + return path; + } + else { + return realH5PGetPath(path, contentId); + } + };` + ]; + private editorSuffix: string; + private player: H5PPlayer; + + /** + * Creates a HTML file that contains **all** scripts, styles and library + * resources (images and fonts) inline. All resources used inside the + * content are only listed and must be retrieved from library storage by the + * caller. + * @param contentId a content id that can be found in the content repository + * passed into the constructor + * @param user the user who wants to create the bundle + * @param contentResourcesPrefix (optional) if set, the prefix will be added + * to all content files in the content's parameters; example: + * contentResourcesPrefix = '123'; filename = 'images/image.jpg' => filename + * in parameters: '123/images/image.jpg' (the directory separated is added + * automatically) + * @throws H5PError if there are access violations, missing files etc. + * @returns a HTML string that can be written into a file and a list of + * content files used by the content; you can use the filenames in + * IContentStorage.getFileStream. Note that the returned filenames DO NOT + * include the prefix, so that the caller doesn't have to remove it when + * calling getFileStream. + */ + public async createBundleWithExternalContentResources( + contentId: ContentId, + user: IUser, + contentResourcesPrefix: string = '' + ): Promise<{ contentFiles: string[]; html: string }> { + this.player.setRenderer( + this.renderer( + { + contentResources: 'files', + core: 'inline', + libraries: 'inline' + }, + { + contentResourcesPrefix + } + ) + ); + return this.player.render(contentId, undefined, undefined, user); + } + + /** + * Creates a single HTML file that contains **all** scripts, styles and + * resources (images, videos, etc.) inline. This bundle will grow very large + * if there are big videos in the content. + * @param contentId a content id that can be found in the content repository + * passed into the constructor + * @param user the user who wants to create the bundle + * @throws H5PError if there are access violations, missing files etc. + * @returns a HTML string that can be written into a file + */ + public async createSingleBundle( + contentId: ContentId, + user: IUser + ): Promise { + this.player.setRenderer( + this.renderer({ + contentResources: 'inline', + core: 'inline', + libraries: 'inline' + }) + ); + return (await this.player.render(contentId, undefined, undefined, user)) + .html; + } + + /** + * Finds all files in the content's parameters and returns them. Also + * appends the prefix if necessary. Note: This method has a mutating effect + * on model! + * @param model + * @param prefix this prefix will be added to all file references as + * subdirectory + */ + private async findAndPrefixContentResources( + model: IPlayerModel, + prefix: string = '' + ): Promise { + const content = model.integration.contents[`cid-${model.contentId}`]; + const params = JSON.parse(content.jsonContent); + const mainLibraryUbername = content.library; + + const fileRefs = ( + await this.contentFileScanner.scanForFiles( + params, + LibraryName.fromUberName(mainLibraryUbername, { + useWhitespace: true + }) + ) + ).filter((ref) => this.isLocalPath(ref.filePath)); + fileRefs.forEach( + (ref) => (ref.context.params.path = path.join(prefix, ref.filePath)) + ); + model.integration.contents[ + `cid-${model.contentId}` + ].jsonContent = JSON.stringify(params); + + return fileRefs.map((ref) => ref.filePath); + } + + /** + * Generates JavaScript / CSS comments that includes license information + * about a file. Includes: filename, author, license. Note that some H5P + * libraries don't contain any license information. + * @param filename + * @param core + * @param editor + * @param library + * @returns a multi-line comment with the license information. The comment + * is marked as important and includes @license so that uglify-js and + * postcss-clean leave it in. + */ + private async generateLicenseText( + filename: string, + core?: boolean, + editor?: boolean, + library?: ILibraryName + ): Promise { + if (core) { + return `/*!@license ${filename} by Joubel and other contributors, licensed under GNU GENERAL PUBLIC LICENSE Version 3*/`; + } + if (editor) { + return `/*!@license ${filename} by Joubel and other contributors, licensed under MIT license*/`; + } + if (library) { + let { author, license } = await this.libraryStorage.getLibrary( + library + ); + if (!author || author === '') { + author = 'unknown'; + } + if (!license || license === '') { + license = 'unknown license'; + } + return `/*!@license ${LibraryName.toUberName( + library + )}/${filename} by ${author} licensed under ${license}*/`; + } + return ''; + } + + /** + * Gets the contents of a file as a string. Only works for text files, not + * binary files. + * @param filename the filename as generated by H5PPlayer. This can be a + * path to a) a core file b) an editor file c) a library file + * @returns an object giving more detailed information about the file: + * - core: true if the file is a core file, undefined otherwise + * - editor: true if the file is an editor file, undefined otherwise + * - library: the library name if the file is a library file, undefined + * otherwise + * - filename: the filename if the suffix of the core/editor/library is + * stripped + * - text: the text in the file + */ + private async getFileAsText( + filename: string, + usedFiles: LibrariesFilesList + ): Promise<{ + core?: boolean; + editor?: boolean; + filename: string; + library?: ILibraryName; + text: string; + }> { + const libraryFileMatch = new RegExp( + `^${this.config.baseUrl}${this.config.librariesUrl}/([\\w\\.]+)-(\\d+)\\.(\\d+)\\/(.+)$` + ).exec(filename); + + if (!libraryFileMatch) { + if (filename.startsWith(this.coreSuffix)) { + // Core files + const filenameWithoutDir = filename.substr( + this.coreSuffix.length + ); + return { + text: ( + await fsExtra.readFile( + path.resolve(this.coreFilePath, filenameWithoutDir) + ) + ).toString(), + core: true, + filename: filenameWithoutDir + }; + } + + if (filename.startsWith(this.editorSuffix)) { + // Editor files + const filenameWithoutDir = filename.substr( + this.editorSuffix.length + ); + return { + text: ( + await fsExtra.readFile( + path.resolve( + this.editorFilePath, + filenameWithoutDir + ) + ) + ).toString(), + editor: true, + filename: filenameWithoutDir + }; + } + } else { + // Library files + const library = { + machineName: libraryFileMatch[1], + majorVersion: Number.parseInt(libraryFileMatch[2], 10), + minorVersion: Number.parseInt(libraryFileMatch[3], 10) + }; + const filenameWithoutDir = libraryFileMatch[4]; + usedFiles.addFile(library, filenameWithoutDir); + return { + text: await streamToString( + await this.libraryStorage.getFileStream( + library, + filenameWithoutDir + ) + ), + library, + filename: filenameWithoutDir + }; + } + throw Error( + `Unknown file pattern: ${filename} is neither a library file, a core file or an editor file.` + ); + } + + /** + * Creates a big minified bundle of all script files in the model + * @param model + * @param additionalScripts an array of scripts (actual script code as + * string, not filenames!) that should be appended at the end of the bundle + * @returns all scripts in a single bundle + */ + private async getScriptBundle( + model: IPlayerModel, + usedFiles: LibrariesFilesList, + additionalScripts: string[] = [] + ): Promise { + const texts = {}; + await Promise.all( + model.scripts.map(async (script) => { + const { + text, + filename, + core, + editor, + library + } = await this.getFileAsText(script, usedFiles); + const licenseText = await this.generateLicenseText( + filename, + core, + editor, + library + ); + // We must escape tags inside scripts. + texts[script] = + licenseText + text.replace(/<\/script>/g, '<\\/script>'); + }) + ); + const scripts = model.scripts + .map((script) => texts[script]) + .concat(additionalScripts); + return uglifyJs.minify(scripts, { output: { comments: 'some' } }).code; + } + + /** + * Creates a big minified bundle of all style files in the model. Also + * internalizes all url(...) resources in the styles. + * @param model + * @returns all styles in a single bundle + */ + private async getStylesBundle( + model: IPlayerModel, + usedFiles: LibrariesFilesList + ): Promise { + const styleTexts = {}; + await Promise.all( + model.styles.map(async (style) => { + const { + text, + filename, + library, + editor, + core + } = await this.getFileAsText(style, usedFiles); + const licenseText = await this.generateLicenseText( + filename, + core, + editor, + library + ); + let processedCss = ''; + const pCss = postCss( + postCssRemoveRedundantUrls( + undefined, + library + ? (f) => { + usedFiles.addFile( + library, + path.join(path.dirname(filename), f) + ); + } + : undefined + ), + postCssUrl({ + url: this.urlInternalizer( + filename, + library, + editor, + core, + usedFiles + ) + }), + postCssClean() + ); + + try { + processedCss = ( + await pCss.process(licenseText + text, { + from: filename + }) + )?.css; + } catch (error) { + // We retry with a more tolerant CSS parser if parsing has + // failed with the regular one. + if (error instanceof CssSyntaxError) { + processedCss = ( + await pCss.process(licenseText + text, { + parser: postCssSafeParser, + from: filename + }) + )?.css; + } else { + throw error; + } + } + styleTexts[style] = processedCss; + }) + ); + return model.styles.map((style) => styleTexts[style]).join('\n'); + } + + /** + * Gets base64 encoded contents of library files that have not been used in + * the bundle so far. Ignores files that are only used by the editor. + * @param libraries the libraries for which to get files + * @returns an object with the filenames of files as keys and base64 strings + * as values + */ + private async getUnusedLibraryFiles( + libraries: ILibraryName[], + usedFiles: LibrariesFilesList + ): Promise<{ [filename: string]: string }> { + const result: { [filename: string]: string } = {}; + + await Promise.all( + libraries.map(async (library) => { + const ubername = LibraryName.toUberName(library); + const allLibraryFiles = await this.libraryStorage.listFiles( + library + ); + const unusedLibraryFiles = allLibraryFiles.filter( + (filename) => { + if ( + !usedFiles.checkFile(library, filename) && + !filename.startsWith('language/') && + filename !== 'library.json' && + filename !== 'semantics.json' && + filename !== 'icon.svg' && + filename !== 'upgrades.js' && + filename !== 'presave.js' + ) { + const mt = mimetypes.lookup( + path.basename(filename) + ); + if ( + mt && + (mt.startsWith('audio/') || + mt.startsWith('video/') || + mt.startsWith('image/')) && + !filename.includes('font') + ) { + return true; + } + } + return false; + } + ); + await Promise.all( + unusedLibraryFiles.map(async (unusedFile) => { + result[ + `${ubername}/${unusedFile}` + ] = `data:${mimetypes.lookup( + path.basename(unusedFile) + )};base64,${await streamToString( + await this.libraryStorage.getFileStream( + library, + unusedFile + ), + 'base64' + )}`; + }) + ); + }) + ); + return result; + } + + /** + * Changes the content params by internalizing all files references with + * base64 data strings. Has a side effect on contents[cid-xxx]! + * @param model + */ + private async internalizeContentResources( + model: IPlayerModel + ): Promise { + const content = model.integration.contents[`cid-${model.contentId}`]; + const params = JSON.parse(content.jsonContent); + const mainLibraryUbername = content.library; + + const contentFiles = await this.contentFileScanner.scanForFiles( + params, + LibraryName.fromUberName(mainLibraryUbername, { + useWhitespace: true + }) + ); + await Promise.all( + contentFiles.map(async (fileRef) => { + if (this.isLocalPath(fileRef.filePath)) { + try { + const base64 = await streamToString( + await this.contentStorage.getFileStream( + model.contentId, + fileRef.filePath, + model.user + ), + 'base64' + ); + const mimetype = + fileRef.mimeType || + mimetypes.lookup(path.extname(fileRef.filePath)); + fileRef.context.params.path = `data:${mimetype};base64,${base64}`; + } catch (error) { + // We silently ignore errors, as there might be cases in + // which YouTube links are not detected correctly. + } + } + }) + ); + content.jsonContent = JSON.stringify(params); + content.contentUrl = '.'; + content.url = '.'; + } + + /** + * Returns true if the filename is not an absolute URL or empty. + * @param filename + */ + private isLocalPath = (filename: string): boolean => + !( + filename === '' || + filename.toLocaleLowerCase().startsWith('http://') || + filename.toLocaleLowerCase().startsWith('https://') + ); + + /** + * Creates HTML strings out of player models. + * @param model the player model created by H5PPlayer + * @returns a string with HTML markup + */ + private renderer = ( + mode: { + contentResources: 'files' | 'inline'; + core: 'files' | 'inline'; + libraries: 'files' | 'inline'; + }, + options?: { + contentResourcesPrefix?: string; + } + ) => async ( + model: IPlayerModel + ): Promise<{ contentFiles?: string[]; html: string }> => { + if (mode.core === 'files') { + throw new Error('Core mode "files" not supported yet.'); + } + if (mode.libraries === 'files') { + throw new Error('Library mode "files" not supported yet.'); + } + + const usedFiles = new LibrariesFilesList(); + // tslint:disable-next-line: prefer-const + let [scriptsBundle, stylesBundle] = await Promise.all([ + this.getScriptBundle( + model, + usedFiles, + this.defaultAdditionalScripts + ), + this.getStylesBundle(model, usedFiles), + mode?.contentResources === 'inline' + ? this.internalizeContentResources(model) + : undefined + ]); + + // Look for files in the libraries which haven't been included in the + // bundle so far. + const unusedFiles = await this.getUnusedLibraryFiles( + model.dependencies, + usedFiles + ); + // If there are files in the directory of a library that haven't been + // included in the bundle yet, we add those as base64 encoded variables + // and rewire H5P.ContentType.getLibraryFilePath to return these files + // as data urls. (needed for resource files of H5P.BranchingScenario) + if (Object.keys(unusedFiles).length) { + scriptsBundle = scriptsBundle.concat( + ` var furtherH5PInlineResources=${JSON.stringify( + unusedFiles + )};`, + getLibraryFilePathOverrideScript + ); + } + + // If the user wants to put content resources into files, we must get + // these files and + let contentFiles: string[]; + if (mode.contentResources === 'files') { + contentFiles = await this.findAndPrefixContentResources( + model, + options?.contentResourcesPrefix + ); + scriptsBundle = scriptsBundle.concat(getContentPathOverrideScript); + } + + const html = ` + + + + + + + +
+ + `; + return { html, contentFiles }; + }; + + /** + * A factory method that returns functions that can be passed to the url + * option of postcss-url. The function returns the base64 encoded resource. + * @param filename the filename of the css file being internalized + * @param library the library name if the css file is a library file + * @param editor true if the css file is a editor file + * @param core true if the css file is a core file + * @param asset the object received from the postcss-url plugin call + */ + private urlInternalizer = ( + filename: string, + library: ILibraryName, + editor: boolean, + core: boolean, + usedFiles: LibrariesFilesList + ) => async (asset) => { + const mimetype = mimetypes.lookup(path.extname(asset.relativePath)); + + if (library) { + const p = path.join(path.dirname(filename), asset.relativePath); + try { + usedFiles.addFile(library, p); + return `data:${mimetype};base64,${await streamToString( + await this.libraryStorage.getFileStream(library, p), + 'base64' + )}`; + } catch { + // There are edge cases in which there are non-existent files in + // stylesheets as placeholders (H5P.BranchingScenario), so we + // have to leave them in. + return asset.relativePath; + } + } + + if (editor || core) { + const basePath = editor + ? path.join(this.editorFilePath, 'styles') + : path.join(this.coreFilePath, 'styles'); + return `data:${mimetype};base64,${await fsExtra.readFile( + path.resolve(basePath, asset.relativePath), + 'base64' + )}`; + } + }; +} diff --git a/src/LibraryName.ts b/src/LibraryName.ts index 731f76239..450c4f7a1 100644 --- a/src/LibraryName.ts +++ b/src/LibraryName.ts @@ -11,6 +11,12 @@ export default class LibraryName implements ILibraryName { public majorVersion: number, public minorVersion: number ) { + if (typeof this.majorVersion === 'string') { + this.majorVersion = Number.parseInt(this.majorVersion, 10); + } + if (typeof this.minorVersion === 'string') { + this.minorVersion = Number.parseInt(this.minorVersion, 10); + } LibraryName.validate(this); } diff --git a/src/helpers/LibrariesFilesList.ts b/src/helpers/LibrariesFilesList.ts new file mode 100644 index 000000000..d3286dc67 --- /dev/null +++ b/src/helpers/LibrariesFilesList.ts @@ -0,0 +1,40 @@ +import { LibraryName } from '../'; +import { ILibraryName } from '../types'; + +/** + * Collects lists of files grouped by libraries. + */ +export default class LibrariesFilesList { + private usedFiles: { [ubername: string]: string[] } = {}; + + /** + * Adds a library file to the list. + * @param library + * @param filename + */ + public addFile(library: ILibraryName, filename: string): void { + const ubername = LibraryName.toUberName(library); + if (!this.usedFiles[ubername]) { + this.usedFiles[ubername] = []; + } + this.usedFiles[ubername].push(filename); + } + + /** + * Checks if a library file is in the list + * @param library + * @param filename + */ + public checkFile(library: ILibraryName, filename: string): boolean { + return this.usedFiles[LibraryName.toUberName(library)]?.includes( + filename + ); + } + + /** + * Clears the list of all libraries. + */ + public clear(): void { + this.usedFiles = {}; + } +} diff --git a/src/helpers/StreamHelpers.ts b/src/helpers/StreamHelpers.ts index 18b9a7ea9..37f849bed 100644 --- a/src/helpers/StreamHelpers.ts +++ b/src/helpers/StreamHelpers.ts @@ -5,12 +5,17 @@ import { Stream } from 'stream'; * @param stream the stream to read * @returns */ -export function streamToString(stream: Stream): Promise { +export function streamToString( + stream: Stream, + encoding: BufferEncoding = 'utf8' +): Promise { /* from https://stackoverflow.com/questions/10623798/read-contents-of-node-js-stream-into-a-string-variable */ const chunks = []; return new Promise((resolve, reject) => { stream.on('data', (chunk) => chunks.push(chunk)); stream.on('error', reject); - stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + stream.on('end', () => + resolve(Buffer.concat(chunks).toString(encoding)) + ); }); } diff --git a/src/helpers/postCssRemoveRedundantFontUrls.ts b/src/helpers/postCssRemoveRedundantFontUrls.ts new file mode 100644 index 000000000..64235c26e --- /dev/null +++ b/src/helpers/postCssRemoveRedundantFontUrls.ts @@ -0,0 +1,120 @@ +import { ChildNode, Plugin, Root } from 'postcss'; + +/** + * Maps file extensions to font types. + */ +const extensionsMap = { + eot: 'embedded-opentype', + ttf: 'truetype', + otf: 'opentype' +}; + +/** + * A list of font types used in CSS. + */ +type FontTypes = + | 'woff' + | 'woff2' + | 'truetype' + | 'svg' + | 'embedded-opentype' + | 'opentype'; + +/** + * A PostCSS plugin Removing redundant URLs in @font-face rules by deleting all + * URLs from src except for a single one. + * @param fontPreference (optional) the order in which fonts should be kept; the + * first one in the list is the one that is taken first, if it exists + * @param removedCallback (optional) this function if executed for each file + * reference that is removed by the plugin + */ +export default function ( + fontPreference: FontTypes[] = [ + 'woff', + 'woff2', + 'truetype', + 'svg', + 'opentype', + 'embedded-opentype' + ], + removedCallback?: (filename: string) => void +): Plugin { + if (!fontPreference || fontPreference.length === 0) { + throw new Error( + 'You must specify the order in which fonts should be preferred as any array with at least one entry.' + ); + } + + return { + postcssPlugin: 'postcss-remove-redundant-font-urls', + // tslint:disable-next-line: function-name + async Once(styles: Root): Promise { + styles.walkAtRules('font-face', (atRule) => { + const fonts: { + extension: string; + filename: string; + format: FontTypes; + node: ChildNode; + sourceText: string; + }[] = []; + + // Create a list of all fonts used in the @font-face rule. + atRule.nodes + .filter((node) => (node as any).prop === 'src') + .forEach((node) => { + const regex = /url\(["']?([^'"\)\?#]+)\.(.*?)([\?\#].+?)?["']?\)( format\(["'](.*?)["']\))?[,$]?/g; + let matches: string[]; + while ( + // tslint:disable-next-line: no-conditional-assignment + (matches = regex.exec((node as any).value)) + ) { + const format = + matches[5] ?? + extensionsMap[matches[2]] ?? + matches[2]; + fonts.push({ + format, + node, + sourceText: matches[0], + filename: matches[1], + extension: matches[2] + }); + } + }); + + // Determine which font should be kept by sorting the list. + const fontToKeep = fonts.sort((a, b) => { + const indexA = fontPreference.indexOf(a.format); + const indexB = fontPreference.indexOf(b.format); + return ( + (indexA === -1 ? fontPreference.length : indexA) - + (indexB === -1 ? fontPreference.length : indexB) + ); + })[0]; + + // Remove all other fonts from the rule. + fonts.forEach((f) => { + if (f === fontToKeep) { + return; + } + let newValue = (f.node as any).value as string; + newValue = newValue.replace(f.sourceText, '').trim(); + if (newValue.endsWith(',')) { + newValue = newValue.substr(0, newValue.length - 1); + } + if (removedCallback) { + removedCallback(`${f.filename}.${f.extension}`); + } + + // Delete the whole src node if it has become empty because + // of the removed font. + if (newValue.trim() === '') { + atRule.removeChild(f.node); + } else { + (f.node as any).value = newValue.trim(); + } + }); + }); + } + }; +} diff --git a/src/index.ts b/src/index.ts index 111119729..a30964a5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,8 @@ // Classes import H5PEditor from './H5PEditor'; -import H5PPlayer from './H5PPlayer'; import H5pError from './helpers/H5pError'; +import H5PPlayer from './H5PPlayer'; +import HtmlExporter from './HtmlExporter'; import InstalledLibrary from './InstalledLibrary'; import LibraryName from './LibraryName'; import PackageExporter from './PackageExporter'; @@ -55,10 +56,11 @@ export { H5PEditor, H5pError, H5PPlayer, + HtmlExporter, InstalledLibrary, + LibraryAdministration, LibraryName, PackageExporter, - LibraryAdministration, // interfaces ContentId, ContentParameters, diff --git a/src/types.ts b/src/types.ts index 8cb8c3876..7abda386d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1645,12 +1645,14 @@ export interface IHubInfo { export interface IPlayerModel { contentId: ContentParameters; + dependencies: ILibraryName[]; downloadPath: string; embedTypes: ('iframe' | 'div')[]; integration: IIntegration; scripts: string[]; styles: string[]; translations: any; + user: IUser; } export interface IEditorModel { diff --git a/test/helpers/postCssRemoveRedundantFonts.test.ts b/test/helpers/postCssRemoveRedundantFonts.test.ts new file mode 100644 index 000000000..6d51526eb --- /dev/null +++ b/test/helpers/postCssRemoveRedundantFonts.test.ts @@ -0,0 +1,94 @@ +import postCss from 'postcss'; + +import postCssRemoveRedundantUrls from '../../src/helpers/postCssRemoveRedundantFontUrls'; + +describe('postCssRemoveRedundantFonts.test', () => { + it('removes redundant fonts', async () => { + const result = await postCss(postCssRemoveRedundantUrls()).process( + `@font-face { src: url(path/to/font.woff) format("woff"), url(path/to/font.otf) format("opentype"); }` + ); + expect(result.css).toEqual( + `@font-face { src: url(path/to/font.woff) format("woff"); }` + ); + }); + + it('removes redundant fonts in the desired order', async () => { + const result = await postCss( + postCssRemoveRedundantUrls(['opentype', 'woff']) + ).process( + `@font-face { src: url(path/to/font.woff) format("woff"), url(path/to/font.otf) format("opentype"); }` + ); + expect(result.css).toEqual( + `@font-face { src: url(path/to/font.otf) format("opentype"); }` + ); + }); + + it('works with quotation marks', async () => { + const result = await postCss(postCssRemoveRedundantUrls()).process( + `@font-face { src: url('path/to/font.woff') format("woff"), url("path/to/font.otf") format("opentype"); }` + ); + expect(result.css).toEqual( + `@font-face { src: url('path/to/font.woff') format("woff"); }` + ); + }); + + it('also removes redundant fonts of unknown type', async () => { + const result = await postCss(postCssRemoveRedundantUrls()).process( + `@font-face { src: url(path/to/font.woff) format("woff"), url(path/to/font.unk) format("unknown"); }` + ); + expect(result.css).toEqual( + `@font-face { src: url(path/to/font.woff) format("woff"); }` + ); + }); + + it('calls the callback for removed fonts', async () => { + const removed = []; + const result = await postCss( + postCssRemoveRedundantUrls(undefined, (filename) => + removed.push(filename) + ) + ).process( + `@font-face { src: url(path/to/font.woff2) format("woff2"), url(path/to/font.woff) format("woff"), url(path/to/font.otf) format("opentype"); }` + ); + expect(removed).toMatchObject([ + 'path/to/font.woff2', + 'path/to/font.otf' + ]); + }); + + it('removes redundant fonts across multiple srcs', async () => { + const result = await postCss(postCssRemoveRedundantUrls()).process( + `@font-face { src: url(path/to/font.woff) format("woff"), url(path/to/font.otf) format("opentype"); src: url(path/to/font.eot) format("embedded-opentype"); }` + ); + expect(result.css).toEqual( + `@font-face { src: url(path/to/font.woff) format("woff"); }` + ); + }); + + it("removes redundant fonts if format isn't specified", async () => { + const result = await postCss(postCssRemoveRedundantUrls()).process( + `@font-face { src: url(path/to/font.woff), url(path/to/font.otf); }` + ); + expect(result.css).toEqual( + `@font-face { src: url(path/to/font.woff); }` + ); + }); + + it('keeps single fonts', async () => { + const result = await postCss(postCssRemoveRedundantUrls()).process( + `@font-face { src: url(path/to/font.otf) format("opentype"); }` + ); + expect(result.css).toEqual( + `@font-face { src: url(path/to/font.otf) format("opentype"); }` + ); + }); + + it('removes real live H5P fonts with query strings', async () => { + const result = await postCss(postCssRemoveRedundantUrls()).process( + `@font-face { src: url('fontawesome-webfont.eot?#iefix&v=4.5.0') format('embedded-opentype'),url('fontawesome-webfont.woff2?v=4.5.0') format('woff2'),url('fontawesome-webfont.woff?v=4.5.0') format('woff'),url('fontawesome-webfont.ttf?v=4.5.0') format('truetype'),url('fontawesome-webfont.svg?v=4.5.0#fontawesomeregular') format('svg'); }` + ); + expect(result.css).toEqual( + `@font-face { src: url('fontawesome-webfont.woff?v=4.5.0') format('woff'); }` + ); + }); +}); diff --git a/test/integration/HtmlExporter.test.ts b/test/integration/HtmlExporter.test.ts new file mode 100644 index 000000000..78e10cc96 --- /dev/null +++ b/test/integration/HtmlExporter.test.ts @@ -0,0 +1,208 @@ +import puppeteer from 'puppeteer'; +import * as fsExtra from 'fs-extra'; +import * as path from 'path'; +import { withDir, withFile } from 'tmp-promise'; +import promisePipe from 'promisepipe'; + +import ContentManager from '../../src/ContentManager'; +import H5PConfig from '../../src/implementation/H5PConfig'; +import FileContentStorage from '../../src/implementation/fs/FileContentStorage'; +import FileLibraryStorage from '../../src/implementation/fs/FileLibraryStorage'; +import LibraryManager from '../../src/LibraryManager'; +import PackageImporter from '../../src/PackageImporter'; + +import User from '../../examples/User'; +import HtmlExporter from '../../src/HtmlExporter'; + +let browser: puppeteer.Browser; +let page: puppeteer.Page; + +async function importAndExportHtml( + packagePath: string, + mode: 'singleBundle' | 'externalContentResources' +): Promise { + await withDir( + async ({ path: tmpDirPath }) => { + const contentDir = path.join(tmpDirPath, 'content'); + const libraryDir = path.join(tmpDirPath, 'libraries'); + await fsExtra.ensureDir(contentDir); + await fsExtra.ensureDir(libraryDir); + + const user = new User(); + user.canUpdateAndInstallLibraries = true; + + const contentStorage = new FileContentStorage(contentDir); + const contentManager = new ContentManager(contentStorage); + const libraryStorage = new FileLibraryStorage(libraryDir); + const libraryManager = new LibraryManager(libraryStorage); + const config = new H5PConfig(null); + + const packageImporter = new PackageImporter( + libraryManager, + config, + contentManager + ); + + const htmlExporter = new HtmlExporter( + libraryStorage, + contentStorage, + config, + path.resolve('h5p/core'), + path.resolve('h5p/editor') + ); + const contentId = ( + await packageImporter.addPackageLibrariesAndContent( + packagePath, + user + ) + ).id; + if (mode === 'singleBundle') { + const exportedHtml = await htmlExporter.createSingleBundle( + contentId, + user + ); + await withFile( + async (result) => { + await fsExtra.writeFile(result.path, exportedHtml); + await page.goto(`file://${result.path}`, { + waitUntil: ['networkidle0', 'load'], + timeout: 30000 + }); + }, + { + keep: false, + postfix: '.html' + } + ); + } else if (mode === 'externalContentResources') { + const res = await htmlExporter.createBundleWithExternalContentResources( + contentId, + user, + contentId.toString() + ); + await withDir( + async (result) => { + await fsExtra.mkdirp(result.path); + await fsExtra.writeFile( + path.join(result.path, `${contentId}.html`), + res.html + ); + for (const f of res.contentFiles) { + try { + const tempFilePath = path.join( + result.path, + contentId.toString(), + f + ); + await fsExtra.mkdirp( + path.dirname(tempFilePath) + ); + const writer = fsExtra.createWriteStream( + tempFilePath + ); + const readable = await contentStorage.getFileStream( + contentId, + f, + user + ); + await promisePipe(readable, writer); + writer.close(); + } catch { + // We silently ignore errors here as there is + // some example content with invalid file + // references. + } + } + await page.goto( + `file://${result.path}/${contentId}.html`, + { + waitUntil: ['networkidle0', 'load'], + timeout: 30000 + } + ); + }, + { + keep: false, + unsafeCleanup: true + } + ); + } + }, + { keep: false, unsafeCleanup: true } + ); +} +describe('HtmlExporter', () => { + beforeAll(async () => { + browser = await puppeteer.launch({ + headless: true, + args: [ + '--headless', + '--no-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu' + ], + ignoreDefaultArgs: ['--mute-audio'] + }); + page = await browser.newPage(); + await page.setCacheEnabled(true); + await page.setUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36' + ); + + // Tests should fail when there is an error on the page + page.on('pageerror', (error) => { + throw new Error(`There was in error in the page: ${error.message}`); + }); + + // Tests should fail when there is a failed request to our server + page.on('response', async (response) => { + if ( + response.status() >= 400 && + response.status() < 600 && + // we ignore requests that result in errors on different servers + // response.url().startsWith(serverHost) && + // we ignore requests that result from missing resource files + // in packages + !/\/content\/(\d+)\/$/.test(response.url()) && + // we ignore missing favicons + !/favicon\.ico$/.test(response.url()) + ) { + throw new Error( + `Received status code ${response.status()} ${response.statusText()} for ${response + .request() + .method()} request to ${response.url()}\n${await response.text()}` + ); + } + }); + }); + + afterAll(async () => { + await page.close(); + await browser.close(); + }); + + const directory = `${path.resolve('')}/test/data/hub-content/`; + let files; + try { + files = fsExtra.readdirSync(directory); + } catch { + throw new Error( + "The directory test/data/hub-content does not exist. Execute 'npm run download:content' to fetch example data from the H5P Hub!" + ); + } + + for (const file of files.filter((f) => f.endsWith('.h5p'))) { + it(`creates html exports (${file})`, async () => { + await importAndExportHtml( + path.join(directory, file), + 'singleBundle' + ); + }, 30000); + } + it(`creates html exports (H5P.Dialogcards.h5p)`, async () => { + await importAndExportHtml( + path.join(directory, 'H5P.Dialogcards.h5p'), + 'externalContentResources' + ); + }, 30000); +});