Skip to content

Commit 8d09e17

Browse files
committed
Add asciinema extension
- Fixes #13
1 parent b29016a commit 8d09e17

14 files changed

+641
-2
lines changed

.eslintrc

+3
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,8 @@
1818
"prefer-regex-literals": "off",
1919
"spaced-comment": "off",
2020
"radix": ["error", "always"]
21+
},
22+
"globals": {
23+
"Opal": true
2124
}
2225
}

README.adoc

+62
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,68 @@ IMPORTANT: Be sure to register this extension under the `antora.extensions` key
287287

288288
This extension adds shared pages that are picked up by the antora-ui-spring project.
289289

290+
=== Asciinema
291+
292+
*require name:* @springio/antora-extensions/asciinema-extension
293+
294+
IMPORTANT: Be sure to register this extension under the `antora.extensions` key in the playbook, not the `asciidoc.extensions` key!
295+
296+
NOTE: Using this extension will need a little help from an
297+
UI bundle as it is expected that named partials `asciinema-load-scripts`,
298+
`asciinema-create-scripts` and `asciinema-styles` are included in a same
299+
locations where javascript and styles are loaded. Extension will add these
300+
partials if those don't already exist in an UI bundle.
301+
302+
The purpose of this extension is to convert asciidoc block type _asciinema_ into an asciinema-player. Expected content is plain
303+
cast file which is automatically extracted and packaged with
304+
into antora assets and configured with player instances.
305+
306+
[source,text]
307+
----
308+
[asciinema]
309+
....
310+
{"version": 2, "width": 80, "height": 24}
311+
[1.0, "o", "hello "]
312+
[2.0, "o", "world!"]
313+
....
314+
----
315+
316+
TIP: You don't need to inline cast file as it can also come
317+
via asciidoc include macro.
318+
319+
The extension accepts several configuration options as defined in
320+
https://github.com/asciinema/asciinema-player#options.
321+
322+
rows::
323+
Optional attribute as a default value for asciinema option `rows`.
324+
325+
cols::
326+
Optional attribute as a default value for asciinema option `cols`.
327+
328+
auto_play::
329+
Optional attribute as a default value for asciinema option `autoPlay`.
330+
331+
The block type accepts several configuration options. Block type options will override
332+
options from an extension level. Not a difference between snake_case and camelCase.
333+
For example:
334+
335+
[source,text]
336+
----
337+
[asciinema,rows=10,autoPlay=true]
338+
....
339+
<cast file>
340+
....
341+
----
342+
343+
rows::
344+
Optional attribute as a default value for asciinema option `rows`.
345+
346+
cols::
347+
Optional attribute as a default value for asciinema option `cols`.
348+
349+
autoPlay::
350+
Optional attribute as a default value for asciinema option `autoPlay`.
351+
290352
ifndef::env-npm[]
291353
== Development Quickstart
292354

lib/asciinema-extension.js

