mdeck 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +259 -0
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/assets/base.css +196 -0
- package/assets/extensions/layouts/chapter/extension.toml +26 -0
- package/assets/extensions/layouts/chapter/layout.jsx +21 -0
- package/assets/extensions/layouts/chapter/starter.md +8 -0
- package/assets/extensions/layouts/focus/extension.toml +8 -0
- package/assets/extensions/layouts/focus/layout.jsx +10 -0
- package/assets/extensions/layouts/focus/starter.md +4 -0
- package/assets/extensions/layouts/full-bleed-image/extension.toml +31 -0
- package/assets/extensions/layouts/full-bleed-image/layout.jsx +19 -0
- package/assets/extensions/layouts/full-bleed-image/starter.md +6 -0
- package/assets/extensions/layouts/generic/extension.toml +8 -0
- package/assets/extensions/layouts/generic/layout.jsx +8 -0
- package/assets/extensions/layouts/generic/starter.md +4 -0
- package/assets/extensions/layouts/image-text/extension.toml +25 -0
- package/assets/extensions/layouts/image-text/layout.jsx +17 -0
- package/assets/extensions/layouts/image-text/starter.md +6 -0
- package/assets/extensions/layouts/split/extension.toml +25 -0
- package/assets/extensions/layouts/split/layout.jsx +19 -0
- package/assets/extensions/layouts/split/starter.md +14 -0
- package/assets/extensions/layouts/title/extension.toml +26 -0
- package/assets/extensions/layouts/title/layout.jsx +14 -0
- package/assets/extensions/layouts/title/starter.md +5 -0
- package/assets/extensions/palettes/brand/extension.toml +29 -0
- package/assets/extensions/palettes/cobalt/extension.toml +27 -0
- package/assets/extensions/palettes/ember/extension.toml +27 -0
- package/assets/extensions/palettes/forest/extension.toml +27 -0
- package/assets/extensions/palettes/graphite/extension.toml +27 -0
- package/assets/extensions/palettes/lagoon/extension.toml +27 -0
- package/assets/extensions/palettes/neon/extension.toml +27 -0
- package/assets/extensions/palettes/nordic/extension.toml +27 -0
- package/assets/extensions/palettes/pastel/extension.toml +30 -0
- package/assets/extensions/palettes/phosphor/extension.toml +27 -0
- package/assets/extensions/palettes/swiss/extension.toml +27 -0
- package/assets/extensions/palettes/terra/extension.toml +27 -0
- package/assets/extensions/themes/academic/extension.toml +53 -0
- package/assets/extensions/themes/academic/styles.css +631 -0
- package/assets/extensions/themes/aurora/extension.toml +37 -0
- package/assets/extensions/themes/aurora/styles.css +644 -0
- package/assets/extensions/themes/duet/extension.toml +46 -0
- package/assets/extensions/themes/duet/styles.css +573 -0
- package/assets/extensions/themes/editorial/extension.toml +46 -0
- package/assets/extensions/themes/editorial/styles.css +574 -0
- package/assets/extensions/themes/fhnw/extension.toml +61 -0
- package/assets/extensions/themes/fhnw/styles.css +604 -0
- package/assets/extensions/themes/neue/extension.toml +46 -0
- package/assets/extensions/themes/neue/styles.css +489 -0
- package/assets/extensions/themes/sketch/extension.toml +48 -0
- package/assets/extensions/themes/sketch/styles.css +633 -0
- package/assets/extensions/themes/terminal/extension.toml +38 -0
- package/assets/extensions/themes/terminal/styles.css +550 -0
- package/assets/logo/avatar.png +0 -0
- package/assets/logo/logo-dark.svg +10 -0
- package/assets/logo/logo-light.svg +10 -0
- package/assets/logo/mark.svg +15 -0
- package/bin/mdeck.js +2 -0
- package/docs/README.md +8 -0
- package/docs/reference/authoring.md +795 -0
- package/docs/reference/claude-skill.md +96 -0
- package/docs/reference/extensions.md +250 -0
- package/docs/reference/layouts.md +153 -0
- package/docs/reference/palettes.md +106 -0
- package/docs/reference/structured-slides.md +120 -0
- package/docs/reference/themes.md +133 -0
- package/docs/site/assets/favicon.svg +15 -0
- package/docs/site/assets/github-logo.svg +1 -0
- package/docs/site/assets/site.css +162 -0
- package/docs/site/assets/site.js +123 -0
- package/docs/site/build.js +148 -0
- package/docs/site/content/appearance.md +203 -0
- package/docs/site/content/audience.md +178 -0
- package/docs/site/content/commands.md +153 -0
- package/docs/site/content/components.md +134 -0
- package/docs/site/content/drawing.md +72 -0
- package/docs/site/content/editing.md +59 -0
- package/docs/site/content/getting-started.md +103 -0
- package/docs/site/content/layouts.md +126 -0
- package/docs/site/content/media.md +107 -0
- package/docs/site/content/more-content.md +130 -0
- package/docs/site/content/presenting.md +93 -0
- package/docs/site/content/reusable-layouts.md +74 -0
- package/docs/site/content/sharing.md +88 -0
- package/docs/site/content/theme-authoring.md +144 -0
- package/docs/site/content/theme-repository.md +76 -0
- package/docs/site/content/troubleshooting.md +92 -0
- package/docs/site/content/views.md +56 -0
- package/docs/site/content/welcome.md +71 -0
- package/docs/site/content/writing.md +121 -0
- package/docs/site/examples/first-talk.md +88 -0
- package/docs/site/examples/notebook.svg +58 -0
- package/docs/site/images/themes/academic-dark.webp +0 -0
- package/docs/site/images/themes/academic-light.webp +0 -0
- package/docs/site/images/themes/academic.webp +0 -0
- package/docs/site/images/themes/aurora-dark.webp +0 -0
- package/docs/site/images/themes/aurora-light.webp +0 -0
- package/docs/site/images/themes/aurora.webp +0 -0
- package/docs/site/images/themes/duet-dark.webp +0 -0
- package/docs/site/images/themes/duet-light.webp +0 -0
- package/docs/site/images/themes/duet.webp +0 -0
- package/docs/site/images/themes/editorial-dark.webp +0 -0
- package/docs/site/images/themes/editorial-light.webp +0 -0
- package/docs/site/images/themes/editorial.webp +0 -0
- package/docs/site/images/themes/fhnw.webp +0 -0
- package/docs/site/images/themes/neue-dark.webp +0 -0
- package/docs/site/images/themes/neue-light.webp +0 -0
- package/docs/site/images/themes/neue.webp +0 -0
- package/docs/site/images/themes/sketch-dark.webp +0 -0
- package/docs/site/images/themes/sketch-light.webp +0 -0
- package/docs/site/images/themes/sketch.webp +0 -0
- package/docs/site/images/themes/terminal-dark.webp +0 -0
- package/docs/site/images/themes/terminal-light.webp +0 -0
- package/docs/site/images/themes/terminal.webp +0 -0
- package/docs/site/package.json +9 -0
- package/docs/site/pages.js +27 -0
- package/docs/site/server.js +58 -0
- package/examples/custom-layouts/extensions/comparison/extension.toml +35 -0
- package/examples/custom-layouts/extensions/comparison/layout.jsx +15 -0
- package/examples/custom-layouts/extensions/comparison/starter.md +17 -0
- package/examples/custom-layouts/extensions/comparison/styles.css +4 -0
- package/examples/custom-layouts/slides.md +60 -0
- package/examples/poll/slides.md +127 -0
- package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T08-36-47Z.json +19 -0
- package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T11-27-30Z.json +6 -0
- package/examples/python/slides.drawings.json +6 -0
- package/examples/python/slides.md +493 -0
- package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T08-08-30Z.json +10 -0
- package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T11-41-35Z.json +13 -0
- package/examples/showcase/img/image.jpg +0 -0
- package/examples/showcase/img/mdeck.svg +66 -0
- package/examples/showcase/slides.drawings.json +11 -0
- package/examples/showcase/slides.md +427 -0
- package/package.json +75 -0
- package/skills/write-slides/SKILL.md +144 -0
- package/src/build/check.js +49 -0
- package/src/build/chrome.js +130 -0
- package/src/build/components.js +44 -0
- package/src/build/config.js +61 -0
- package/src/build/drawings.js +14 -0
- package/src/build/editorPlugin.js +272 -0
- package/src/build/editorServer.js +36 -0
- package/src/build/fonts.js +84 -0
- package/src/build/homePlugin.js +208 -0
- package/src/build/inkPlugin.js +192 -0
- package/src/build/ownWrites.js +18 -0
- package/src/build/pairing.js +81 -0
- package/src/build/pdf.js +34 -0
- package/src/build/relayAccess.js +85 -0
- package/src/build/slidesPlugin.js +371 -0
- package/src/build/themeCheck.js +151 -0
- package/src/build/tunnelClient.js +184 -0
- package/src/cli/args.js +76 -0
- package/src/cli/index.js +758 -0
- package/src/cli/migrate.js +123 -0
- package/src/cli/skill.js +40 -0
- package/src/cli/themes.js +140 -0
- package/src/components/CodeBlock.jsx +168 -0
- package/src/components/ConnectedViews.jsx +25 -0
- package/src/components/Icon.jsx +8 -0
- package/src/components/Poll.jsx +67 -0
- package/src/components/QrCode.jsx +29 -0
- package/src/components/QrCodeTag.jsx +27 -0
- package/src/components/Question.jsx +28 -0
- package/src/components/Scale.jsx +47 -0
- package/src/components/VideoPlayer.jsx +108 -0
- package/src/components/WordCloud.jsx +106 -0
- package/src/components/activity.jsx +35 -0
- package/src/components/callout.css +78 -0
- package/src/components/code-block.css +123 -0
- package/src/components/columns.css +11 -0
- package/src/components/poll.css +81 -0
- package/src/components/video-player.css +14 -0
- package/src/core/devPath.js +4 -0
- package/src/core/editDeck.js +230 -0
- package/src/core/icons.js +69 -0
- package/src/core/ink.js +221 -0
- package/src/core/labels.js +144 -0
- package/src/core/outline.js +33 -0
- package/src/core/parseSlides.js +152 -0
- package/src/core/source.js +137 -0
- package/src/core/urls.js +34 -0
- package/src/core/validateDeck.js +139 -0
- package/src/editor/api.js +67 -0
- package/src/editor/components/App.jsx +75 -0
- package/src/editor/components/ConflictBanner.jsx +10 -0
- package/src/editor/components/DeckSettings.jsx +108 -0
- package/src/editor/components/DesignApp.jsx +252 -0
- package/src/editor/components/Diagnostics.jsx +10 -0
- package/src/editor/components/ExtensionEditor.jsx +66 -0
- package/src/editor/components/ExtensionForms.jsx +134 -0
- package/src/editor/components/ExtensionsPanel.jsx +114 -0
- package/src/editor/components/Field.jsx +24 -0
- package/src/editor/components/Inspector.jsx +36 -0
- package/src/editor/components/LayoutPicker.jsx +23 -0
- package/src/editor/components/LayoutSelect.jsx +25 -0
- package/src/editor/components/LookThumbnail.jsx +8 -0
- package/src/editor/components/MetaFields.jsx +24 -0
- package/src/editor/components/Outline.jsx +28 -0
- package/src/editor/components/PalettePicker.jsx +32 -0
- package/src/editor/components/Preview.jsx +63 -0
- package/src/editor/components/PropsForm.jsx +46 -0
- package/src/editor/components/RawSlideEditor.jsx +9 -0
- package/src/editor/components/RegionEditor.jsx +15 -0
- package/src/editor/components/Resizer.jsx +18 -0
- package/src/editor/designLink.js +19 -0
- package/src/editor/editor.css +139 -0
- package/src/editor/extensions.js +86 -0
- package/src/editor/keyboard.js +21 -0
- package/src/editor/model.js +38 -0
- package/src/editor/sampleDeck.js +165 -0
- package/src/editor/saveQueue.js +47 -0
- package/src/editor/schema.js +36 -0
- package/src/editor/state.js +92 -0
- package/src/editor/useDeckDocument.js +67 -0
- package/src/extensions/appearance.js +79 -0
- package/src/extensions/contrast.js +57 -0
- package/src/extensions/discover.js +126 -0
- package/src/extensions/manifest.js +209 -0
- package/src/extensions/packs.js +268 -0
- package/src/extensions/tokens.js +25 -0
- package/src/home/Home.jsx +362 -0
- package/src/home/home.css +148 -0
- package/src/layouts/layoutApi.jsx +205 -0
- package/src/layouts/layoutProps.js +30 -0
- package/src/layouts/renderSlide.jsx +17 -0
- package/src/live/answer/answer.css +34 -0
- package/src/live/answer/answer.html +14 -0
- package/src/live/answer/answer.js +130 -0
- package/src/live/client.js +290 -0
- package/src/live/code.js +19 -0
- package/src/live/follow.js +17 -0
- package/src/live/pairing.js +34 -0
- package/src/live/presence.js +33 -0
- package/src/live/roomTag.js +38 -0
- package/src/live/rooms.js +249 -0
- package/src/live/server.js +82 -0
- package/src/live/tunnel.js +180 -0
- package/src/paths.js +9 -0
- package/src/runtime/ReaderView.jsx +193 -0
- package/src/runtime/SlideErrorBoundary.jsx +28 -0
- package/src/runtime/chrome.jsx +55 -0
- package/src/runtime/deck-stage.js +1533 -0
- package/src/runtime/design.html +12 -0
- package/src/runtime/design.jsx +5 -0
- package/src/runtime/editor.html +12 -0
- package/src/runtime/editor.jsx +5 -0
- package/src/runtime/editorBridge.js +58 -0
- package/src/runtime/fit.js +149 -0
- package/src/runtime/home.html +13 -0
- package/src/runtime/home.jsx +5 -0
- package/src/runtime/index.html +15 -0
- package/src/runtime/ink/InkLayer.jsx +17 -0
- package/src/runtime/ink/Toolbar.jsx +145 -0
- package/src/runtime/ink/attach.js +69 -0
- package/src/runtime/ink/bus.js +126 -0
- package/src/runtime/ink/controller.js +132 -0
- package/src/runtime/ink/room.js +101 -0
- package/src/runtime/ink/select.js +124 -0
- package/src/runtime/ink/server.js +75 -0
- package/src/runtime/ink/store.js +55 -0
- package/src/runtime/ink/toolbar.css +79 -0
- package/src/runtime/main.jsx +863 -0
- package/src/runtime/markedSetup.js +147 -0
- package/src/runtime/reader.css +59 -0
- package/src/runtime/registry.jsx +26 -0
- package/src/runtime/syncOrder.js +32 -0
- package/src/runtime/themeLoader.js +68 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync, renameSync, copyFileSync, mkdirSync, unlinkSync, constants } from 'node:fs'
|
|
2
|
+
import { resolve, dirname, basename, relative } from 'node:path'
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
4
|
+
import { parseSync } from 'vite'
|
|
5
|
+
import { parseSlides, isPlainObject } from '../core/parseSlides.js'
|
|
6
|
+
import { setDeckConfig } from '../core/editDeck.js'
|
|
7
|
+
import { applySourceEdits } from '../core/source.js'
|
|
8
|
+
import { inkFileFor, oldInkFileFor } from '../core/ink.js'
|
|
9
|
+
import { parseManifestText, validateManifest, MANIFEST_FILENAME } from '../extensions/manifest.js'
|
|
10
|
+
import { BACKUP_DIR } from '../build/editorPlugin.js'
|
|
11
|
+
|
|
12
|
+
function migratedSource(source) {
|
|
13
|
+
const deck = parseSlides(source), config = deck.deckConfig, patch = {}
|
|
14
|
+
if (deck.diagnostics.some(d => d.severity === 'error')) throw new Error('Fix source parsing errors before migrating the deck')
|
|
15
|
+
const set = (key, value) => {
|
|
16
|
+
if (Object.hasOwn(config, key) && !isDeepStrictEqual(config[key], value)) throw new Error(`Migration conflict: ${key} already has a different value`)
|
|
17
|
+
patch[key] = value
|
|
18
|
+
}
|
|
19
|
+
for (const [old, key] of [['design', 'theme'], ['share', 'reader']]) {
|
|
20
|
+
if (Object.hasOwn(config, old)) { set(key, config[old]); patch[old] = undefined }
|
|
21
|
+
}
|
|
22
|
+
const show = { ...(config.show ?? {}) }
|
|
23
|
+
for (const [old, key] of [['institution', 'organization'], ['authorDate', 'author'], ['pageNumbers', 'numbers'], ['sections', 'sections']]) {
|
|
24
|
+
if (!Object.hasOwn(config, old)) continue
|
|
25
|
+
if (config.show != null && !isPlainObject(config.show)) throw new Error('Migration conflict: show is not a mapping')
|
|
26
|
+
if (Object.hasOwn(show, key) && !isDeepStrictEqual(show[key], config[old])) throw new Error(`Migration conflict: show.${key} already has a different value`)
|
|
27
|
+
show[key] = config[old]; patch[old] = undefined; patch.show = show
|
|
28
|
+
}
|
|
29
|
+
if (Object.hasOwn(config, 'live')) {
|
|
30
|
+
const live = config.live
|
|
31
|
+
if (!isPlainObject(live) || Object.keys(live).some(key => !['server', 'id', 'code'].includes(key))) throw new Error('Cannot migrate live: expected a mapping containing server, id and/or code')
|
|
32
|
+
if (Object.hasOwn(live, 'server')) set('server', live.server)
|
|
33
|
+
const session = { ...(config.session ?? {}) }
|
|
34
|
+
if (config.session != null && !isPlainObject(config.session)) throw new Error('Migration conflict: session is not a mapping')
|
|
35
|
+
for (const key of ['id', 'code']) {
|
|
36
|
+
if (!Object.hasOwn(live, key)) continue
|
|
37
|
+
if (Object.hasOwn(session, key) && !isDeepStrictEqual(session[key], live[key])) throw new Error(`Migration conflict: session.${key} already has a different value`)
|
|
38
|
+
session[key] = live[key]; patch.session = session
|
|
39
|
+
}
|
|
40
|
+
patch.live = undefined
|
|
41
|
+
}
|
|
42
|
+
return Object.keys(patch).length ? setDeckConfig(deck, patch) : source
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function* walk(dir) {
|
|
46
|
+
if (!existsSync(dir)) return
|
|
47
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
48
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
|
|
49
|
+
const file = resolve(dir, entry.name)
|
|
50
|
+
if (entry.isDirectory()) yield* walk(file)
|
|
51
|
+
else if (entry.isFile()) yield file
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function migratedImports(file, source) {
|
|
56
|
+
if (!source.includes('mdeck/template-api')) return source
|
|
57
|
+
const parsed = parseSync(file, source)
|
|
58
|
+
if (parsed.errors.length) throw new Error(`Cannot parse ${file}; fix its code before migrating imports`)
|
|
59
|
+
const edits = []
|
|
60
|
+
const visit = node => {
|
|
61
|
+
if (!node || typeof node !== 'object') return
|
|
62
|
+
if (['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration', 'ImportExpression'].includes(node.type) && node.source?.value === 'mdeck/template-api') edits.push({ start: node.source.start + 1, end: node.source.end - 1, text: 'mdeck/layout' })
|
|
63
|
+
for (const value of Object.values(node)) {
|
|
64
|
+
if (Array.isArray(value)) value.forEach(visit)
|
|
65
|
+
else if (value && typeof value === 'object') visit(value)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
visit(parsed.program)
|
|
69
|
+
return applySourceEdits(source, edits)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Plan every change and check conflicts before writing. Backups and rollback
|
|
73
|
+
// keep both source edits and file renames reversible; a dry run writes nothing.
|
|
74
|
+
export function migrateDeck(slidesPath, { dryRun = false } = {}) {
|
|
75
|
+
const abs = resolve(slidesPath), dir = dirname(abs), changes = []
|
|
76
|
+
const edit = (file, transform) => {
|
|
77
|
+
const before = readFileSync(file, 'utf8'), after = transform(before)
|
|
78
|
+
if (before !== after) changes.push({ file, before, after })
|
|
79
|
+
}
|
|
80
|
+
edit(abs, migratedSource)
|
|
81
|
+
for (const file of walk(resolve(dir, 'extensions'))) {
|
|
82
|
+
if (basename(file) === MANIFEST_FILENAME) edit(file, source => {
|
|
83
|
+
const raw = parseManifestText(source, file)
|
|
84
|
+
if (raw.kind !== 'template') return source
|
|
85
|
+
validateManifest({ ...raw, kind: 'layout' }, { file, dir: dirname(file), folderName: basename(dirname(file)) })
|
|
86
|
+
// Only the value changes. Comments, quoting and CRLF stay intact.
|
|
87
|
+
const after = source.replace(/^(\s*(?:kind|"kind"|'kind')\s*=\s*)(["'])template\2/gm, '$1$2layout$2')
|
|
88
|
+
const expected = Object.assign(Object.create(Object.getPrototypeOf(raw)), raw, { kind: 'layout' })
|
|
89
|
+
if (after === source || !isDeepStrictEqual(parseManifestText(after, file), expected)) throw new Error(`Cannot safely rewrite kind in ${file}; set kind = "layout" manually`)
|
|
90
|
+
return after
|
|
91
|
+
})
|
|
92
|
+
else if (/\.[cm]?[jt]sx?$/.test(file)) edit(file, source => migratedImports(file, source))
|
|
93
|
+
}
|
|
94
|
+
for (const file of walk(resolve(dir, 'components'))) if (/\.[cm]?[jt]sx?$/.test(file)) edit(file, source => migratedImports(file, source))
|
|
95
|
+
const old = oldInkFileFor(abs), target = inkFileFor(abs)
|
|
96
|
+
if (existsSync(old)) {
|
|
97
|
+
if (existsSync(target)) throw new Error(`Migration conflict: both ${basename(old)} and ${basename(target)} exist. Resolve the two drawings files first.`)
|
|
98
|
+
changes.push({ file: old, to: target })
|
|
99
|
+
}
|
|
100
|
+
if (dryRun || !changes.length) return changes
|
|
101
|
+
const backup = resolve(dir, BACKUP_DIR, `migration-${Date.now()}-${process.pid}`)
|
|
102
|
+
mkdirSync(backup, { recursive: true })
|
|
103
|
+
for (const change of changes) {
|
|
104
|
+
const saved = resolve(backup, relative(dir, change.file))
|
|
105
|
+
mkdirSync(dirname(saved), { recursive: true })
|
|
106
|
+
copyFileSync(change.file, saved)
|
|
107
|
+
}
|
|
108
|
+
const applied = []
|
|
109
|
+
try {
|
|
110
|
+
for (const change of changes) {
|
|
111
|
+
if (change.to) { copyFileSync(change.file, change.to, constants.COPYFILE_EXCL); try { unlinkSync(change.file) } catch (error) { unlinkSync(change.to); throw error } }
|
|
112
|
+
else writeFileSync(change.file, change.after, 'utf8')
|
|
113
|
+
applied.push(change)
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
for (const change of applied.reverse()) {
|
|
117
|
+
if (change.to) renameSync(change.to, change.file)
|
|
118
|
+
else writeFileSync(change.file, change.before, 'utf8')
|
|
119
|
+
}
|
|
120
|
+
throw error
|
|
121
|
+
}
|
|
122
|
+
return changes
|
|
123
|
+
}
|
package/src/cli/skill.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Installs the slide-writing skill for different AI coding assistants. The
|
|
2
|
+
// SKILL.md follows the Agent Skills format; adapters wrap the same body for
|
|
3
|
+
// tools that use their own file conventions.
|
|
4
|
+
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { resolve } from 'node:path'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
import { frameworkRoot } from '../paths.js'
|
|
8
|
+
|
|
9
|
+
export const SKILL_FILE = resolve(frameworkRoot, 'skills/write-slides/SKILL.md')
|
|
10
|
+
|
|
11
|
+
export function readSkill(file = SKILL_FILE) {
|
|
12
|
+
const text = readFileSync(file, 'utf8')
|
|
13
|
+
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/)
|
|
14
|
+
const frontmatter = match?.[1] ?? ''
|
|
15
|
+
const body = match ? text.slice(match[0].length) : text
|
|
16
|
+
const description = frontmatter.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? 'Write a complete mdeck slide deck.'
|
|
17
|
+
return { text, frontmatter, body, description }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const toml = value => `"""${value.replace(/\\/g, '\\\\').replace(/"""/g, '\\"\\"\\"')}"""`
|
|
21
|
+
|
|
22
|
+
// Each target: where the file goes (personal home or the current project) and
|
|
23
|
+
// how the shared body is wrapped.
|
|
24
|
+
export const TARGETS = {
|
|
25
|
+
claude: { title: 'Claude Code', scope: 'home', path: '.claude/skills/write-slides/SKILL.md', projectPath: '.claude/skills/write-slides/SKILL.md', render: skill => skill.text },
|
|
26
|
+
codex: { title: 'OpenAI Codex', scope: 'home', path: '.codex/skills/write-slides/SKILL.md', projectPath: '.codex/skills/write-slides/SKILL.md', render: skill => skill.text },
|
|
27
|
+
cursor: { title: 'Cursor', scope: 'project', projectPath: '.cursor/rules/write-slides.mdc', render: skill => `---\ndescription: ${skill.description}\nglobs:\nalwaysApply: false\n---\n\n${skill.body}` },
|
|
28
|
+
copilot: { title: 'GitHub Copilot', scope: 'project', projectPath: '.github/prompts/write-slides.prompt.md', render: skill => `---\nmode: agent\ndescription: ${skill.description}\n---\n\n${skill.body}` },
|
|
29
|
+
gemini: { title: 'Gemini CLI', scope: 'project', projectPath: '.gemini/commands/write-slides.toml', render: skill => `description = "${skill.description.replace(/"/g, '\\"')}"\nprompt = ${toml(skill.body + '\n\nRequest: {{args}}\n')}\n` },
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function installSkill(target, { project = false, cwd = process.cwd(), home = homedir(), skill = readSkill() } = {}) {
|
|
33
|
+
const spec = TARGETS[target]
|
|
34
|
+
if (!spec) throw new Error(`Unknown assistant "${target}". Choose from: ${Object.keys(TARGETS).join(', ')}`)
|
|
35
|
+
const useProject = project || spec.scope === 'project'
|
|
36
|
+
const file = useProject ? resolve(cwd, spec.projectPath) : resolve(home, spec.path)
|
|
37
|
+
mkdirSync(resolve(file, '..'), { recursive: true })
|
|
38
|
+
writeFileSync(file, spec.render(skill), 'utf8')
|
|
39
|
+
return { target, title: spec.title, file, scope: useProject ? 'project' : 'home' }
|
|
40
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// `mdeck themes`: themes and palettes from the theme repository. Installs go
|
|
2
|
+
// into a deck's extensions/ folder, or with --global into the user's
|
|
3
|
+
// (~/.mdeck/extensions), where every deck finds them. `build` is for the
|
|
4
|
+
// repository itself: it checks the packs and writes what is served.
|
|
5
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { createRequire } from 'node:module'
|
|
7
|
+
import { dirname, resolve } from 'node:path'
|
|
8
|
+
import { builtInRegistry, loadRegistry, userExtensionsDir } from '../extensions/discover.js'
|
|
9
|
+
import { ManifestError } from '../extensions/manifest.js'
|
|
10
|
+
import { buildRepository, compareVersions, fetchIndex, findPack, installedPacks, installFromIndex, PackError, removePack } from '../extensions/packs.js'
|
|
11
|
+
|
|
12
|
+
const VERSION = createRequire(import.meta.url)('../../package.json').version
|
|
13
|
+
|
|
14
|
+
const USAGE = 'search [words] | install <pack> | remove <pack> | update [pack] | list, each with [slides.md or folder] and --global; build <packs> -o <out> [--check]'
|
|
15
|
+
|
|
16
|
+
// The built-in themes and palettes a pack may name and must not reuse the ids of.
|
|
17
|
+
const builtIns = () => {
|
|
18
|
+
const registry = builtInRegistry()
|
|
19
|
+
return { themes: registry.themes, palettes: registry.palettes }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The extensions folder a command works on, and a slides path to load the
|
|
23
|
+
// registry with: the user's with --global, else beside the deck or in the folder.
|
|
24
|
+
function target(where, global) {
|
|
25
|
+
if (global) return { root: userExtensionsDir(), deck: resolve('slides.md'), label: 'for every deck' }
|
|
26
|
+
const given = resolve(where ?? '.')
|
|
27
|
+
const folder = existsSync(given) && statSync(given).isDirectory() ? given : dirname(given)
|
|
28
|
+
return { root: resolve(folder, 'extensions'), deck: existsSync(given) && !statSync(given).isDirectory() ? given : resolve(folder, 'slides.md'), label: `in ${resolve(folder, 'extensions')}` }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const install = (index, entry, { root, force }) => installFromIndex(index, entry, { root, known: builtIns(), mdeckVersion: VERSION, force })
|
|
32
|
+
|
|
33
|
+
export async function runThemes({ positionals, flag, output, ui: { ok, err, tip, c } }) {
|
|
34
|
+
const [sub = 'search', ...rest] = positionals
|
|
35
|
+
const global = flag('--global'), force = flag('--force')
|
|
36
|
+
try {
|
|
37
|
+
if (sub === 'search') {
|
|
38
|
+
const index = await fetchIndex()
|
|
39
|
+
const words = rest.map(word => word.toLowerCase())
|
|
40
|
+
const local = installedPacks(target(null, false).root), mine = installedPacks(userExtensionsDir())
|
|
41
|
+
const found = index.packs.filter(pack => words.every(word => [pack.id, pack.title, pack.description, ...pack.themes, ...pack.palettes].join(' ').toLowerCase().includes(word)))
|
|
42
|
+
if (!found.length) return tip(`No pack matches${words.length ? ` "${words.join(' ')}"` : ''} in ${index.url}`)
|
|
43
|
+
for (const pack of found) {
|
|
44
|
+
const have = local[pack.id] ?? mine[pack.id]
|
|
45
|
+
const state = !have ? '' : compareVersions(pack.version, have.version) > 0 ? ` ${c.yellow}(${have.version} installed, update available)${c.reset}` : ` ${c.green}(installed)${c.reset}`
|
|
46
|
+
console.log(`\n ${c.cyan}${pack.id}${c.reset} ${pack.version} — ${pack.title}${state}`)
|
|
47
|
+
if (pack.description) console.log(` ${pack.description}`)
|
|
48
|
+
console.log(` ${c.dim}${[pack.themes.length ? `themes: ${pack.themes.join(', ')}` : '', pack.palettes.length ? `palettes: ${pack.palettes.join(', ')}` : ''].filter(Boolean).join(' · ')} · by ${pack.author}, ${pack.license}${c.reset}`)
|
|
49
|
+
}
|
|
50
|
+
console.log()
|
|
51
|
+
return tip('Install one with: mdeck themes install <pack> [slides.md] (--global for every deck)')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (sub === 'install') {
|
|
55
|
+
const [id, where] = rest
|
|
56
|
+
if (!id) { err('Name a pack.'); return tip('Usage: mdeck themes install <pack> [slides.md or folder] [--global]; mdeck themes search lists them.') }
|
|
57
|
+
const { root, deck, label } = target(where, global)
|
|
58
|
+
const index = await fetchIndex()
|
|
59
|
+
const result = await install(index, findPack(index, id), { root, force })
|
|
60
|
+
loadRegistry(deck)
|
|
61
|
+
ok(`${result.replaced ? `Updated ${id} from ${result.replaced}` : `Installed ${id}`} ${label}`)
|
|
62
|
+
if (result.themes.length) tip(`Themes: ${result.themes.join(', ')}. Use one with "theme: ${result.themes[0]}" in the deck settings.`)
|
|
63
|
+
if (result.palettes.length) tip(`Palettes: ${result.palettes.join(', ')}. Use one with "palette: ${result.palettes[0]}".`)
|
|
64
|
+
return tip('Look at it on a sample deck: mdeck design')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (sub === 'remove') {
|
|
68
|
+
const [id, where] = rest
|
|
69
|
+
if (!id) { err('Name a pack.'); return tip('Usage: mdeck themes remove <pack> [slides.md or folder] [--global]') }
|
|
70
|
+
const { root, label } = target(where, global)
|
|
71
|
+
const pack = removePack(id, { root, force })
|
|
72
|
+
return ok(`Removed ${id} ${label} (${pack.folders.join(', ')})`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (sub === 'update') {
|
|
76
|
+
const [first, second] = rest
|
|
77
|
+
const named = first && !/\.md$/i.test(first) && !(existsSync(first) && statSync(first).isDirectory()) ? first : null
|
|
78
|
+
const { root, deck, label } = target(named ? second : first, global)
|
|
79
|
+
const installed = installedPacks(root)
|
|
80
|
+
const ids = named ? [named] : Object.keys(installed)
|
|
81
|
+
if (!ids.length) return tip(`No packs installed ${label}`)
|
|
82
|
+
const index = await fetchIndex()
|
|
83
|
+
for (const id of ids) {
|
|
84
|
+
if (!installed[id]) throw new PackError(`The pack ${id} is not installed ${label}`)
|
|
85
|
+
const entry = findPack(index, id)
|
|
86
|
+
if (compareVersions(entry.version, installed[id].version) <= 0) { tip(`${id} ${installed[id].version} is up to date`); continue }
|
|
87
|
+
await install(index, entry, { root, force })
|
|
88
|
+
ok(`Updated ${id} ${installed[id].version} → ${entry.version}`)
|
|
89
|
+
}
|
|
90
|
+
loadRegistry(deck)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (sub === 'list') {
|
|
95
|
+
const [where] = rest
|
|
96
|
+
for (const [label, root] of [['This folder', target(where, false).root], ['Every deck (--global)', userExtensionsDir()]]) {
|
|
97
|
+
const packs = installedPacks(root)
|
|
98
|
+
console.log(`\n ${c.bold}${label}${c.reset} ${c.dim}${root}${c.reset}`)
|
|
99
|
+
if (!Object.keys(packs).length) console.log(` ${c.dim}none${c.reset}`)
|
|
100
|
+
for (const [id, pack] of Object.entries(packs)) console.log(` ${c.cyan}${id}${c.reset} ${pack.version} — ${pack.folders.join(', ')}`)
|
|
101
|
+
}
|
|
102
|
+
console.log()
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (sub === 'build') {
|
|
107
|
+
const [packsDir] = rest
|
|
108
|
+
const out = output()
|
|
109
|
+
if (!packsDir || !out) { err('Name the packs folder and the output folder.'); return tip('Usage: mdeck themes build <packs folder> -o <output folder> [--check]') }
|
|
110
|
+
const index = buildRepository(resolve(packsDir), resolve(out), { known: builtIns() })
|
|
111
|
+
// Every theme and palette of every pack, from the pack folders.
|
|
112
|
+
const extensions = Object.fromEntries(index.packs.flatMap(pack => [...pack.themes, ...pack.palettes].map(id => [id, resolve(packsDir, pack.id, id)])))
|
|
113
|
+
const paletteTheme = id => {
|
|
114
|
+
const text = readFileSync(resolve(extensions[id], 'extension.toml'), 'utf8')
|
|
115
|
+
return /^theme\s*=\s*"([^"]+)"/m.exec(text)?.[1] ?? 'neue'
|
|
116
|
+
}
|
|
117
|
+
const { checkLooks, renderPreviews } = await import('../build/themeCheck.js')
|
|
118
|
+
const looks = index.packs.flatMap(pack => [...pack.themes.map(theme => ({ id: theme, theme })), ...pack.palettes.map(palette => ({ id: palette, theme: paletteTheme(palette), palette }))])
|
|
119
|
+
if (flag('--check')) {
|
|
120
|
+
const failures = await checkLooks(looks, { extensions })
|
|
121
|
+
if (failures.length) { err(`The theme check found ${failures.length} problem(s):`); for (const line of failures) console.error(` ${line}`); process.exitCode = 1; return }
|
|
122
|
+
ok(`Checked ${looks.map(look => look.id).join(', ')}, light and dark, on every kind of slide`)
|
|
123
|
+
}
|
|
124
|
+
// Themes on the title slide, palettes on the chapter slide (the third).
|
|
125
|
+
await renderPreviews(looks.map(look => ({ file: resolve(out, 'previews', `${look.id}.webp`), theme: look.theme, palette: look.palette ?? '', slide: look.palette ? 3 : 1 })), { extensions })
|
|
126
|
+
for (const pack of index.packs) pack.previews = Object.fromEntries([...pack.themes, ...pack.palettes].map(id => [id, `previews/${id}.webp`]))
|
|
127
|
+
writeFileSync(resolve(out, 'index.json'), JSON.stringify(index, null, 2) + '\n')
|
|
128
|
+
return ok(`Built ${index.packs.length} pack(s) into ${resolve(out)}`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
err(`Unknown: mdeck themes ${sub}`)
|
|
132
|
+
tip(`Use: mdeck themes ${USAGE}`)
|
|
133
|
+
process.exitCode = 1
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (!(error instanceof PackError || error instanceof ManifestError)) throw error
|
|
136
|
+
err(error.message)
|
|
137
|
+
process.exitCode = 1
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { h } from 'preact'
|
|
2
|
+
import { useState, useMemo, useRef, useEffect } from 'preact/hooks'
|
|
3
|
+
import Prism from 'prismjs'
|
|
4
|
+
import 'prismjs/components/prism-javascript'
|
|
5
|
+
import 'prismjs/components/prism-typescript'
|
|
6
|
+
import 'prismjs/components/prism-jsx'
|
|
7
|
+
import 'prismjs/components/prism-python'
|
|
8
|
+
import 'prismjs/components/prism-bash'
|
|
9
|
+
import 'prismjs/components/prism-css'
|
|
10
|
+
import 'prismjs/components/prism-markup'
|
|
11
|
+
import 'prismjs/components/prism-json'
|
|
12
|
+
import 'prismjs/components/prism-yaml'
|
|
13
|
+
import 'prismjs/components/prism-sql'
|
|
14
|
+
import { Icon } from './Icon.jsx'
|
|
15
|
+
import './code-block.css'
|
|
16
|
+
|
|
17
|
+
// ─── Syntax highlighting ───────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function hl(code, lang) {
|
|
20
|
+
const g = Prism.languages[lang]
|
|
21
|
+
if (!g) return code.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
22
|
+
return Prism.highlight(code, g, lang)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── Execution engines ─────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
async function execJS(code) {
|
|
28
|
+
const lines = []
|
|
29
|
+
const con = {
|
|
30
|
+
log: (...a) => lines.push(a.map(v => typeof v === 'object' ? JSON.stringify(v) : String(v)).join(' ')),
|
|
31
|
+
error: (...a) => lines.push(a.map(String).join(' ')),
|
|
32
|
+
warn: (...a) => lines.push(a.map(String).join(' ')),
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const fn = new Function('console', `return (async()=>{\n${code}\n})()`)
|
|
36
|
+
const result = await fn(con)
|
|
37
|
+
if (result !== undefined && !lines.length) lines.push(String(result))
|
|
38
|
+
return { output: lines.join('\n') }
|
|
39
|
+
} catch (e) {
|
|
40
|
+
return { error: e.message }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let _pyodide = null
|
|
45
|
+
let _pyodideLoading = null
|
|
46
|
+
async function execPython(code) {
|
|
47
|
+
try {
|
|
48
|
+
if (!_pyodide) {
|
|
49
|
+
if (!_pyodideLoading) {
|
|
50
|
+
_pyodideLoading = new Promise((res, rej) => {
|
|
51
|
+
const s = Object.assign(document.createElement('script'), {
|
|
52
|
+
src: 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js',
|
|
53
|
+
onload: res,
|
|
54
|
+
onerror: () => rej(new Error('Failed to load Pyodide — serve the file over HTTP to run Python.')),
|
|
55
|
+
})
|
|
56
|
+
document.head.appendChild(s)
|
|
57
|
+
}).then(() => window.loadPyodide())
|
|
58
|
+
}
|
|
59
|
+
_pyodide = await _pyodideLoading
|
|
60
|
+
}
|
|
61
|
+
let out = ''
|
|
62
|
+
_pyodide.setStdout({ batched: s => { out += s + '\n' } })
|
|
63
|
+
_pyodide.setStderr({ batched: s => { out += s + '\n' } })
|
|
64
|
+
// Packages the code imports (numpy, pandas …) come from Pyodide's CDN.
|
|
65
|
+
await _pyodide.loadPackagesFromImports(code)
|
|
66
|
+
const r = await _pyodide.runPythonAsync(code)
|
|
67
|
+
if (r != null && !out.trim()) out = String(r)
|
|
68
|
+
return { output: out.trim() }
|
|
69
|
+
} catch (e) {
|
|
70
|
+
_pyodideLoading = null
|
|
71
|
+
return { error: e.message }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Component ─────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export default function CodeBlock({ lang = 'text', children = '', live, copy, editable }) {
|
|
78
|
+
const isLive = live != null
|
|
79
|
+
const isCopy = copy != null
|
|
80
|
+
const isEditable = editable != null
|
|
81
|
+
const isPython = lang === 'python' || lang === 'py'
|
|
82
|
+
|
|
83
|
+
const [code, setCode] = useState(() => String(children).trim())
|
|
84
|
+
const [output, setOutput] = useState(null)
|
|
85
|
+
const [isError, setIsError] = useState(false)
|
|
86
|
+
const [running, setRunning] = useState(false)
|
|
87
|
+
const [copied, setCopied] = useState(false)
|
|
88
|
+
|
|
89
|
+
const highlighted = useMemo(() => hl(code, lang), [code, lang])
|
|
90
|
+
const highlightRef = useRef(null)
|
|
91
|
+
const wrapperRef = useRef(null)
|
|
92
|
+
|
|
93
|
+
// Output takes room on the slide: the deck fits the slide again, so code
|
|
94
|
+
// and output share its height (src/runtime/fit.js).
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
wrapperRef.current?.dispatchEvent(new CustomEvent('mdeck:refit', { bubbles: true }))
|
|
97
|
+
}, [output])
|
|
98
|
+
|
|
99
|
+
function syncScroll(e) {
|
|
100
|
+
if (highlightRef.current) {
|
|
101
|
+
highlightRef.current.scrollTop = e.currentTarget.scrollTop
|
|
102
|
+
highlightRef.current.scrollLeft = e.currentTarget.scrollLeft
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function handleRun() {
|
|
107
|
+
setRunning(true)
|
|
108
|
+
setOutput(null)
|
|
109
|
+
setIsError(false)
|
|
110
|
+
const result = await (isPython ? execPython : execJS)(code)
|
|
111
|
+
setRunning(false)
|
|
112
|
+
setIsError(!!result.error)
|
|
113
|
+
setOutput(result.error ?? result.output ?? '')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function handleCopy() {
|
|
117
|
+
try { await navigator.clipboard.writeText(code) } catch {}
|
|
118
|
+
setCopied(true)
|
|
119
|
+
setTimeout(() => setCopied(false), 1500)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div class="code-block-wrapper" ref={wrapperRef}>
|
|
124
|
+
{isEditable ? (
|
|
125
|
+
<div class="code-editable-container">
|
|
126
|
+
<pre ref={highlightRef} class={`code-block code-editable-highlight language-${lang}`} aria-hidden="true">
|
|
127
|
+
<code dangerouslySetInnerHTML={{ __html: highlighted }} />
|
|
128
|
+
</pre>
|
|
129
|
+
<textarea
|
|
130
|
+
class="code-block code-editable-input"
|
|
131
|
+
value={code}
|
|
132
|
+
onInput={e => setCode(e.currentTarget.value)}
|
|
133
|
+
onScroll={syncScroll}
|
|
134
|
+
spellcheck={false}
|
|
135
|
+
autocomplete="off"
|
|
136
|
+
autocorrect="off"
|
|
137
|
+
autocapitalize="off"
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
) : (
|
|
141
|
+
<pre class={`code-block language-${lang}`}>
|
|
142
|
+
<code dangerouslySetInnerHTML={{ __html: highlighted }} />
|
|
143
|
+
</pre>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
{(isCopy || isLive) && (
|
|
147
|
+
<div class="code-block-actions">
|
|
148
|
+
{isCopy && (
|
|
149
|
+
<button class="code-btn" onClick={handleCopy} title={copied ? 'Copied!' : 'Copy'}>
|
|
150
|
+
<Icon name={copied ? 'check' : 'copy'} size={18} />
|
|
151
|
+
</button>
|
|
152
|
+
)}
|
|
153
|
+
{isLive && (
|
|
154
|
+
<button class="code-btn code-btn--run" onClick={handleRun} disabled={running} title="Run">
|
|
155
|
+
{running ? <Icon name="loading" size={18} class="icon code-btn-spin" /> : <Icon name="play" size={18} />}
|
|
156
|
+
</button>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
|
|
161
|
+
{output !== null && (
|
|
162
|
+
<pre class={`code-output${isError ? ' code-output--error' : ''}`}>
|
|
163
|
+
{output || '(no output)'}
|
|
164
|
+
</pre>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { h } from 'preact'
|
|
2
|
+
import { Icon } from './Icon.jsx'
|
|
3
|
+
import { devicesText, phonesText } from '../live/presence.js'
|
|
4
|
+
|
|
5
|
+
// Who is connected to the deck, as small icons: one per kind of view with a
|
|
6
|
+
// count when there are several, and the people whose phones can answer polls. The
|
|
7
|
+
// tooltip says which devices. `presence` is { views: [{ view, device }],
|
|
8
|
+
// phones } from the room server (src/live/rooms.js).
|
|
9
|
+
|
|
10
|
+
const VIEW_ICON = { presenter: 'presenter', audience: 'projector', deck: 'slides' }
|
|
11
|
+
const VIEW_NAME = { presenter: 'Presenter view', audience: 'Audience window', deck: 'Deck window' }
|
|
12
|
+
|
|
13
|
+
export function ConnectedViews({ presence, phones = false, size = 14, style }) {
|
|
14
|
+
if (!presence) return null
|
|
15
|
+
const kinds = Object.keys(VIEW_ICON).filter(view => presence.views.some(entry => entry.view === view))
|
|
16
|
+
const items = kinds.map(view => ({ icon: VIEW_ICON[view], count: presence.views.filter(entry => entry.view === view).length, text: `${VIEW_NAME[view]} on ${devicesText(presence.views.filter(entry => entry.view === view))}` }))
|
|
17
|
+
if (phones || presence.phones) items.push({ icon: 'people', count: presence.phones, text: phonesText(presence.phones), always: true })
|
|
18
|
+
if (!items.length) return null
|
|
19
|
+
const summary = `Connected: ${items.map(item => item.text).join(', ')}`
|
|
20
|
+
return <span class="connected-views" role="status" aria-label={summary} title={summary} style={{ display: 'inline-flex', alignItems: 'center', gap: '10px', fontSize: '12px', fontVariantNumeric: 'tabular-nums', ...style }}>
|
|
21
|
+
{items.map(item => <span key={item.icon} title={item.text} style={{ display: 'inline-flex', alignItems: 'center', gap: '3px', opacity: item.always && !item.count ? 0.5 : 1 }}>
|
|
22
|
+
<Icon name={item.icon} size={size} />{(item.count > 1 || item.always) && <span>{item.count}</span>}
|
|
23
|
+
</span>)}
|
|
24
|
+
</span>
|
|
25
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { h } from 'preact'
|
|
2
|
+
import { ICONS, ICON_STROKE } from '../core/icons.js'
|
|
3
|
+
|
|
4
|
+
// One icon from the shared set (src/core/icons.js). It takes the text colour,
|
|
5
|
+
// so a button only has to set `color`.
|
|
6
|
+
export function Icon({ name, size = 16, class: className = 'icon', style }) {
|
|
7
|
+
return <svg class={className} viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" stroke-width={ICON_STROKE} stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style={style} dangerouslySetInnerHTML={{ __html: ICONS[name] }} />
|
|
8
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { h } from 'preact'
|
|
2
|
+
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
3
|
+
import { useRoom, latestByDevice } from '../live/client.js'
|
|
4
|
+
import { t } from '../core/labels.js'
|
|
5
|
+
import { JoinCode, ActivityFooter, wantsQr } from './activity.jsx'
|
|
6
|
+
import './poll.css'
|
|
7
|
+
|
|
8
|
+
// <poll room="lunch" question="Where do we eat?" options="Mensa|Thai|Pizza" />
|
|
9
|
+
// On the slide: live bars, the number of answers and the deck's join code
|
|
10
|
+
// (`qr="false"` leaves it out, when the code was on an earlier slide).
|
|
11
|
+
// On the phones, the server's answer page shows one button per option,
|
|
12
|
+
// as described by Poll.phone. Each device has one vote and can change it; the
|
|
13
|
+
// latest one counts.
|
|
14
|
+
//
|
|
15
|
+
// `answer="Thai"` (several: "Thai|Pizza") marks the right answer: a button
|
|
16
|
+
// with a tick outlines its label, bar and count in green. Showing it is an
|
|
17
|
+
// event on the stage (`pollreveal`, { room, shown }), which the ink bus
|
|
18
|
+
// passes to the audience window and other devices.
|
|
19
|
+
|
|
20
|
+
const choicesOf = options => String(options ?? '').split('|').map(option => option.trim()).filter(Boolean)
|
|
21
|
+
|
|
22
|
+
export default function Poll({ room = 'poll', question = '', options = '', qr, answer = '' }) {
|
|
23
|
+
const choices = choicesOf(options)
|
|
24
|
+
const correct = choicesOf(answer).filter(choice => choices.includes(choice))
|
|
25
|
+
const [shown, setShown] = useState(false)
|
|
26
|
+
const ref = useRef()
|
|
27
|
+
const stage = () => ref.current?.closest('deck-stage')
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const target = stage()
|
|
30
|
+
if (!target || !correct.length) return
|
|
31
|
+
const onReveal = event => { if (event.detail?.room === room) setShown(!!event.detail.shown) }
|
|
32
|
+
target.addEventListener('pollreveal', onReveal)
|
|
33
|
+
return () => target.removeEventListener('pollreveal', onReveal)
|
|
34
|
+
}, [room, correct.length])
|
|
35
|
+
const toggle = () => {
|
|
36
|
+
const next = !shown
|
|
37
|
+
setShown(next)
|
|
38
|
+
stage()?.dispatchEvent(new CustomEvent('pollreveal', { detail: { room, shown: next, local: true } }))
|
|
39
|
+
}
|
|
40
|
+
const live = useRoom(room)
|
|
41
|
+
const votes = latestByDevice(live.messages).map(message => message.data?.value).filter(vote => choices.includes(vote))
|
|
42
|
+
const counts = choices.map(choice => votes.filter(vote => vote === choice).length)
|
|
43
|
+
const max = Math.max(1, ...counts)
|
|
44
|
+
const code = wantsQr(qr)
|
|
45
|
+
const still = live.offline && !votes.length
|
|
46
|
+
return <div class={`poll${shown ? ' is-solved' : ''}${still ? ' is-static' : ''}`} ref={ref}>
|
|
47
|
+
<div class="poll-main">
|
|
48
|
+
{question && <p class="poll-question">{question}</p>}
|
|
49
|
+
<div class="poll-bars" role="list">
|
|
50
|
+
{choices.map((choice, i) => <div class={`poll-row${correct.includes(choice) ? ' is-correct' : ''}`} role="listitem" key={choice}>
|
|
51
|
+
<span class="poll-label">{choice}</span>
|
|
52
|
+
<span class="poll-track"><span class="poll-fill" style={{ width: `${(counts[i] / max) * 100}%` }} /></span>
|
|
53
|
+
<span class="poll-count">{counts[i]}</span>
|
|
54
|
+
</div>)}
|
|
55
|
+
</div>
|
|
56
|
+
<ActivityFooter count={votes.length} {...live}>
|
|
57
|
+
{correct.length > 0 && <button type="button" class={`poll-solve${shown ? ' is-on' : ''}`} title={t(shown ? 'poll.hideAnswer' : 'poll.showAnswer')} aria-label={t(shown ? 'poll.hideAnswer' : 'poll.showAnswer')} aria-pressed={shown} onClick={toggle}>
|
|
58
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12l5 5 9-10" /></svg>
|
|
59
|
+
</button>}
|
|
60
|
+
</ActivityFooter>
|
|
61
|
+
</div>
|
|
62
|
+
<JoinCode room={room} {...live} qr={code} />
|
|
63
|
+
</div>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// What the phones show while this poll is on screen.
|
|
67
|
+
Poll.phone = ({ question, options }, { slideTitle = '' } = {}) => ({ type: 'choice', question: question || slideTitle, options: choicesOf(options) })
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { h } from 'preact'
|
|
2
|
+
import { useMemo } from 'preact/hooks'
|
|
3
|
+
import QRCode from 'qrcode'
|
|
4
|
+
|
|
5
|
+
// <qrcode url="https://example.com" size="240" />
|
|
6
|
+
// Drawn in the slide's colours: the dark squares in the text colour, on the
|
|
7
|
+
// slide's own background, so it fits every theme and palette. Phone cameras
|
|
8
|
+
// read light-on-dark codes too. `--qr-ink` and `--qr-bg` override the colours,
|
|
9
|
+
// for example `--qr-bg: #fff` when a scanner needs a white background.
|
|
10
|
+
// An SVG, drawn at once, so it also prints and scales without blurring.
|
|
11
|
+
export default function QrCode({ url, size = '200' }) {
|
|
12
|
+
const code = useMemo(() => {
|
|
13
|
+
if (!url) return null
|
|
14
|
+
try {
|
|
15
|
+
const { modules } = QRCode.create(String(url), { errorCorrectionLevel: 'M' })
|
|
16
|
+
let d = ''
|
|
17
|
+
for (let y = 0; y < modules.size; y++) {
|
|
18
|
+
for (let x = 0; x < modules.size; x++) if (modules.get(x, y)) d += `M${x} ${y}h1v1h-1z`
|
|
19
|
+
}
|
|
20
|
+
return { d, size: modules.size }
|
|
21
|
+
} catch { return null }
|
|
22
|
+
}, [url])
|
|
23
|
+
if (!code) return null
|
|
24
|
+
const quiet = 2, box = code.size + quiet * 2
|
|
25
|
+
return <svg class="qrcode" viewBox={`${-quiet} ${-quiet} ${box} ${box}`} width={Number(size)} height={Number(size)} role="img" aria-label={url} shape-rendering="crispEdges"
|
|
26
|
+
style={{ display: 'block', background: 'var(--qr-bg, transparent)', color: 'var(--qr-ink, var(--ink, currentColor))', borderRadius: '6px' }}>
|
|
27
|
+
<path d={code.d} fill="currentColor" />
|
|
28
|
+
</svg>
|
|
29
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { h } from 'preact'
|
|
2
|
+
import QrCode from './QrCode.jsx'
|
|
3
|
+
import { useJoinLink } from '../live/client.js'
|
|
4
|
+
import { JoinCode } from './activity.jsx'
|
|
5
|
+
import { t } from '../core/labels.js'
|
|
6
|
+
import './poll.css'
|
|
7
|
+
|
|
8
|
+
// <qrcode url="https://example.com" size="240" /> on a slide.
|
|
9
|
+
// <qrcode join />: the deck's join link for audience questions, with the
|
|
10
|
+
// short link below it, so a slide early in the talk can invite everyone once
|
|
11
|
+
// and the questions after it can leave out their own code (`qr="false"`).
|
|
12
|
+
|
|
13
|
+
function JoinQr({ size }) {
|
|
14
|
+
const link = useJoinLink()
|
|
15
|
+
return <div class="poll-join-slide">
|
|
16
|
+
{link.offline && <p class="poll-static">{t('join.static')}</p>}
|
|
17
|
+
<JoinCode {...link} size={Number(size) || 420} label={t('join.scan')} />
|
|
18
|
+
</div>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A bare `join` attribute arrives as "", so only "false" and the like turn it off.
|
|
22
|
+
const isOn = value => value != null && !/^(false|no|off|0)$/i.test(String(value))
|
|
23
|
+
|
|
24
|
+
export default function QrCodeTag({ url, join, size }) {
|
|
25
|
+
if (isOn(join)) return <JoinQr size={size} />
|
|
26
|
+
return <QrCode url={url} size={size ?? '200'} />
|
|
27
|
+
}
|