├── .gitignore ├── LICENSE ├── README.md ├── decks.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── scripts ├── bundle.js └── components.js ├── slide-deck.css ├── slide-deck.webc └── templates └── deck.html /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License: 2 | 3 | Copyright (c) 2022 Benny Powers 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Am Yisrael Chai - עם ישראל חי](https://bennypowers.dev/assets/flag.am.yisrael.chai.png) 2 | 3 | # eleventy-plugin-slide-decks 4 | 5 | 🎼 It makes an *itsy-bitsy*, *teeny-weeny*, *web-component* slide deck prezzy, 6 | which is basically a small SPA. 🎵 7 | 8 | 🎚️ Write slide decks with [eleventy](https://11ty.dev) and share them over the 9 | web. 🎴 10 | 11 | Uses [``](https://github.com/ruphin/slidem) to do most of the work 12 | for you. 13 | 14 | ## Installation and Setup 15 | 16 | ``` 17 | npm i eleventy-plugin-slide-decks 18 | echo "_includes/deck.html" >> .gitignore 19 | ``` 20 | 21 | Then in your 11ty config: 22 | 23 | ```js 24 | const DecksPlugin = require('eleventy-plugin-slide-decks'); 25 | eleventyConfig.addPlugin(DecksPlugin); 26 | ``` 27 | 28 | ## Writing Decks 29 | 30 | Create a `decks` directory in your 11ty source root (or use `decksDir` option). 31 | Each slide deck is a single dir under the `decksDir`. Add an 11ty data file for 32 | each deck or for all decks. 33 | You must add a single template to each deck's root dir, containing frontmatter 34 | for that deck. At minimum, that file should have `layout: deck.html` and `deck: 35 | deckdir`. For example, if you have a deck in `decks/prezzo`, you must at very 36 | least create `decks/prezzo/prezzo.md` with this content: 37 | 38 | ```md 39 | --- 40 | layout: deck.html 41 | deck: prezzo 42 | --- 43 | ``` 44 | 45 | Optional frontmatter keys: 46 | ```yaml 47 | title: Slide deck title 48 | author: slide deck author (used in open graph tags) 49 | description: meta description (and og) 50 | lang: en # default 51 | dir: ltr # default 52 | date: 2022-12-02 53 | locale: en-US # default 54 | origin: https://bennypowers.dev # slide deck origin (used in og:url) 55 | coverImage: prezzo.png # used in open graph tags 56 | icons: 57 | - rel: icon # required 58 | href: /assets/images/favicon.ico?v=2 # required 59 | - rel: shortcut icon 60 | href: /assets/icon.svg 61 | - rel: apple-touch-icon 62 | sizes: 72x72 63 | href: /assets/images/manifest/icon-72x72.png 64 | preconnect: 65 | - https://fonts.googleapis.com 66 | stylesheets: 67 | - href: /optional/urls/to/stylesheets.css 68 | async: true # optional 69 | media: 'screen and (prefers-color-scheme: dark)' # optional 70 | scripts: 71 | - src: prezzo.js 72 | type: module 73 | ``` 74 | 75 | You can also provide an `importMap` as data, and it will print in the ``. 76 | 77 | All urls are passed through the `url` filter for you. 78 | 79 | It's possible to put content in this file, but you should expect that content to 80 | be invisible, for example, you can put an SVG sprite sheet in there: 81 | 82 | ```html 83 | 84 | {% for icon in collections.icon %} 85 | {{ icon.templateContent | safe }}{% endfor %} 86 | 87 | 88 | ``` 89 | 90 | ### WebC Decks 91 | 92 | If you want to go buck wild, let loose; if you're so excited and you just can't 93 | hide it, you can also (deep breaths) use the 94 | [WebC](https://www.11ty.dev/docs/languages/webc/) deck component: 95 | 96 | ```html 97 | --- 98 | eleventyImport: 99 | collections: 100 | - webbyprezzy 101 | --- 102 | 107 | 108 | 110 | 111 | ``` 112 | 113 | Don't leave out the eleventyImport or or the `webc:nokeep`, or else things will 114 | break in hilarious ways. 115 | 116 | ### Writing Slides 117 | 118 | Each decks slides are located in it's `slides` directory, and ordered by name. 119 | It's recommended to name the slide files `00-first-slide.md`, 120 | `10-second-slide.md`, etc. You should also add an 11ty data file for the slides 121 | directory containing `permalink: false`, in order to prevent 11ty from rendering 122 | individual HTML pages for each slide, which would only duplicate the slide 123 | content. 124 | 125 | So for example: 126 | 127 | ``` 128 | decks 129 | ├── decks.json 130 | └── prezzo 131 | ├── img.png 132 | ├── prezo.css 133 | ├── prezzo.md 134 | └── slides 135 | ├── 00-first-slide.md 136 | ├── 10-second-slide.md 137 | ├── 99-last-slide.md 138 | └── slides.json 139 | ``` 140 | 141 | #### Stepping Through Slide Content 142 | 143 | If you want certain elements of a slide to reveal one by one, specify a CSS 144 | selector in the `reveal` frontmatter key. Take for example this slide: 145 | 146 | ~~~md 147 | --- 148 | reveal: li, img 149 | --- 150 | ## Using Slidem 151 | 152 | - HTML wins 153 | - JavaScript is p. cool too 154 | 155 | ![a satisfied and productive slide author](img.png) 156 | ~~~ 157 | 158 | When switching to this slide, on step 1 only the heading will be visible, on 159 | step 2 the first list item ("HTML wins") will appear, on step 3, the next list 160 | item, and on step 4, the image. 161 | 162 | #### Styling Slides 163 | 164 | You can set the classname for the slide with the `class` frontmatter key: 165 | 166 | ```md 167 | --- 168 | class: dark 169 | --- 170 | ``` 171 | ```html 172 | 173 | ``` 174 | 175 | Slides get the `name` attribute based on their filename, so `00-first-slide.md` 176 | would render as ``. You can then style that 177 | slide with good-old CSS. Each slide exposes `container` and `content` [CSS 178 | Shadow Parts](https://developer.mozilla.org/en-US/docs/Web/CSS/::part): 179 | 180 | ```css 181 | [name=first-slide] { 182 | background: hotpink; 183 | } 184 | 185 | [name=first-slide]::part(content) { 186 | display: grid; 187 | grid-template-columns: 1fr 1fr; 188 | gap: 1em; 189 | } 190 | ``` 191 | 192 | ##### `style` frontmatter 193 | 194 | Although using CSS is encouraged, you can also set styles on the slide element 195 | with the `style` frontmatter key. If the value is a string, it will be dumped 196 | into the `style` attribute: 197 | 198 | ```md 199 | --- 200 | style: 'color:hotpink' 201 | --- 202 | ... 203 | ``` 204 | ```html 205 | 206 |

...

207 |
208 | ``` 209 | 210 | But if you pass a YAML dictionary or a JSON object, it will be collapsed into a 211 | style string for you: 212 | 213 | ```md 214 | --- 215 | style: 216 | color: hotpink 217 | font-size: 200% 218 | animation: jazz-hands 219 | --- 220 | ... 221 | ``` 222 | ```html 223 | 224 |

...

225 |
226 | ``` 227 | 228 | #### Extending `SlidemSlide` 229 | You might want to create your own custom slide types by extending `SlidemSlide`. 230 | One case in which this is useful is in providing custom slots to a slide, like a 231 | slide which presents a `
` in a `
` with the author in the 232 | `
`. In that case, you can specify the tag name to use for the slide 233 | with the `is` frontmatter key: 234 | 235 | ```md 236 | --- 237 | is: slidem-quote 238 | --- 239 |

240 | All our work, our whole life is a matter of semantics, because words are the 241 | tools with which we work... Everything depends on our understanding of 242 | them. 243 |

244 | 245 | Felix Frankfurter 246 | 247 | felix at the window 248 | ``` 249 | ```html 250 | 251 | ``` 252 | 253 | 254 | ## Options 255 | 256 | | option | type | default | description | 257 | | ------------------ | -------- | ------------------------ | ------------------------------------------------------- | 258 | | `decksDir` | string | 'decks' | directory off the 11ty input dir which contains slides | 259 | | `assetsExtensions` | string[] | see below | file extensions to pass-through copy from the decks dir | 260 | | `target` | boolean | [esbuild target][target] | esbuild target to use when bundling slide dependencies | 261 | | `polyfills` | object | see below | polyfills to load on the decks page | 262 | 263 | By default, files matching `decks/**/*.{css|jpeg|jpg|js|mp4|png|svg|webp}` will 264 | passthrough copy to the output dir. 265 | 266 | The `polyfills object` defaults to the following: 267 | 268 | ```json 269 | { 270 | "constructibleStyleSheets": true, 271 | "webcomponents": false, 272 | "esmoduleShims": false, 273 | } 274 | ``` 275 | 276 | [target]: https://esbuild.github.io/api/#target 277 | -------------------------------------------------------------------------------- /decks.js: -------------------------------------------------------------------------------- 1 | import { readdir, readFile } from 'node:fs/promises'; 2 | import { basename } from 'node:path'; 3 | import { load } from 'cheerio'; 4 | import { lookup } from 'mime-types'; 5 | import { bundle } from './scripts/bundle.js'; 6 | 7 | /** @import { UserConfig } from '@11ty/eleventy' */ 8 | 9 | /** 10 | * @param {string} content HTML content of the page 11 | * @param {string} selector CSS selector (all) to apply `reveal` attr to 12 | * 13 | */ 14 | function addRevealAttrs(content, selector) { 15 | if (!selector) return content; 16 | const $ = load(content, null, false); 17 | $(selector).each(function() { 18 | const closest = $(this).closest('[slot="notes"]'); 19 | if (!closest.length) 20 | $(this).attr('reveal', ''); 21 | }); 22 | return $.html(); 23 | } 24 | 25 | const assignMetadata = x => Object.assign(x, { 26 | deck: x.data.page.filePathStem.split('/').at(2), 27 | data: Object.assign(x.data, { 28 | name: x.data.name ?? basename(x.data.page.filePathStem).replace(/^\d+-/, '') 29 | }), 30 | }); 31 | 32 | const byInputPath = (a, b) => 33 | a.inputPath < b.inputPath ? -1 34 | : a.inputPath > b.inputPath ? 1 35 | : 0; 36 | 37 | /** 38 | * @typedef {object} Polyfills 39 | * @prop {boolean} [esmoduleShims=false] load the es-module-shims polyfills 40 | * @prop {boolean} [webcomponents=false] load the webcomponents polyfills 41 | * @prop {boolean} [constructibleStyleSheets=true] load the constructible stylesheets polyfills 42 | */ 43 | 44 | /** 45 | * @typedef {object} EleventyPluginSlideDecksOptions 46 | * @prop {object} [templateData={}] extra template data for decks. see decks.html 47 | * @prop {string} [decksDir='decks'] directory off the 11ty input dir which contains slides 48 | * @prop {string[]} [assetsExtensions] file extensions to pass-through copy from the decks dir 49 | * @prop {Polyfills}[polyfills] which polyfills to load 50 | * @prop {string} [target=es2020] esbuild build target when bundling dependencies 51 | */ 52 | 53 | /** 54 | * @param {UserConfig} eleventyConfig 55 | * @param {EleventyPluginSlideDecksOptions} options Options for the decks 56 | * Create Slide Decks using eleventy. 57 | * 58 | * Create a `decks` dir in your eleventy root to hold your slide decks, one per directory. 59 | * Each deck dir should contain a template with frontmatter metadata for the deck, 60 | * and a `slides` dir containing templates for each slide. You must add a `slides` 11ty data file 61 | * containing `{"permalink":false}`, otherwise your slides will be published individually as well 62 | * as part of the slide deck. 63 | * @example 64 | * ```tree 65 | * decks 66 | * └── 11ty-deck 67 | * ├── deck-graphic.svg 68 | * └── slides 69 | * ├── 00-title-card.md 70 | * ├── 01-intro.md 71 | * ├── 10-code.md 72 | * ├── 99-thanks.md 73 | * └── slides.json 74 | * ``` 75 | */ 76 | export async function slideDecksPlugin(eleventyConfig, options = {}) { 77 | const { 78 | decksDir = 'decks', 79 | assetsExtensions = [ 80 | 'css', 81 | 'jpeg', 82 | 'jpg', 83 | 'js', 84 | 'mp4', 85 | 'png', 86 | 'svg', 87 | 'webp', 88 | ] 89 | } = options ?? {}; 90 | 91 | const polyfills = options?.polyfills ?? {}; 92 | polyfills.constructibleStyleSheets ??= true; 93 | polyfills.webcomponents ??= false; 94 | polyfills.esmoduleShims ??= false; 95 | 96 | eleventyConfig.addGlobalData('polyfills', polyfills); 97 | 98 | eleventyConfig.addFilter('mime', url => lookup(url)); 99 | eleventyConfig.addFilter('trim', str => typeof str === 'string' ? str.trim() : str); 100 | eleventyConfig.addFilter('stringifyCSSStyle', strOrObj => 101 | typeof strOrObj === 'string' ? strOrObj.trim() 102 | : Object.entries(strOrObj).map(([k, v]) => `${k}:${v}`).join(';')); 103 | 104 | /** Add the `reveal` attribute to all elements matching the selector */ 105 | eleventyConfig.addFilter('reveal', addRevealAttrs); 106 | 107 | eleventyConfig.addFilter('byInputPath', byInputPath); 108 | 109 | for (const ext of assetsExtensions) 110 | eleventyConfig.addPassthroughCopy(`${decksDir}/**/*.${ext}`); 111 | 112 | /** Get all the slides, sort and assign their deck id */ 113 | eleventyConfig.addCollection('slides', collectionApi => collectionApi 114 | .getFilteredByGlob(`./${decksDir}/*/slides/*`) 115 | .map(assignMetadata) 116 | .sort(byInputPath)); 117 | 118 | const templateDir = new URL('./templates/', import.meta.url); 119 | for (const filename of await readdir(templateDir)) { 120 | eleventyConfig.addTemplate( 121 | 'deck.html', 122 | await readFile(new URL(filename, templateDir), 'utf8'), 123 | { 124 | ...options?.templateData ?? {}, 125 | layout: false, 126 | eleventyExcludeFromCollections: ['slides'], 127 | eleventyImport: { 128 | collections: ['slides'], 129 | }, 130 | }, 131 | ) 132 | } 133 | 134 | /** bundle slidem deck dependencies */ 135 | eleventyConfig.on('eleventy.before', bundle.bind(this, eleventyConfig, options)); 136 | } 137 | 138 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "." 4 | ], 5 | "compilerOptions": { 6 | "module": "commonjs", 7 | "moduleResolution": "Node", 8 | "target": "esnext", 9 | "emit": false, 10 | "checkJs": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eleventy-plugin-slide-decks", 3 | "version": "0.5.0", 4 | "description": "Write slide decks using 11ty and share them over the web", 5 | "type": "module", 6 | "main": "decks.js", 7 | "module": "decks.js", 8 | "exports": "./decks.js", 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "keywords": [ 13 | "eleventy-plugin", 14 | "slides", 15 | "web-components", 16 | "html" 17 | ], 18 | "author": "Benny Powers ", 19 | "license": "ISC", 20 | "dependencies": { 21 | "@shoelace-style/shoelace": "^2.15.1", 22 | "cheerio": "^1.0.0-rc.12", 23 | "esbuild": "^0.18.11", 24 | "esbuild-plugin-minify-html-literals": "^2.0.0", 25 | "mime-types": "^2.1.35", 26 | "slidem": "^2.0.2" 27 | }, 28 | "devDependencies": { 29 | "@11ty/eleventy": "^2.0.1", 30 | "typescript": "^5.5.3" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /scripts/bundle.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url'; 2 | import { build } from 'esbuild'; 3 | import { minifyHTMLLiteralsPlugin } from 'esbuild-plugin-minify-html-literals' 4 | 5 | /** @import { UserConfig } from '@11ty/eleventy' */ 6 | 7 | /** 8 | * @param {UserConfig} eleventyConfig 9 | * @param {EleventyPluginSlideDecksOptions} [options] Options for the decks 10 | */ 11 | export async function bundle(eleventyConfig, options) { 12 | const start = performance.now(); 13 | const prefix = '[eleventy-plugin-slide-decks]:'; 14 | 15 | const { 16 | outfile = '_site/assets/decks.min.js', 17 | target = 'es2022', 18 | } = options ?? {}; 19 | 20 | eleventyConfig.logger.info(`${prefix} bundling with esbuild`); 21 | 22 | await build({ 23 | outfile, 24 | entryPoints: [fileURLToPath(new URL('./components.js', import.meta.url))], 25 | format: 'esm', 26 | target, 27 | bundle: true, 28 | minifySyntax: true, 29 | minifyWhitespace: true, 30 | mangleQuoted: false, 31 | legalComments: 'linked', 32 | plugins: [ 33 | minifyHTMLLiteralsPlugin({ 34 | minifyOptions: { 35 | removeComments: true, 36 | minifyCSS: true, 37 | }, 38 | }) 39 | ], 40 | }); 41 | 42 | const seconds = ((performance.now() - start) / 1000).toFixed(2); 43 | 44 | eleventyConfig.logger.info(`${prefix} ...done bundling in ${seconds}s`); 45 | } 46 | -------------------------------------------------------------------------------- /scripts/components.js: -------------------------------------------------------------------------------- 1 | export * from 'slidem/slidem-deck.js'; 2 | export * from 'slidem/slidem-slide.js'; 3 | export * from 'slidem/slidem-video-slide.js'; 4 | 5 | import '@shoelace-style/shoelace/dist/components/progress-bar/progress-bar.js'; 6 | 7 | const deck = 8 | /** @type {import('slidem/slidem-deck.js').SlidemDeck} */ 9 | (document.querySelector('slidem-deck')); 10 | 11 | const progress = 12 | /** @type {import('@shoelace-style/shoelace').SlProgressBar} */ 13 | (document.getElementById('slides-progress')); 14 | 15 | /** @param {Event} event */ 16 | function isInputEvent(event) { 17 | return event 18 | .composedPath() 19 | .some(/** @param {HTMLElement} x*/ x => ( 20 | x.contentEditable === 'true' 21 | || x instanceof HTMLInputElement 22 | || x instanceof HTMLTextAreaElement 23 | )) 24 | } 25 | 26 | document.body.addEventListener('keydown', event => { 27 | // If event already processed, or if the event happens within an editor, 28 | if (event.defaultPrevented || isInputEvent(event)) 29 | return; 30 | switch (event.key) { 31 | case 'f': 32 | if (document.fullscreenElement) 33 | document.exitFullscreen(); 34 | else 35 | document.body.requestFullscreen(); 36 | return true; 37 | default: 38 | return true; 39 | } 40 | }); 41 | 42 | deck.addEventListener('change', event => { 43 | const curr = deck.currentSlideIndex + 1; 44 | const total = deck.slides.length; 45 | const oneSlide = (1 / total) * 100; 46 | const { steps, step } = 47 | /** @type {import('slidem/slidem-slide.js').SlidemSlide} */(deck.currentSlide); 48 | // Add in a fraction of one slide's worth of progress, if there are slide steps 49 | const stepProgress = steps <= 1 ? 0 : (((step / steps) * oneSlide) - oneSlide); 50 | const percentage = ((curr / total) * 100); 51 | progress.value = percentage + stepProgress; 52 | }); 53 | 54 | (async () => { 55 | await customElements.whenDefined('slidem-deck'); 56 | progress.indeterminate = false; 57 | })(); 58 | -------------------------------------------------------------------------------- /slide-deck.css: -------------------------------------------------------------------------------- 1 | body { 2 | height: 100vh; 3 | max-height: 100vh; 4 | overflow: hidden; 5 | max-width: 100vw; 6 | padding: 0; 7 | } 8 | 9 | main { position: relative; } 10 | 11 | main, 12 | slidem-deck { height: 100%; } 13 | 14 | #footer { 15 | position: fixed; 16 | font-size: 80%; 17 | inset-block-end: 1em; 18 | inset-inline-end: 1em; 19 | z-index: 2; 20 | } 21 | 22 | #slides-progress { 23 | --sl-border-radius-pill: 0; 24 | --indicator-color: var(--deck-primary-color); 25 | --height: 100%; 26 | 27 | width: 100%; 28 | } 29 | 30 | slidem-deck::part(progress) { 31 | width: 100%; 32 | height: 5px; 33 | top: 0; 34 | } 35 | 36 | -------------------------------------------------------------------------------- /slide-deck.webc: -------------------------------------------------------------------------------- 1 | 2 | 18 | 19 | 20 | 21 | 22 | 24 | 26 | 27 | 28 | 29 | 30 | 33 | 36 | 37 | 40 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 57 | 61 | 63 | 64 | 68 | 69 | 70 | 71 | 75 | 79 | 82 | 85 | 88 | 89 | 90 | 93 | 96 | 97 | 98 | 99 | 100 |
101 | 102 | 103 | 112 | 113 |
114 |
115 | 116 |
117 | 118 | 119 | -------------------------------------------------------------------------------- /templates/deck.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ title }} 6 | 7 | 8 | 9 | 10 | 11 | 12 | {% if author %}{% endif %} 13 | {% if date %}{% endif %} 14 | 15 | {% if description %} 16 | 17 | 18 | {% endif %} 19 | 20 | 21 | 22 | 23 | 24 | {% if coverImage %} 25 | 26 | 27 | 28 | 29 | 30 | {% endif %} 31 | 32 | {% for link in preconnect %} 33 | {% endfor %} 34 | 35 | {% for icon in icons %} 36 | {% endfor %} 37 | 38 | 39 | 40 | 41 | {% for sheet in stylesheets %} 42 | 46 | {% endfor %} 47 | 48 | {% if importMap %} 49 | {% endif %} 50 | {% if polyfills.esmoduleShims -%} 51 | {% endif %} 52 | 53 | {% for script in scripts %} 54 | 57 | {% endfor %} 58 | 59 | {% if polyfills.constructibleStyleSheets -%} 60 | {% endif %} 61 | {% if polyfills.webcomponents -%} 62 | {% endif %} 63 | 64 | 65 | 66 | 101 | 102 | 103 |
104 | 105 | 106 | {% for slide in collections.slides %} 107 | {% if slide.deck == deck %} 108 | <{{ slide.data.is or 'slidem-slide' }} 109 | {%- if slide.data.name %} name="{{ slide.data.name }}"{% endif %} 110 | {%- if slide.data.class %} class="{{ slide.data.class }}"{% endif %} 111 | {%- if slide.data.style %} style="{{ slide.data.style | stringifyCSSStyle }}"{% endif %}> 112 | {{ slide.templateContent | reveal(slide.data.reveal) | safe }} 113 | 114 | {% endif %} 115 | {% endfor %} 116 | 117 | {{ content | safe }} 118 |
119 | 122 | 123 | 124 | 125 | --------------------------------------------------------------------------------