+255
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
'use strict'
2+
3+
const { name: packageName } = require('../package.json')
4+
const fs = require('fs')
5+
const crypto = require('crypto')
6+
const { promises: fsp } = fs
7+
const LazyReadable = require('./lazy-readable')
8+
const MultiFileReadStream = require('./multi-file-read-stream')
9+
const ospath = require('path')
10+
const template = require('./template')
11+
12+
function register ({ config: { rows, cols, autoPlay, ...unknownOptions } }) {
13+
const logger = this.getLogger(packageName)
14+
15+
if (Object.keys(unknownOptions).length) {
16+
const keys = Object.keys(unknownOptions)
17+
throw new Error(`Unrecognized option${keys.length > 1 ? 's' : ''} specified for ${packageName}: ${keys.join(', ')}`)
18+
}
19+
20+
const defaultOptions = { rows, cols, autoPlay }
21+
22+
this.on('uiLoaded', async ({ playbook, uiCatalog }) => {
23+
playbook.env.SITE_ASCIINEMA_PROVIDER = 'asciinema'
24+
const asciinemaDir = 'asciinema'
25+
const uiOutputDir = playbook.ui.outputDir
26+
vendorJsFile(
27+
uiCatalog,
28+
logger,
29+
uiOutputDir,
30+
'asciinema-player/dist/bundle/asciinema-player.min.js',
31+
'asciinema-player.js'
32+
)
33+
vendorCssFile(
34+
uiCatalog,
35+
logger,
36+
uiOutputDir,
37+
'asciinema-player/dist/bundle/asciinema-player.css',
38+
'asciinema-player.css'
39+
)
40+
41+
const asciinemaLoadScriptsPartialPath = 'asciinema-load.hbs'
42+
if (!uiCatalog.findByType('partial').some(({ path }) => path === asciinemaLoadScriptsPartialPath)) {
43+
const asciinemaLoadScriptsPartialFilepath = ospath.join(__dirname, asciinemaDir, asciinemaLoadScriptsPartialPath)
44+
uiCatalog.addFile({
45+
contents: Buffer.from(template(await fsp.readFile(asciinemaLoadScriptsPartialFilepath, 'utf8'), {})),
46+
path: asciinemaLoadScriptsPartialPath,
47+
stem: 'asciinema-load-scripts',
48+
type: 'partial',
49+
})
50+
}
51+
52+
const asciinemaCreateScriptsPartialPath = 'asciinema-create.hbs'
53+
if (!uiCatalog.findByType('partial').some(({ path }) => path === asciinemaCreateScriptsPartialPath)) {
54+
const asciinemaCreateScriptsPartialFilepath = ospath.join(
55+
__dirname,
56+
asciinemaDir,
57+
asciinemaCreateScriptsPartialPath
58+
)
59+
uiCatalog.addFile({
60+
contents: Buffer.from(template(await fsp.readFile(asciinemaCreateScriptsPartialFilepath, 'utf8'), {})),
61+
path: asciinemaCreateScriptsPartialPath,
62+
stem: 'asciinema-create-scripts',
63+
type: 'partial',
64+
})
65+
}
66+
67+
const asciinemaStylesPartialPath = 'asciinema-styles.hbs'
68+
if (!uiCatalog.findByType('partial').some(({ path }) => path === asciinemaStylesPartialPath)) {
69+
const asciinemaStylesPartialFilepath = ospath.join(__dirname, asciinemaDir, asciinemaStylesPartialPath)
70+
uiCatalog.addFile({
71+
contents: Buffer.from(template(await fsp.readFile(asciinemaStylesPartialFilepath, 'utf8'), {})),
72+
path: asciinemaStylesPartialPath,
73+
stem: 'asciinema-styles',
74+
type: 'partial',
75+
})
76+
}
77+
78+
const splitHelperPartialPath = 'asciinema-split-helper.js'
79+
const splitHelperPartialFilepath = ospath.join(__dirname, asciinemaDir, splitHelperPartialPath)
80+
uiCatalog.addFile({
81+
contents: Buffer.from(template(await fsp.readFile(splitHelperPartialFilepath, 'utf8'), {})),
82+
path: 'helpers/' + splitHelperPartialPath,
83+
stem: 'asciinema-split',
84+
type: 'helper',
85+
})
86+
87+
const optionsHelperPartialPath = 'asciinema-options-helper.js'
88+
const optionsHelperPartialFilepath = ospath.join(__dirname, asciinemaDir, optionsHelperPartialPath)
89+
uiCatalog.addFile({
90+
contents: Buffer.from(template(await fsp.readFile(optionsHelperPartialFilepath, 'utf8'), {})),
91+
path: 'helpers/' + optionsHelperPartialPath,
92+
stem: 'asciinema-options',
93+
type: 'helper',
94+
})
95+
})
96+
97+
this.on('contentClassified', async ({ siteAsciiDocConfig, uiCatalog }) => {
98+
if (!siteAsciiDocConfig.extensions) siteAsciiDocConfig.extensions = []
99+
siteAsciiDocConfig.extensions.push({
100+
register: (registry, _context) => {
101+
registry.block('asciinema', processAsciinemaBlock(uiCatalog, defaultOptions, _context))
102+
return registry
103+
},
104+
})
105+
})
106+
}
107+
108+
function processAsciinemaBlock (uiCatalog, defaultOptions, context) {
109+
return function () {
110+
this.onContext(['listing', 'literal'])
111+
this.positionalAttributes(['target', 'format'])
112+
this.process((parent, reader, attrs) => {
113+
const { file } = context
114+
const source = reader.getLines().join('\n')
115+
return toBlock(attrs, parent, source, this, uiCatalog, defaultOptions, file)
116+
})
117+
}
118+
}
119+
120+
const fromHash = (hash) => {
121+
const object = {}
122+
const data = hash.$$smap
123+
for (const key in data) {
124+
object[key] = data[key]
125+
}
126+
return object
127+
}
128+
129+
const toBlock = (attrs, parent, source, context, uiCatalog, defaultOptions, file) => {
130+
if (typeof attrs === 'object' && '$$smap' in attrs) {
131+
attrs = fromHash(attrs)
132+
}
133+
const doc = parent.getDocument()
134+
const subs = attrs.subs
135+
if (subs) {
136+
source = doc.$apply_subs(attrs.subs, doc.$resolve_subs(subs))
137+
}
138+
const idAttr = attrs.id ? ` id="${attrs.id}"` : ''
139+
const classAttr = attrs.role ? `${attrs.role} videoblock` : 'videoblock'
140+
141+
const block = context.$create_pass_block(parent, '', Opal.hash(attrs))
142+
143+
const title = attrs.title
144+
if (title) {
145+
block.title = title
146+
delete block.caption
147+
const caption = attrs.caption
148+
delete attrs.caption
149+
block.assignCaption(caption, 'figure')
150+
}
151+
152+
const asciinemaId = crypto.createHash('md5').update(source, 'utf8').digest('hex')
153+
if (file.asciidoc.attributes['page-asciinemacasts']) {
154+
file.asciidoc.attributes['page-asciinemacasts'] =
155+
file.asciidoc.attributes['page-asciinemacasts'] + ',' + asciinemaId
156+
} else {
157+
file.asciidoc.attributes['page-asciinemacasts'] = asciinemaId
158+
}
159+
160+
uiCatalog.addFile({
161+
contents: Buffer.from(source),
162+
path: '_asciinema/' + asciinemaId + '.cast',
163+
type: 'asset',
164+
out: { path: '_asciinema/' + asciinemaId + '.cast' },
165+
})
166+
167+
const asciinemaOptions = JSON.stringify(buildOptions(attrs, defaultOptions))
168+
file.asciidoc.attributes['page-asciinema-options-' + asciinemaId] = asciinemaOptions
169+
170+
const titleElement = title ? `<div class="title">${block.caption}${title}</div>` : ''
171+
const style = `${Object.hasOwn(attrs, 'width') ? `width: ${attrs.width}px;` : ''} ${
172+
Object.hasOwn(attrs, 'height') ? `height: ${attrs.height}px;` : ''
173+
}`
174+
block.lines = [
175+
`<div${idAttr} class="${classAttr}">`,
176+
`<div class="content"><div id="${asciinemaId}" style="${style}"></div></div>`,
177+
`${titleElement}</div>`,
178+
]
179+
return block
180+
}
181+
182+
function buildOptions (attrs, defaultOptions) {
183+
const options = {}
184+
const rows = attrs.rows ? attrs.rows : defaultOptions.rows
185+
if (rows) {
186+
options.rows = rows
187+
}
188+
const cols = attrs.cols ? attrs.cols : defaultOptions.cols
189+
if (cols) {
190+
options.cols = cols
191+
}
192+
const autoPlay = attrs.autoPlay ? attrs.autoPlay : defaultOptions.autoPlay
193+
if (autoPlay) {
194+
options.autoPlay = autoPlay
195+
}
196+
return options
197+
}
198+
199+
function assetFile (
200+
uiCatalog,
201+
logger,
202+
uiOutputDir,
203+
assetDir,
204+
basename,
205+
assetPath = assetDir + '/' + basename,
206+
contents = new LazyReadable(() => fs.createReadStream(ospath.join(__dirname, '../data', assetPath))),
207+
overwrite = false
208+
) {
209+
const outputDir = uiOutputDir + '/' + assetDir
210+
const existingFile = uiCatalog.findByType('asset').some(({ path }) => path === assetPath)
211+
if (existingFile) {
212+
if (overwrite) {
213+
logger.warn(`Please remove the following file from your UI since it is managed by ${packageName}: ${assetPath}`)
214+
existingFile.contents = contents
215+
delete existingFile.stat
216+
} else {
217+
logger.info(`The following file already exists in your UI: ${assetPath}, skipping`)
218+
}
219+
} else {
220+
uiCatalog.addFile({
221+
contents,
222+
type: 'asset',
223+
path: assetPath,
224+
out: { dirname: outputDir, path: outputDir + '/' + basename, basename },
225+
})
226+
}
227+
}
228+
229+
function vendorJsFile (uiCatalog, logger, uiOutputDir, requireRequest, basename = requireRequest.split('/').pop()) {
230+
let contents
231+
if (Array.isArray(requireRequest)) {
232+
const filepaths = requireRequest.map(require.resolve)
233+
contents = new LazyReadable(() => new MultiFileReadStream(filepaths))
234+
} else {
235+
const filepath = require.resolve(requireRequest)
236+
contents = new LazyReadable(() => fs.createReadStream(filepath))
237+
}
238+
const jsVendorDir = 'js/vendor'
239+
assetFile(uiCatalog, logger, uiOutputDir, jsVendorDir, basename, jsVendorDir + '/' + basename, contents)
240+
}
241+
242+
function vendorCssFile (uiCatalog, logger, uiOutputDir, requireRequest, basename = requireRequest.split('/').pop()) {
243+
let contents
244+
if (Array.isArray(requireRequest)) {
245+
const filepaths = requireRequest.map(require.resolve)
246+
contents = new LazyReadable(() => new MultiFileReadStream(filepaths))
247+
} else {
248+
const filepath = require.resolve(requireRequest)
249+
contents = new LazyReadable(() => fs.createReadStream(filepath))
250+
}
251+
const jsVendorDir = 'css/vendor'
252+
assetFile(uiCatalog, logger, uiOutputDir, jsVendorDir, basename, jsVendorDir + '/' + basename, contents)
253+
}
254+
255+
module.exports = { register }

