Path: blob/main/src/format/reveal/format-reveal-plugin.ts
6451 views
/*1* format-reveal-plugin.ts2*3* Copyright (C) 2021-2022 Posit Software, PBC4*/56import { ensureDirSync, existsSync } from "../../deno_ral/fs.ts";7import { basename, join } from "../../deno_ral/path.ts";8import { kIncludeInHeader, kSelfContained } from "../../config/constants.ts";910import { error } from "../../deno_ral/log.ts";1112import {13Format,14FormatDependency,15FormatExtras,16kDependencies,17Metadata,18PandocFlags,19} from "../../config/types.ts";20import { camelToKebab, mergeConfigs } from "../../core/config.ts";21import { pathWithForwardSlashes } from "../../core/path.ts";22import { formatResourcePath } from "../../core/resources.ts";23import { TempContext } from "../../core/temp.ts";24import { optionsToKebab, revealMetadataFilter } from "./metadata.ts";25import { revealMultiplexPlugin } from "./format-reveal-multiplex.ts";26import { isSelfContained } from "../../command/render/render-info.ts";2728import { readAndValidateYamlFromFile } from "../../core/schema/validated-yaml.ts";2930import { revealPluginSchema } from "./schemas.ts";31import { copyMinimal } from "../../core/copy.ts";32import { kRevealJSPlugins } from "../../extension/constants.ts";33import { ExtensionContext } from "../../extension/types.ts";34import { ProjectContext } from "../../project/types.ts";35import { filterExtensions } from "../../extension/extension.ts";36import {37RevealPlugin,38RevealPluginBundle,39RevealPluginScript,40} from "./format-reveal-plugin-types.ts";4142const kRevealjsPlugins = "revealjs-plugins";4344const kRevealSlideTone = "slide-tone";45const kRevealMenu = "menu";46const kRevealChalkboard = "chalkboard";4748const kRevealPluginOptions = [49// reveal.js-menu50"side",51"width",52"numbers",53"titleSelector",54"useTextContentForMissingTitles",55"hideMissingTitles",56"markers",57"custom",58"themes",59"themesPath",60"transitions",61"openButton",62"openSlideNumber",63"keyboard",64"sticky",65"autoOpen",66"delayInit",67"openOnInit",68"loadIcons",69// reveal.js-chalkboard70"boardmarkerWidth",71"chalkWidth",72"chalkEffect",73"storage",74"src",75"readOnly",76"transition",77"theme",78"background",79"grid",80"eraser",81"boardmarkers",82"chalks",83"rememberColor",84// reveal-pdfexport85"pdfExportShortcut",86];8788const kRevealPluginKebabOptions = optionsToKebab(kRevealPluginOptions);8990export function isPluginBundle(91plugin: RevealPluginBundle | RevealPlugin,92): plugin is RevealPluginBundle {93return (plugin as RevealPluginBundle).plugin !== undefined;94}9596export async function revealPluginExtras(97input: string,98format: Format,99flags: PandocFlags,100temp: TempContext,101revealUrl: string,102revealDestDir: string,103extensionContext?: ExtensionContext,104project?: ProjectContext,105) {106// directory to copy plugins into107108const pluginsDestDir = join(revealDestDir, "plugin");109110// accumlate content to inject111const register: string[] = [];112const scripts: RevealPluginScript[] = [];113const stylesheets: string[] = [];114const config: Metadata = {};115const metadata: string[] = [];116const dependencies: FormatDependency[] = [];117118// built-in plugins119const pluginBundles: Array<RevealPlugin | RevealPluginBundle | string> = [120{121plugin: formatResourcePath("revealjs", join("plugins", "line-highlight")),122},123{ plugin: formatResourcePath("revealjs", join("plugins", "pdfexport")) },124];125126// menu plugin (enabled by default)127const menuPlugin = revealMenuPlugin(format);128if (menuPlugin) {129pluginBundles.push(menuPlugin);130}131132// chalkboard plugin (optional)133const chalkboardPlugiln = revealChalkboardPlugin(format);134if (chalkboardPlugiln) {135pluginBundles.push(chalkboardPlugiln);136}137138// tone plugin (optional)139const tonePlugin = revealTonePlugin(format);140if (tonePlugin) {141dependencies.push(toneDependency());142pluginBundles.push(tonePlugin);143}144145// multiplex plugin (optional)146const multiplexPlugin = revealMultiplexPlugin(format);147if (multiplexPlugin) {148pluginBundles.push(multiplexPlugin);149}150151const resolvePluginPath = async (plugin: string) => {152// Look for an extension153let extensions = await extensionContext?.find(154plugin,155input,156kRevealJSPlugins,157project?.config,158project?.dir,159) || [];160161// Filter the extensions162extensions = filterExtensions(163extensions || [],164plugin,165"revealjs-plugins",166);167168// Return any contributed plugins169if (extensions.length > 0) {170return extensions[0].contributes[kRevealJSPlugins] || [];171} else {172return [plugin];173}174};175176const resolvePlugin = async (177plugin: string | RevealPluginBundle | RevealPlugin,178) => {179if (typeof plugin === "string") {180// This is just a simple path181// If the path can be resolved to a file on disk then182// don't treat it as an extension183if (existsSync(plugin)) {184return [plugin];185} else {186return await resolvePluginPath(plugin);187}188} else {189if (isPluginBundle(plugin)) {190// This is a plugin bundle, so try to resolve that191const path = plugin.plugin;192const resolvedPlugins = await resolvePluginPath(path);193194const pluginBundles = resolvedPlugins.map(195(resolvedPlug): RevealPluginBundle => {196if (typeof resolvedPlug === "string") {197return {198plugin: resolvedPlug,199config: plugin.config,200};201} else if (isPluginBundle(resolvedPlug)) {202return {203plugin: resolvedPlug.plugin,204config: mergeConfigs(205plugin.config,206resolvedPlug.config,207),208};209} else {210return plugin;211}212},213);214return pluginBundles;215} else {216return Promise.resolve([plugin]);217}218}219};220221if (Array.isArray(format.metadata[kRevealjsPlugins])) {222for (const plugin of format.metadata[kRevealJSPlugins]) {223const resolvedPlugins = await resolvePlugin(plugin);224pluginBundles.push(...resolvedPlugins);225}226}227228// add general support plugin (after others so it can rely on their init)229pluginBundles.push(230{ plugin: formatResourcePath("revealjs", join("plugins", "support")) },231);232233// read plugins234for (let bundle of pluginBundles) {235// convert string to plugin236if (typeof bundle === "string") {237bundle = {238plugin: bundle,239};240}241242// read from bundle243const plugin = isPluginBundle(bundle)244? await pluginFromBundle(bundle)245: bundle;246247// check for self-contained incompatibility248if (isSelfContained(flags, format)) {249if (plugin[kSelfContained] === false) {250throw new Error(251"Reveal plugin '" + plugin.name +252" is not compatible with self-contained output",253);254}255}256257// note name258if (plugin.register !== false) {259register.push(plugin.name);260}261262// copy plugin (plugin dir uses a kebab-case version of name)263const pluginUrl = pathWithForwardSlashes(264join(revealUrl, "plugin", camelToKebab(plugin.name)),265);266const pluginDir = join(pluginsDestDir, camelToKebab(plugin.name));267if (isPluginBundle(bundle)) {268copyMinimal(bundle.plugin, pluginDir);269} else {270ensureDirSync(pluginDir);271plugin.script?.forEach((script) => {272Deno.copyFileSync(273join(plugin.path, script.path),274join(pluginDir, basename(script.path)),275);276});277plugin.stylesheet?.forEach((style) => {278Deno.copyFileSync(279join(plugin.path, style),280join(pluginDir, basename(style)),281);282});283}284285// note scripts286if (plugin.script) {287for (const script of plugin.script) {288script.path = pathWithForwardSlashes(join(pluginUrl, script.path));289scripts.push(script);290}291}292293// note stylesheet294if (plugin.stylesheet) {295for (const stylesheet of plugin.stylesheet) {296const pluginStylesheet = pathWithForwardSlashes(297join(pluginUrl, stylesheet),298);299stylesheets.push(pathWithForwardSlashes(pluginStylesheet));300}301}302303// add to config304if (plugin.config) {305for (const key of Object.keys(plugin.config)) {306const kebabKey = camelToKebab(key);307if (typeof (plugin.config[key]) === "object") {308config[key] = plugin.config[key];309310// see if the user has yaml to merge311if (typeof (format.metadata[kebabKey]) === "object") {312config[key] = mergeConfigs(313revealMetadataFilter(314config[key] as Metadata,315kRevealPluginKebabOptions,316),317revealMetadataFilter(318format.metadata[kebabKey] as Metadata,319kRevealPluginKebabOptions,320),321);322}323} else {324config[key] = plugin.config[key];325if (format.metadata[key] !== undefined) {326config[key] = format.metadata[key];327}328}329}330}331332// note metadata we should forward into reveal config333if (plugin.metadata) {334metadata.push(...plugin.metadata);335}336}337338// inject them into extras339const extras: FormatExtras = {340[kIncludeInHeader]: [],341html: {342[kDependencies]: dependencies,343},344};345346// link tags for stylesheets347const linkTags = stylesheets.map((file) => {348return `<link href="${file}" rel="stylesheet">`;349}).join("\n");350const linkTagsInclude = temp.createFile({ suffix: ".html" });351Deno.writeTextFileSync(linkTagsInclude, linkTags);352extras[kIncludeInHeader]?.push(linkTagsInclude);353354// inject top level options used by plugins into config355metadata.forEach((option) => {356if (format.metadata[option] !== undefined) {357config[option] = format.metadata[option];358}359});360361const result = {362pluginInit: {363scripts,364register,365revealConfig: config,366},367extras,368};369370// return371return result;372}373374function revealMenuPlugin(format: Format) {375return {376plugin: formatResourcePath("revealjs", join("plugins", "menu")),377config: {378menu: {379custom: [{380title: "Tools",381icon: '<i class="fas fa-gear"></i>',382content: revealMenuTools(format),383}],384openButton: format.metadata[kRevealMenu] !== false,385},386},387};388}389390function revealChalkboardPlugin(format: Format) {391if (format.metadata[kRevealChalkboard]) {392return {393plugin: formatResourcePath("revealjs", join("plugins", "chalkboard")),394};395} else {396return undefined;397}398}399400function revealMenuTools(format: Format) {401const tools = [402{403title: "Fullscreen",404key: "f",405handler: "fullscreen",406},407{408title: "Speaker View",409key: "s",410handler: "speakerMode",411},412{413title: "Slide Overview",414key: "o",415handler: "overview",416},417{418title: "PDF Export Mode",419key: "e",420handler: "togglePdfExport",421},422{423title: "Scroll View Mode",424key: "r",425handler: "toggleScrollView",426},427];428if (format.metadata[kRevealChalkboard]) {429tools.push(430{431title: "Toggle Chalkboard",432key: "b",433handler: "toggleChalkboard",434},435{436title: "Toggle Notes Canvas",437key: "c",438handler: "toggleNotesCanvas",439},440{441title: "Download Drawings",442key: "d",443handler: "downloadDrawings",444},445);446}447tools.push({448title: "Keyboard Help",449key: "?",450handler: "keyboardHelp",451});452const lines = ['<ul class="slide-menu-items">'];453lines.push(...tools.map((tool, index) => {454return `<li class="slide-tool-item${455index === 0 ? " active" : ""456}" data-item="${index}"><a href="#" onclick="RevealMenuToolHandlers.${tool.handler}(event)"><kbd>${457tool458.key || " "459}</kbd> ${tool.title}</a></li>`;460}));461462lines.push("</ul>");463return lines.join("\n");464}465466function revealTonePlugin(format: Format) {467if (format.metadata[kRevealSlideTone]) {468return { plugin: formatResourcePath("revealjs", join("plugins", "tone")) };469} else {470return undefined;471}472}473474function toneDependency() {475const dependency: FormatDependency = {476name: "tone",477scripts: [{478name: "tone.js",479path: formatResourcePath("revealjs", join("tone", "tone.js")),480}],481};482return dependency;483}484485async function pluginFromBundle(486bundle: RevealPluginBundle,487): Promise<RevealPlugin> {488// confirm it's a directory489if (!existsSync(bundle.plugin) || !Deno.statSync(bundle.plugin).isDirectory) {490throw new Error(491"Specified Reveal plugin directory '" + bundle.plugin +492"' does not exist.",493);494}495496let plugin;497498try {499// read the plugin definition (and provide the path)500plugin = (await readAndValidateYamlFromFile(501join(bundle.plugin, "plugin.yml"),502revealPluginSchema,503"Validation of reveal plugin object failed.",504)) as RevealPlugin;505plugin.path = bundle.plugin;506} catch (e) {507error(508`Validation of plugin configuration ${509join(bundle.plugin, "plugin.yml")510} failed.`,511);512throw e;513}514515// convert script and stylesheet to arrays516if (plugin.script && !Array.isArray(plugin.script)) {517plugin.script = [plugin.script];518}519plugin.script = plugin.script?.map((script) => {520if (typeof script === "string") {521return {522path: script,523};524} else {525return script;526}527});528529if (plugin.stylesheet && !Array.isArray(plugin.stylesheet)) {530plugin.stylesheet = [plugin.stylesheet];531}532plugin.stylesheet = plugin.stylesheet?.map((stylesheet) =>533String(stylesheet)534);535536// validate plugin537validatePlugin(plugin);538539// merge user config into plugin config540if (typeof (bundle.config) === "object") {541plugin.config = mergeConfigs(542plugin.config || {} as Metadata,543bundle.config || {} as Metadata,544);545}546547// ensure that metadata is an array548if (typeof (plugin.metadata) === "string") {549plugin.metadata = [plugin.metadata];550}551552// return plugin553return plugin;554}555556function validatePlugin(plugin: RevealPlugin) {557if (typeof (plugin.name) !== "string") {558throw new Error("Reveal plugin definition must include a name.");559}560if (!Array.isArray(plugin.script)) {561throw new Error("Reveal plugin definition must include a script.");562}563for (const script of plugin.script) {564if (!existsSync(join(plugin.path, script.path))) {565throw new Error(566"Reveal plugin script '" + script + "' not found.",567);568}569}570571if (plugin.stylesheet) {572for (const stylesheet of plugin.stylesheet) {573if (!existsSync(join(plugin.path, stylesheet))) {574throw new Error(575"Reveal plugin stylesheet '" + stylesheet + "' not found.",576);577}578}579}580if (plugin.config) {581if (582typeof (plugin.config) !== "object"583) {584throw new Error(585"Reveal plugin config must be an object.",586);587}588}589}590591592