diff --git a/.eslintrc b/.eslintrc index 2ef4f7d..588e0cb 100644 --- a/.eslintrc +++ b/.eslintrc @@ -18,5 +18,8 @@ "prefer-regex-literals": "off", "spaced-comment": "off", "radix": ["error", "always"] + }, + "globals": { + "Opal": true } } diff --git a/README.adoc b/README.adoc index ac8c3f1..809ee41 100644 --- a/README.adoc +++ b/README.adoc @@ -287,6 +287,68 @@ IMPORTANT: Be sure to register this extension under the `antora.extensions` key This extension adds shared pages that are picked up by the antora-ui-spring project. +=== Asciinema + +*require name:* @springio/antora-extensions/asciinema-extension + +IMPORTANT: Be sure to register this extension under the `antora.extensions` key in the playbook, not the `asciidoc.extensions` key! + +NOTE: Using this extension will need a little help from an +UI bundle as it is expected that named partials `asciinema-load-scripts`, +`asciinema-create-scripts` and `asciinema-styles` are included in a same +locations where javascript and styles are loaded. Extension will add these +partials if those don't already exist in an UI bundle. + +The purpose of this extension is to convert asciidoc block type _asciinema_ into an asciinema-player. Expected content is plain +cast file which is automatically extracted and packaged with +into antora assets and configured with player instances. + +[source,text] +---- +[asciinema] +.... +{"version": 2, "width": 80, "height": 24} +[1.0, "o", "hello "] +[2.0, "o", "world!"] +.... +---- + +TIP: You don't need to inline cast file as it can also come +via asciidoc include macro. + +The extension accepts several configuration options as defined in +https://github.com/asciinema/asciinema-player#options. + +rows:: +Optional attribute as a default value for asciinema option `rows`. + +cols:: +Optional attribute as a default value for asciinema option `cols`. + +auto_play:: +Optional attribute as a default value for asciinema option `autoPlay`. + +The block type accepts several configuration options. Block type options will override +options from an extension level. Not a difference between snake_case and camelCase. +For example: + +[source,text] +---- +[asciinema,rows=10,autoPlay=true] +.... + +.... +---- + +rows:: +Optional attribute as a default value for asciinema option `rows`. + +cols:: +Optional attribute as a default value for asciinema option `cols`. + +autoPlay:: +Optional attribute as a default value for asciinema option `autoPlay`. + ifndef::env-npm[] == Development Quickstart diff --git a/lib/asciinema-extension.js b/lib/asciinema-extension.js new file mode 100644 index 0000000..ef17cf4 --- /dev/null +++ b/lib/asciinema-extension.js @@ -0,0 +1,255 @@ +'use strict' + +const { name: packageName } = require('../package.json') +const fs = require('fs') +const crypto = require('crypto') +const { promises: fsp } = fs +const LazyReadable = require('./lazy-readable') +const MultiFileReadStream = require('./multi-file-read-stream') +const ospath = require('path') +const template = require('./template') + +function register ({ config: { rows, cols, autoPlay, ...unknownOptions } }) { + const logger = this.getLogger(packageName) + + if (Object.keys(unknownOptions).length) { + const keys = Object.keys(unknownOptions) + throw new Error(`Unrecognized option${keys.length > 1 ? 's' : ''} specified for ${packageName}: ${keys.join(', ')}`) + } + + const defaultOptions = { rows, cols, autoPlay } + + this.on('uiLoaded', async ({ playbook, uiCatalog }) => { + playbook.env.SITE_ASCIINEMA_PROVIDER = 'asciinema' + const asciinemaDir = 'asciinema' + const uiOutputDir = playbook.ui.outputDir + vendorJsFile( + uiCatalog, + logger, + uiOutputDir, + 'asciinema-player/dist/bundle/asciinema-player.min.js', + 'asciinema-player.js' + ) + vendorCssFile( + uiCatalog, + logger, + uiOutputDir, + 'asciinema-player/dist/bundle/asciinema-player.css', + 'asciinema-player.css' + ) + + const asciinemaLoadScriptsPartialPath = 'asciinema-load.hbs' + if (!uiCatalog.findByType('partial').some(({ path }) => path === asciinemaLoadScriptsPartialPath)) { + const asciinemaLoadScriptsPartialFilepath = ospath.join(__dirname, asciinemaDir, asciinemaLoadScriptsPartialPath) + uiCatalog.addFile({ + contents: Buffer.from(template(await fsp.readFile(asciinemaLoadScriptsPartialFilepath, 'utf8'), {})), + path: asciinemaLoadScriptsPartialPath, + stem: 'asciinema-load-scripts', + type: 'partial', + }) + } + + const asciinemaCreateScriptsPartialPath = 'asciinema-create.hbs' + if (!uiCatalog.findByType('partial').some(({ path }) => path === asciinemaCreateScriptsPartialPath)) { + const asciinemaCreateScriptsPartialFilepath = ospath.join( + __dirname, + asciinemaDir, + asciinemaCreateScriptsPartialPath + ) + uiCatalog.addFile({ + contents: Buffer.from(template(await fsp.readFile(asciinemaCreateScriptsPartialFilepath, 'utf8'), {})), + path: asciinemaCreateScriptsPartialPath, + stem: 'asciinema-create-scripts', + type: 'partial', + }) + } + + const asciinemaStylesPartialPath = 'asciinema-styles.hbs' + if (!uiCatalog.findByType('partial').some(({ path }) => path === asciinemaStylesPartialPath)) { + const asciinemaStylesPartialFilepath = ospath.join(__dirname, asciinemaDir, asciinemaStylesPartialPath) + uiCatalog.addFile({ + contents: Buffer.from(template(await fsp.readFile(asciinemaStylesPartialFilepath, 'utf8'), {})), + path: asciinemaStylesPartialPath, + stem: 'asciinema-styles', + type: 'partial', + }) + } + + const splitHelperPartialPath = 'asciinema-split-helper.js' + const splitHelperPartialFilepath = ospath.join(__dirname, asciinemaDir, splitHelperPartialPath) + uiCatalog.addFile({ + contents: Buffer.from(template(await fsp.readFile(splitHelperPartialFilepath, 'utf8'), {})), + path: 'helpers/' + splitHelperPartialPath, + stem: 'asciinema-split', + type: 'helper', + }) + + const optionsHelperPartialPath = 'asciinema-options-helper.js' + const optionsHelperPartialFilepath = ospath.join(__dirname, asciinemaDir, optionsHelperPartialPath) + uiCatalog.addFile({ + contents: Buffer.from(template(await fsp.readFile(optionsHelperPartialFilepath, 'utf8'), {})), + path: 'helpers/' + optionsHelperPartialPath, + stem: 'asciinema-options', + type: 'helper', + }) + }) + + this.on('contentClassified', async ({ siteAsciiDocConfig, uiCatalog }) => { + if (!siteAsciiDocConfig.extensions) siteAsciiDocConfig.extensions = [] + siteAsciiDocConfig.extensions.push({ + register: (registry, _context) => { + registry.block('asciinema', processAsciinemaBlock(uiCatalog, defaultOptions, _context)) + return registry + }, + }) + }) +} + +function processAsciinemaBlock (uiCatalog, defaultOptions, context) { + return function () { + this.onContext(['listing', 'literal']) + this.positionalAttributes(['target', 'format']) + this.process((parent, reader, attrs) => { + const { file } = context + const source = reader.getLines().join('\n') + return toBlock(attrs, parent, source, this, uiCatalog, defaultOptions, file) + }) + } +} + +const fromHash = (hash) => { + const object = {} + const data = hash.$$smap + for (const key in data) { + object[key] = data[key] + } + return object +} + +const toBlock = (attrs, parent, source, context, uiCatalog, defaultOptions, file) => { + if (typeof attrs === 'object' && '$$smap' in attrs) { + attrs = fromHash(attrs) + } + const doc = parent.getDocument() + const subs = attrs.subs + if (subs) { + source = doc.$apply_subs(attrs.subs, doc.$resolve_subs(subs)) + } + const idAttr = attrs.id ? ` id="${attrs.id}"` : '' + const classAttr = attrs.role ? `${attrs.role} videoblock` : 'videoblock' + + const block = context.$create_pass_block(parent, '', Opal.hash(attrs)) + + const title = attrs.title + if (title) { + block.title = title + delete block.caption + const caption = attrs.caption + delete attrs.caption + block.assignCaption(caption, 'figure') + } + + const asciinemaId = crypto.createHash('md5').update(source, 'utf8').digest('hex') + if (file.asciidoc.attributes['page-asciinemacasts']) { + file.asciidoc.attributes['page-asciinemacasts'] = + file.asciidoc.attributes['page-asciinemacasts'] + ',' + asciinemaId + } else { + file.asciidoc.attributes['page-asciinemacasts'] = asciinemaId + } + + uiCatalog.addFile({ + contents: Buffer.from(source), + path: '_asciinema/' + asciinemaId + '.cast', + type: 'asset', + out: { path: '_asciinema/' + asciinemaId + '.cast' }, + }) + + const asciinemaOptions = JSON.stringify(buildOptions(attrs, defaultOptions)) + file.asciidoc.attributes['page-asciinema-options-' + asciinemaId] = asciinemaOptions + + const titleElement = title ? `
${block.caption}${title}
` : '' + const style = `${Object.hasOwn(attrs, 'width') ? `width: ${attrs.width}px;` : ''} ${ + Object.hasOwn(attrs, 'height') ? `height: ${attrs.height}px;` : '' + }` + block.lines = [ + ``, + `
`, + `${titleElement}`, + ] + return block +} + +function buildOptions (attrs, defaultOptions) { + const options = {} + const rows = attrs.rows ? attrs.rows : defaultOptions.rows + if (rows) { + options.rows = rows + } + const cols = attrs.cols ? attrs.cols : defaultOptions.cols + if (cols) { + options.cols = cols + } + const autoPlay = attrs.autoPlay ? attrs.autoPlay : defaultOptions.autoPlay + if (autoPlay) { + options.autoPlay = autoPlay + } + return options +} + +function assetFile ( + uiCatalog, + logger, + uiOutputDir, + assetDir, + basename, + assetPath = assetDir + '/' + basename, + contents = new LazyReadable(() => fs.createReadStream(ospath.join(__dirname, '../data', assetPath))), + overwrite = false +) { + const outputDir = uiOutputDir + '/' + assetDir + const existingFile = uiCatalog.findByType('asset').some(({ path }) => path === assetPath) + if (existingFile) { + if (overwrite) { + logger.warn(`Please remove the following file from your UI since it is managed by ${packageName}: ${assetPath}`) + existingFile.contents = contents + delete existingFile.stat + } else { + logger.info(`The following file already exists in your UI: ${assetPath}, skipping`) + } + } else { + uiCatalog.addFile({ + contents, + type: 'asset', + path: assetPath, + out: { dirname: outputDir, path: outputDir + '/' + basename, basename }, + }) + } +} + +function vendorJsFile (uiCatalog, logger, uiOutputDir, requireRequest, basename = requireRequest.split('/').pop()) { + let contents + if (Array.isArray(requireRequest)) { + const filepaths = requireRequest.map(require.resolve) + contents = new LazyReadable(() => new MultiFileReadStream(filepaths)) + } else { + const filepath = require.resolve(requireRequest) + contents = new LazyReadable(() => fs.createReadStream(filepath)) + } + const jsVendorDir = 'js/vendor' + assetFile(uiCatalog, logger, uiOutputDir, jsVendorDir, basename, jsVendorDir + '/' + basename, contents) +} + +function vendorCssFile (uiCatalog, logger, uiOutputDir, requireRequest, basename = requireRequest.split('/').pop()) { + let contents + if (Array.isArray(requireRequest)) { + const filepaths = requireRequest.map(require.resolve) + contents = new LazyReadable(() => new MultiFileReadStream(filepaths)) + } else { + const filepath = require.resolve(requireRequest) + contents = new LazyReadable(() => fs.createReadStream(filepath)) + } + const jsVendorDir = 'css/vendor' + assetFile(uiCatalog, logger, uiOutputDir, jsVendorDir, basename, jsVendorDir + '/' + basename, contents) +} + +module.exports = { register } diff --git a/lib/asciinema/asciinema-create.hbs b/lib/asciinema/asciinema-create.hbs new file mode 100644 index 0000000..9046a75 --- /dev/null +++ b/lib/asciinema/asciinema-create.hbs @@ -0,0 +1,3 @@ +{{#each (asciinema-split page.attributes.asciinemacasts)}} + +{{/each}} diff --git a/lib/asciinema/asciinema-load.hbs b/lib/asciinema/asciinema-load.hbs new file mode 100644 index 0000000..5833367 --- /dev/null +++ b/lib/asciinema/asciinema-load.hbs @@ -0,0 +1 @@ + diff --git a/lib/asciinema/asciinema-options-helper.js b/lib/asciinema/asciinema-options-helper.js new file mode 100644 index 0000000..bfdf416 --- /dev/null +++ b/lib/asciinema/asciinema-options-helper.js @@ -0,0 +1,17 @@ +'use strict' + +module.exports = ( + id, + { + data: { + root: { page }, + }, + } +) => { + const raw = page.attributes['asciinema-options-' + id] + if (raw) { + return raw + } else { + return '{}' + } +} diff --git a/lib/asciinema/asciinema-split-helper.js b/lib/asciinema/asciinema-split-helper.js new file mode 100644 index 0000000..0399398 --- /dev/null +++ b/lib/asciinema/asciinema-split-helper.js @@ -0,0 +1,7 @@ +'use strict' + +module.exports = (ids) => { + if (ids) { + return ids.split(',') + } +} diff --git a/lib/asciinema/asciinema-styles.hbs b/lib/asciinema/asciinema-styles.hbs new file mode 100644 index 0000000..e5255f5 --- /dev/null +++ b/lib/asciinema/asciinema-styles.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lib/lazy-readable.js b/lib/lazy-readable.js new file mode 100644 index 0000000..6724701 --- /dev/null +++ b/lib/lazy-readable.js @@ -0,0 +1,16 @@ +const { PassThrough } = require('stream') + +// adapted from https://github.com/jpommerening/node-lazystream/blob/master/lib/lazystream.js | license: MIT +class LazyReadable extends PassThrough { + constructor (fn, options) { + super(options) + this._read = function () { + delete this._read // restores original method + fn.call(this, options).on('error', this.emit.bind(this, 'error')).pipe(this) + return this._read.apply(this, arguments) + } + this.emit('readable') + } +} + +module.exports = LazyReadable diff --git a/lib/multi-file-read-stream.js b/lib/multi-file-read-stream.js new file mode 100644 index 0000000..03ec481 --- /dev/null +++ b/lib/multi-file-read-stream.js @@ -0,0 +1,24 @@ +'use strict' + +const fs = require('fs') +const { PassThrough } = require('stream') + +class MultiFileReadStream extends PassThrough { + constructor (paths) { + super() + ;(this.queue = this.createQueue(paths)).next() + } + + * createQueue (paths) { + for (const path of paths) { + fs.createReadStream(path) + .once('error', (err) => this.destroy(err)) + .once('end', () => this.queue.next()) + .pipe(this, { end: false }) + yield + } + this.push(null) + } +} + +module.exports = MultiFileReadStream diff --git a/lib/template.js b/lib/template.js new file mode 100644 index 0000000..c3bb4da --- /dev/null +++ b/lib/template.js @@ -0,0 +1,3 @@ +'use strict' + +module.exports = (string, vars) => string.replace(/\${(.+?)}/g, (_, name) => vars[name]) diff --git a/package-lock.json b/package-lock.json index 46bfcba..f74d479 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,12 +11,14 @@ "dependencies": { "@antora/expand-path-helper": "~2.0", "archiver": "^5.3.1", + "asciinema-player": "^3.6.1", "cache-directory": "~2.0", "decompress": "4.2.1", "fast-xml-parser": "latest", "handlebars": "latest" }, "devDependencies": { + "@asciidoctor/core": "latest", "@asciidoctor/tabs": "latest", "chai": "~4.3", "chai-fs": "~2.0", @@ -54,6 +56,21 @@ "node": ">=10.17.0" } }, + "node_modules/@asciidoctor/core": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@asciidoctor/core/-/core-2.2.6.tgz", + "integrity": "sha512-TmB2K5UfpDpSbCNBBntXzKHcAk2EA3/P68jmWvmJvglVUdkO9V6kTAuXVe12+h6C4GK0ndwuCrHHtEVcL5t6pQ==", + "dev": true, + "dependencies": { + "asciidoctor-opal-runtime": "0.3.3", + "unxhr": "1.0.1" + }, + "engines": { + "node": ">=8.11", + "npm": ">=5.0.0", + "yarn": ">=1.1.0" + } + }, "node_modules/@asciidoctor/tabs": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/@asciidoctor/tabs/-/tabs-1.0.0-beta.2.tgz", @@ -388,6 +405,17 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.23.1", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.1.tgz", + "integrity": "sha512-hC2v6p8ZSI/W0HUzh3V8C5g+NwSKzKPtJwSpTjwl0o297GP9+ZLQSkdvHz46CM3LqyoXxq+5G9komY+eSqSO0g==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.20.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz", @@ -1125,6 +1153,45 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asciidoctor-opal-runtime": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/asciidoctor-opal-runtime/-/asciidoctor-opal-runtime-0.3.3.tgz", + "integrity": "sha512-/CEVNiOia8E5BMO9FLooo+Kv18K4+4JBFRJp8vUy/N5dMRAg+fRNV4HA+o6aoSC79jVU/aT5XvUpxSxSsTS8FQ==", + "dev": true, + "dependencies": { + "glob": "7.1.3", + "unxhr": "1.0.1" + }, + "engines": { + "node": ">=8.11" + } + }, + "node_modules/asciidoctor-opal-runtime/node_modules/glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/asciinema-player": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/asciinema-player/-/asciinema-player-3.6.1.tgz", + "integrity": "sha512-FfTABH/N6pjG74A6cCfsrirTSM4UAOLMzcFXb0zS34T5czvg3CyUy2TAqa3WEs5owUFHcuN1Y2y8o0n2yjeMvQ==", + "dependencies": { + "@babel/runtime": "^7.21.0", + "solid-js": "^1.3.0" + } + }, "node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -1685,6 +1752,11 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -4957,6 +5029,11 @@ "node": ">=8.10.0" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", + "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" + }, "node_modules/regexp.prototype.flags": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", @@ -5159,6 +5236,14 @@ "randombytes": "^2.1.0" } }, + "node_modules/seroval": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-0.5.1.tgz", + "integrity": "sha512-ZfhQVB59hmIauJG5Ydynupy8KHyr5imGNtdDhbZG68Ufh1Ynkv9KOYOAABf71oVbQxJ8VkWnMHAjEHE7fWkH5g==", + "engines": { + "node": ">=10" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -5267,6 +5352,15 @@ "node": ">=8" } }, + "node_modules/solid-js": { + "version": "1.7.12", + "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.7.12.tgz", + "integrity": "sha512-QoyoOUKu14iLoGxjxWFIU8+/1kLT4edQ7mZESFPonsEXZ//VJtPKD8Ud1aTKzotj+MNWmSs9YzK6TdY+fO9Eww==", + "dependencies": { + "csstype": "^3.1.0", + "seroval": "^0.5.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -5660,6 +5754,15 @@ "through": "^2.3.8" } }, + "node_modules/unxhr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/unxhr/-/unxhr-1.0.1.tgz", + "integrity": "sha512-MAhukhVHyaLGDjyDYhy8gVjWJyhTECCdNsLwlMoGFoNJ3o79fpQhtQuzmAE4IxCMDwraF4cW8ZjpAV0m9CRQbg==", + "dev": true, + "engines": { + "node": ">=8.11" + } + }, "node_modules/update-browserslist-db": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz", diff --git a/package.json b/package.json index b4432d5..b7da947 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "./static-page-extension": "./lib/static-page-extension.js", "./cache-scandir": "./lib/cache-scandir/index.js", "./static-pages/search": "./lib/static/search.adoc", - "./static-pages/spring-projects": "./lib/static/spring-projects.adoc" + "./static-pages/spring-projects": "./lib/static/spring-projects.adoc", + "./asciinema-extension": "./lib/asciinema-extension.js" }, "imports": { "#package": "./package.json" @@ -53,10 +54,12 @@ "archiver": "^5.3.1", "decompress": "4.2.1", "fast-xml-parser": "latest", - "handlebars": "latest" + "handlebars": "latest", + "asciinema-player": "^3.6.1" }, "devDependencies": { "@asciidoctor/tabs": "latest", + "@asciidoctor/core": "latest", "chai": "~4.3", "chai-fs": "~2.0", "chai-spies": "~1.0", diff --git a/test/asciinema-extension-test.js b/test/asciinema-extension-test.js new file mode 100644 index 0000000..36c395c --- /dev/null +++ b/test/asciinema-extension-test.js @@ -0,0 +1,141 @@ +/* eslint-env mocha */ +'use strict' + +const { expect, heredoc } = require('./harness') +const { name: packageName } = require('#package') +const Asciidoctor = require('@asciidoctor/core') +const asciidoctor = Asciidoctor() + +describe('asciinema-extension', () => { + const ext = require(packageName + '/asciinema-extension') + + let generatorContext + let playbook + let siteAsciiDocConfig + let uiCatalog + let contentCatalog + + const addPage = (contents, publishable = true) => { + contents = Buffer.from(contents) + const mediaType = 'text/asciidoc' + const page = publishable ? { contents, mediaType, out: {} } : { contents, mediaType } + contentCatalog.pages.push(page) + return page + } + + const createSiteAsciiDocConfig = () => ({ + extensions: [], + }) + + const createUiCatalog = () => ({ + files: [], + addFile (f) { + this.files.push(f) + }, + findByType (f) { + return [] + }, + }) + + const createContentCatalog = () => ({ + pages: [], + partials: [], + getPages (filter) { + return filter ? this.pages.filter(filter) : this.pages.slice() + }, + findBy () { + return this.partials + }, + }) + + const createGeneratorContext = () => ({ + messages: [], + variables: {}, + once (eventName, fn) { + this[eventName] = fn + }, + on (eventName, fn) { + this[eventName] = fn + }, + require, + getLogger (name) { + const messages = this.messages + const appendMessage = function (message) { + messages.push(message) + } + return { + info: appendMessage, + debug: appendMessage, + } + }, + updateVariables (updates) { + Object.assign(this.variables, updates) + }, + }) + + beforeEach(() => { + generatorContext = createGeneratorContext() + playbook = { + env: {}, + ui: { + outputDir: 'out', + }, + } + siteAsciiDocConfig = createSiteAsciiDocConfig() + contentCatalog = createContentCatalog() + uiCatalog = createUiCatalog() + }) + + describe('bootstrap', () => { + it('should be able to require extension', () => { + expect(ext).to.be.instanceOf(Object) + expect(ext.register).to.be.instanceOf(Function) + }) + + it('should be able to call register function exported by extension', () => { + ext.register.call(generatorContext, { config: {} }) + expect(generatorContext.uiLoaded).to.be.instanceOf(Function) + expect(generatorContext.contentClassified).to.be.instanceOf(Function) + }) + + it('should fail with unknown option', () => { + expect(() => ext.register.call(generatorContext, { config: { foo: 'bar' } })).to.throws( + 'Unrecognized option specified for @springio/antora-extensions: foo' + ) + expect(generatorContext.messages).to.eql([]) + }) + }) + + describe('uiLoaded', () => { + it('should add js, css, helpers and templates', async () => { + ext.register.call(generatorContext, { config: {} }) + generatorContext.updateVariables({ contentCatalog, playbook, siteAsciiDocConfig, uiCatalog }) + await generatorContext.uiLoaded(generatorContext.variables) + expect(uiCatalog.files.length).to.equal(7) + }) + }) + + describe('contentClassified', () => { + it('should migrate asciinema block', async () => { + const input = heredoc` + [asciinema] + ---- + foobar + ---- + ` + addPage(input) + + ext.register.call(generatorContext, { config: {} }) + generatorContext.updateVariables({ contentCatalog, siteAsciiDocConfig, uiCatalog }) + await generatorContext.contentClassified(generatorContext.variables) + + const registry = asciidoctor.Extensions.create() + siteAsciiDocConfig.extensions[0].register.call({}, registry, { file: { asciidoc: { attributes: {} } } }) + const out = asciidoctor.convert(input, { extension_registry: registry }) + + expect(siteAsciiDocConfig.extensions.length).to.equal(1) + expect(uiCatalog.files.length).to.equal(1) + expect(out).to.contains('video') + }) + }) +})