Skip to content

Commit

Permalink
Add asciinema extension
Browse files Browse the repository at this point in the history
- Fixes #13
  • Loading branch information
jvalkeal committed Oct 13, 2023
1 parent b29016a commit 8d09e17
Show file tree
Hide file tree
Showing 14 changed files with 641 additions and 2 deletions.
3 changes: 3 additions & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,8 @@
"prefer-regex-literals": "off",
"spaced-comment": "off",
"radix": ["error", "always"]
},
"globals": {
"Opal": true
}
}
62 changes: 62 additions & 0 deletions README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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]
....
<cast file>
....
----

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

Expand Down
255 changes: 255 additions & 0 deletions lib/asciinema-extension.js
Original file line number Diff line number Diff line change
@@ -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 ? `<div class="title">${block.caption}${title}</div>` : ''
const style = `${Object.hasOwn(attrs, 'width') ? `width: ${attrs.width}px;` : ''} ${
Object.hasOwn(attrs, 'height') ? `height: ${attrs.height}px;` : ''
}`
block.lines = [
`<div${idAttr} class="${classAttr}">`,
`<div class="content"><div id="${asciinemaId}" style="${style}"></div></div>`,
`${titleElement}</div>`,
]
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 }
3 changes: 3 additions & 0 deletions lib/asciinema/asciinema-create.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{{#each (asciinema-split page.attributes.asciinemacasts)}}
<script>AsciinemaPlayer.create('{{{@root.siteRootPath}}}/_asciinema/{{this}}.cast', document.getElementById('{{this}}'), {{{asciinema-options this}}})</script>
{{/each}}
1 change: 1 addition & 0 deletions lib/asciinema/asciinema-load.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<script src="{{{uiRootPath}}}/js/vendor/asciinema-player.js"></script>
17 changes: 17 additions & 0 deletions lib/asciinema/asciinema-options-helper.js
Original file line number Diff line number Diff line change
@@ -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 '{}'
}
}
7 changes: 7 additions & 0 deletions lib/asciinema/asciinema-split-helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use strict'

module.exports = (ids) => {
if (ids) {
return ids.split(',')
}
}
1 change: 1 addition & 0 deletions lib/asciinema/asciinema-styles.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<link rel="stylesheet" href="{{{uiRootPath}}}/css/vendor/asciinema-player.css">
16 changes: 16 additions & 0 deletions lib/lazy-readable.js
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 8d09e17

Please sign in to comment.