lib/asciinema/asciinema-create.hbs

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{{#each (asciinema-split page.attributes.asciinemacasts)}}
2+
<script>AsciinemaPlayer.create('{{{@root.siteRootPath}}}/_asciinema/{{this}}.cast', document.getElementById('{{this}}'), {{{asciinema-options this}}})</script>
3+
{{/each}}

lib/asciinema/asciinema-load.hbs

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<script src="{{{uiRootPath}}}/js/vendor/asciinema-player.js"></script>
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
'use strict'
2+
3+
module.exports = (
4+
id,
5+
{
6+
data: {
7+
root: { page },
8+
},
9+
}
10+
) => {
11+
const raw = page.attributes['asciinema-options-' + id]
12+
if (raw) {
13+
return raw
14+
} else {
15+
return '{}'
16+
}
17+
}
+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
'use strict'
2+
3+
module.exports = (ids) => {
4+
if (ids) {
5+
return ids.split(',')
6+
}
7+
}

lib/asciinema/asciinema-styles.hbs

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<link rel="stylesheet" href="{{{uiRootPath}}}/css/vendor/asciinema-player.css">

lib/lazy-readable.js

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
const { PassThrough } = require('stream')
2+
3+
// adapted from https://github.com/jpommerening/node-lazystream/blob/master/lib/lazystream.js | license: MIT
4+
class LazyReadable extends PassThrough {
5+
constructor (fn, options) {
6+
super(options)
7+
this._read = function () {
8+
delete this._read // restores original method
9+
fn.call(this, options).on('error', this.emit.bind(this, 'error')).pipe(this)
10+
return this._read.apply(this, arguments)
11+
}
12+
this.emit('readable')
13+
}
14+
}
15+
16+
module.exports = LazyReadable

0 commit comments

Comments
 (0)