Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/format/reveal/format-reveal-plugin.ts
6451 views
1
/*
2
* format-reveal-plugin.ts
3
*
4
* Copyright (C) 2021-2022 Posit Software, PBC
5
*/
6
7
import { ensureDirSync, existsSync } from "../../deno_ral/fs.ts";
8
import { basename, join } from "../../deno_ral/path.ts";
9
import { kIncludeInHeader, kSelfContained } from "../../config/constants.ts";
10
11
import { error } from "../../deno_ral/log.ts";
12
13
import {
14
Format,
15
FormatDependency,
16
FormatExtras,
17
kDependencies,
18
Metadata,
19
PandocFlags,
20
} from "../../config/types.ts";
21
import { camelToKebab, mergeConfigs } from "../../core/config.ts";
22
import { pathWithForwardSlashes } from "../../core/path.ts";
23
import { formatResourcePath } from "../../core/resources.ts";
24
import { TempContext } from "../../core/temp.ts";
25
import { optionsToKebab, revealMetadataFilter } from "./metadata.ts";
26
import { revealMultiplexPlugin } from "./format-reveal-multiplex.ts";
27
import { isSelfContained } from "../../command/render/render-info.ts";
28
29
import { readAndValidateYamlFromFile } from "../../core/schema/validated-yaml.ts";
30
31
import { revealPluginSchema } from "./schemas.ts";
32
import { copyMinimal } from "../../core/copy.ts";
33
import { kRevealJSPlugins } from "../../extension/constants.ts";
34
import { ExtensionContext } from "../../extension/types.ts";
35
import { ProjectContext } from "../../project/types.ts";
36
import { filterExtensions } from "../../extension/extension.ts";
37
import {
38
RevealPlugin,
39
RevealPluginBundle,
40
RevealPluginScript,
41
} from "./format-reveal-plugin-types.ts";
42
43
const kRevealjsPlugins = "revealjs-plugins";
44
45
const kRevealSlideTone = "slide-tone";
46
const kRevealMenu = "menu";
47
const kRevealChalkboard = "chalkboard";
48
49
const kRevealPluginOptions = [
50
// reveal.js-menu
51
"side",
52
"width",
53
"numbers",
54
"titleSelector",
55
"useTextContentForMissingTitles",
56
"hideMissingTitles",
57
"markers",
58
"custom",
59
"themes",
60
"themesPath",
61
"transitions",
62
"openButton",
63
"openSlideNumber",
64
"keyboard",
65
"sticky",
66
"autoOpen",
67
"delayInit",
68
"openOnInit",
69
"loadIcons",
70
// reveal.js-chalkboard
71
"boardmarkerWidth",
72
"chalkWidth",
73
"chalkEffect",
74
"storage",
75
"src",
76
"readOnly",
77
"transition",
78
"theme",
79
"background",
80
"grid",
81
"eraser",
82
"boardmarkers",
83
"chalks",
84
"rememberColor",
85
// reveal-pdfexport
86
"pdfExportShortcut",
87
];
88
89
const kRevealPluginKebabOptions = optionsToKebab(kRevealPluginOptions);
90
91
export function isPluginBundle(
92
plugin: RevealPluginBundle | RevealPlugin,
93
): plugin is RevealPluginBundle {
94
return (plugin as RevealPluginBundle).plugin !== undefined;
95
}
96
97
export async function revealPluginExtras(
98
input: string,
99
format: Format,
100
flags: PandocFlags,
101
temp: TempContext,
102
revealUrl: string,
103
revealDestDir: string,
104
extensionContext?: ExtensionContext,
105
project?: ProjectContext,
106
) {
107
// directory to copy plugins into
108
109
const pluginsDestDir = join(revealDestDir, "plugin");
110
111
// accumlate content to inject
112
const register: string[] = [];
113
const scripts: RevealPluginScript[] = [];
114
const stylesheets: string[] = [];
115
const config: Metadata = {};
116
const metadata: string[] = [];
117
const dependencies: FormatDependency[] = [];
118
119
// built-in plugins
120
const pluginBundles: Array<RevealPlugin | RevealPluginBundle | string> = [
121
{
122
plugin: formatResourcePath("revealjs", join("plugins", "line-highlight")),
123
},
124
{ plugin: formatResourcePath("revealjs", join("plugins", "pdfexport")) },
125
];
126
127
// menu plugin (enabled by default)
128
const menuPlugin = revealMenuPlugin(format);
129
if (menuPlugin) {
130
pluginBundles.push(menuPlugin);
131
}
132
133
// chalkboard plugin (optional)
134
const chalkboardPlugiln = revealChalkboardPlugin(format);
135
if (chalkboardPlugiln) {
136
pluginBundles.push(chalkboardPlugiln);
137
}
138
139
// tone plugin (optional)
140
const tonePlugin = revealTonePlugin(format);
141
if (tonePlugin) {
142
dependencies.push(toneDependency());
143
pluginBundles.push(tonePlugin);
144
}
145
146
// multiplex plugin (optional)
147
const multiplexPlugin = revealMultiplexPlugin(format);
148
if (multiplexPlugin) {
149
pluginBundles.push(multiplexPlugin);
150
}
151
152
const resolvePluginPath = async (plugin: string) => {
153
// Look for an extension
154
let extensions = await extensionContext?.find(
155
plugin,
156
input,
157
kRevealJSPlugins,
158
project?.config,
159
project?.dir,
160
) || [];
161
162
// Filter the extensions
163
extensions = filterExtensions(
164
extensions || [],
165
plugin,
166
"revealjs-plugins",
167
);
168
169
// Return any contributed plugins
170
if (extensions.length > 0) {
171
return extensions[0].contributes[kRevealJSPlugins] || [];
172
} else {
173
return [plugin];
174
}
175
};
176
177
const resolvePlugin = async (
178
plugin: string | RevealPluginBundle | RevealPlugin,
179
) => {
180
if (typeof plugin === "string") {
181
// This is just a simple path
182
// If the path can be resolved to a file on disk then
183
// don't treat it as an extension
184
if (existsSync(plugin)) {
185
return [plugin];
186
} else {
187
return await resolvePluginPath(plugin);
188
}
189
} else {
190
if (isPluginBundle(plugin)) {
191
// This is a plugin bundle, so try to resolve that
192
const path = plugin.plugin;
193
const resolvedPlugins = await resolvePluginPath(path);
194
195
const pluginBundles = resolvedPlugins.map(
196
(resolvedPlug): RevealPluginBundle => {
197
if (typeof resolvedPlug === "string") {
198
return {
199
plugin: resolvedPlug,
200
config: plugin.config,
201
};
202
} else if (isPluginBundle(resolvedPlug)) {
203
return {
204
plugin: resolvedPlug.plugin,
205
config: mergeConfigs(
206
plugin.config,
207
resolvedPlug.config,
208
),
209
};
210
} else {
211
return plugin;
212
}
213
},
214
);
215
return pluginBundles;
216
} else {
217
return Promise.resolve([plugin]);
218
}
219
}
220
};
221
222
if (Array.isArray(format.metadata[kRevealjsPlugins])) {
223
for (const plugin of format.metadata[kRevealJSPlugins]) {
224
const resolvedPlugins = await resolvePlugin(plugin);
225
pluginBundles.push(...resolvedPlugins);
226
}
227
}
228
229
// add general support plugin (after others so it can rely on their init)
230
pluginBundles.push(
231
{ plugin: formatResourcePath("revealjs", join("plugins", "support")) },
232
);
233
234
// read plugins
235
for (let bundle of pluginBundles) {
236
// convert string to plugin
237
if (typeof bundle === "string") {
238
bundle = {
239
plugin: bundle,
240
};
241
}
242
243
// read from bundle
244
const plugin = isPluginBundle(bundle)
245
? await pluginFromBundle(bundle)
246
: bundle;
247
248
// check for self-contained incompatibility
249
if (isSelfContained(flags, format)) {
250
if (plugin[kSelfContained] === false) {
251
throw new Error(
252
"Reveal plugin '" + plugin.name +
253
" is not compatible with self-contained output",
254
);
255
}
256
}
257
258
// note name
259
if (plugin.register !== false) {
260
register.push(plugin.name);
261
}
262
263
// copy plugin (plugin dir uses a kebab-case version of name)
264
const pluginUrl = pathWithForwardSlashes(
265
join(revealUrl, "plugin", camelToKebab(plugin.name)),
266
);
267
const pluginDir = join(pluginsDestDir, camelToKebab(plugin.name));
268
if (isPluginBundle(bundle)) {
269
copyMinimal(bundle.plugin, pluginDir);
270
} else {
271
ensureDirSync(pluginDir);
272
plugin.script?.forEach((script) => {
273
Deno.copyFileSync(
274
join(plugin.path, script.path),
275
join(pluginDir, basename(script.path)),
276
);
277
});
278
plugin.stylesheet?.forEach((style) => {
279
Deno.copyFileSync(
280
join(plugin.path, style),
281
join(pluginDir, basename(style)),
282
);
283
});
284
}
285
286
// note scripts
287
if (plugin.script) {
288
for (const script of plugin.script) {
289
script.path = pathWithForwardSlashes(join(pluginUrl, script.path));
290
scripts.push(script);
291
}
292
}
293
294
// note stylesheet
295
if (plugin.stylesheet) {
296
for (const stylesheet of plugin.stylesheet) {
297
const pluginStylesheet = pathWithForwardSlashes(
298
join(pluginUrl, stylesheet),
299
);
300
stylesheets.push(pathWithForwardSlashes(pluginStylesheet));
301
}
302
}
303
304
// add to config
305
if (plugin.config) {
306
for (const key of Object.keys(plugin.config)) {
307
const kebabKey = camelToKebab(key);
308
if (typeof (plugin.config[key]) === "object") {
309
config[key] = plugin.config[key];
310
311
// see if the user has yaml to merge
312
if (typeof (format.metadata[kebabKey]) === "object") {
313
config[key] = mergeConfigs(
314
revealMetadataFilter(
315
config[key] as Metadata,
316
kRevealPluginKebabOptions,
317
),
318
revealMetadataFilter(
319
format.metadata[kebabKey] as Metadata,
320
kRevealPluginKebabOptions,
321
),
322
);
323
}
324
} else {
325
config[key] = plugin.config[key];
326
if (format.metadata[key] !== undefined) {
327
config[key] = format.metadata[key];
328
}
329
}
330
}
331
}
332
333
// note metadata we should forward into reveal config
334
if (plugin.metadata) {
335
metadata.push(...plugin.metadata);
336
}
337
}
338
339
// inject them into extras
340
const extras: FormatExtras = {
341
[kIncludeInHeader]: [],
342
html: {
343
[kDependencies]: dependencies,
344
},
345
};
346
347
// link tags for stylesheets
348
const linkTags = stylesheets.map((file) => {
349
return `<link href="${file}" rel="stylesheet">`;
350
}).join("\n");
351
const linkTagsInclude = temp.createFile({ suffix: ".html" });
352
Deno.writeTextFileSync(linkTagsInclude, linkTags);
353
extras[kIncludeInHeader]?.push(linkTagsInclude);
354
355
// inject top level options used by plugins into config
356
metadata.forEach((option) => {
357
if (format.metadata[option] !== undefined) {
358
config[option] = format.metadata[option];
359
}
360
});
361
362
const result = {
363
pluginInit: {
364
scripts,
365
register,
366
revealConfig: config,
367
},
368
extras,
369
};
370
371
// return
372
return result;
373
}
374
375
function revealMenuPlugin(format: Format) {
376
return {
377
plugin: formatResourcePath("revealjs", join("plugins", "menu")),
378
config: {
379
menu: {
380
custom: [{
381
title: "Tools",
382
icon: '<i class="fas fa-gear"></i>',
383
content: revealMenuTools(format),
384
}],
385
openButton: format.metadata[kRevealMenu] !== false,
386
},
387
},
388
};
389
}
390
391
function revealChalkboardPlugin(format: Format) {
392
if (format.metadata[kRevealChalkboard]) {
393
return {
394
plugin: formatResourcePath("revealjs", join("plugins", "chalkboard")),
395
};
396
} else {
397
return undefined;
398
}
399
}
400
401
function revealMenuTools(format: Format) {
402
const tools = [
403
{
404
title: "Fullscreen",
405
key: "f",
406
handler: "fullscreen",
407
},
408
{
409
title: "Speaker View",
410
key: "s",
411
handler: "speakerMode",
412
},
413
{
414
title: "Slide Overview",
415
key: "o",
416
handler: "overview",
417
},
418
{
419
title: "PDF Export Mode",
420
key: "e",
421
handler: "togglePdfExport",
422
},
423
{
424
title: "Scroll View Mode",
425
key: "r",
426
handler: "toggleScrollView",
427
},
428
];
429
if (format.metadata[kRevealChalkboard]) {
430
tools.push(
431
{
432
title: "Toggle Chalkboard",
433
key: "b",
434
handler: "toggleChalkboard",
435
},
436
{
437
title: "Toggle Notes Canvas",
438
key: "c",
439
handler: "toggleNotesCanvas",
440
},
441
{
442
title: "Download Drawings",
443
key: "d",
444
handler: "downloadDrawings",
445
},
446
);
447
}
448
tools.push({
449
title: "Keyboard Help",
450
key: "?",
451
handler: "keyboardHelp",
452
});
453
const lines = ['<ul class="slide-menu-items">'];
454
lines.push(...tools.map((tool, index) => {
455
return `<li class="slide-tool-item${
456
index === 0 ? " active" : ""
457
}" data-item="${index}"><a href="#" onclick="RevealMenuToolHandlers.${tool.handler}(event)"><kbd>${
458
tool
459
.key || " "
460
}</kbd> ${tool.title}</a></li>`;
461
}));
462
463
lines.push("</ul>");
464
return lines.join("\n");
465
}
466
467
function revealTonePlugin(format: Format) {
468
if (format.metadata[kRevealSlideTone]) {
469
return { plugin: formatResourcePath("revealjs", join("plugins", "tone")) };
470
} else {
471
return undefined;
472
}
473
}
474
475
function toneDependency() {
476
const dependency: FormatDependency = {
477
name: "tone",
478
scripts: [{
479
name: "tone.js",
480
path: formatResourcePath("revealjs", join("tone", "tone.js")),
481
}],
482
};
483
return dependency;
484
}
485
486
async function pluginFromBundle(
487
bundle: RevealPluginBundle,
488
): Promise<RevealPlugin> {
489
// confirm it's a directory
490
if (!existsSync(bundle.plugin) || !Deno.statSync(bundle.plugin).isDirectory) {
491
throw new Error(
492
"Specified Reveal plugin directory '" + bundle.plugin +
493
"' does not exist.",
494
);
495
}
496
497
let plugin;
498
499
try {
500
// read the plugin definition (and provide the path)
501
plugin = (await readAndValidateYamlFromFile(
502
join(bundle.plugin, "plugin.yml"),
503
revealPluginSchema,
504
"Validation of reveal plugin object failed.",
505
)) as RevealPlugin;
506
plugin.path = bundle.plugin;
507
} catch (e) {
508
error(
509
`Validation of plugin configuration ${
510
join(bundle.plugin, "plugin.yml")
511
} failed.`,
512
);
513
throw e;
514
}
515
516
// convert script and stylesheet to arrays
517
if (plugin.script && !Array.isArray(plugin.script)) {
518
plugin.script = [plugin.script];
519
}
520
plugin.script = plugin.script?.map((script) => {
521
if (typeof script === "string") {
522
return {
523
path: script,
524
};
525
} else {
526
return script;
527
}
528
});
529
530
if (plugin.stylesheet && !Array.isArray(plugin.stylesheet)) {
531
plugin.stylesheet = [plugin.stylesheet];
532
}
533
plugin.stylesheet = plugin.stylesheet?.map((stylesheet) =>
534
String(stylesheet)
535
);
536
537
// validate plugin
538
validatePlugin(plugin);
539
540
// merge user config into plugin config
541
if (typeof (bundle.config) === "object") {
542
plugin.config = mergeConfigs(
543
plugin.config || {} as Metadata,
544
bundle.config || {} as Metadata,
545
);
546
}
547
548
// ensure that metadata is an array
549
if (typeof (plugin.metadata) === "string") {
550
plugin.metadata = [plugin.metadata];
551
}
552
553
// return plugin
554
return plugin;
555
}
556
557
function validatePlugin(plugin: RevealPlugin) {
558
if (typeof (plugin.name) !== "string") {
559
throw new Error("Reveal plugin definition must include a name.");
560
}
561
if (!Array.isArray(plugin.script)) {
562
throw new Error("Reveal plugin definition must include a script.");
563
}
564
for (const script of plugin.script) {
565
if (!existsSync(join(plugin.path, script.path))) {
566
throw new Error(
567
"Reveal plugin script '" + script + "' not found.",
568
);
569
}
570
}
571
572
if (plugin.stylesheet) {
573
for (const stylesheet of plugin.stylesheet) {
574
if (!existsSync(join(plugin.path, stylesheet))) {
575
throw new Error(
576
"Reveal plugin stylesheet '" + stylesheet + "' not found.",
577
);
578
}
579
}
580
}
581
if (plugin.config) {
582
if (
583
typeof (plugin.config) !== "object"
584
) {
585
throw new Error(
586
"Reveal plugin config must be an object.",
587
);
588
}
589
}
590
}
591
592