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,268 @@
|
|
|
1
|
+
// Packs: themes and palettes shared through the theme repository
|
|
2
|
+
// (https://gh.tschieber.de/mdeck-themes/). A pack is a folder with pack.toml
|
|
3
|
+
// and one folder per theme or palette; it travels as one JSON file of text
|
|
4
|
+
// files, so installing it is checking it and writing those files. Only
|
|
5
|
+
// extension.toml and styles.css are allowed: a pack never carries code.
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { resolve, relative } from 'node:path'
|
|
9
|
+
import { parse } from 'smol-toml'
|
|
10
|
+
import { ID_RE, ManifestError, parseManifestText, validateManifest } from './manifest.js'
|
|
11
|
+
|
|
12
|
+
export const INDEX_URL = 'https://gh.tschieber.de/mdeck-themes/index.json'
|
|
13
|
+
export const MARKER = '.mdeck-pack.json'
|
|
14
|
+
export const PACK_FILE = 'pack.toml'
|
|
15
|
+
const FILES = ['extension.toml', 'styles.css']
|
|
16
|
+
const FILE_RE = /^([a-z][a-z0-9-]*)\/(extension\.toml|styles\.css)$/
|
|
17
|
+
const VERSION_RE = /^\d+\.\d+\.\d+$/
|
|
18
|
+
const RANGE_RE = /^>=\s*(\d+\.\d+\.\d+)$/
|
|
19
|
+
// Where theme fonts may come from: stylesheet services that only serve fonts.
|
|
20
|
+
export const FONT_HOSTS = ['https://fonts.googleapis.com/', 'https://fonts.bunny.net/']
|
|
21
|
+
|
|
22
|
+
export class PackError extends Error {}
|
|
23
|
+
const fail = message => { throw new PackError(message) }
|
|
24
|
+
|
|
25
|
+
export const sha256 = text => createHash('sha256').update(text, 'utf8').digest('hex')
|
|
26
|
+
|
|
27
|
+
export function indexUrl() {
|
|
28
|
+
return process.env.MDECK_THEMES_URL || INDEX_URL
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const parts = version => version.split('.').map(Number)
|
|
32
|
+
export function compareVersions(a, b) {
|
|
33
|
+
const [x, y] = [parts(a), parts(b)]
|
|
34
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i]
|
|
35
|
+
return 0
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Ranges are only of the form ">=1.2.3"; no range means any version.
|
|
39
|
+
export function satisfies(range, version) {
|
|
40
|
+
if (!range) return true
|
|
41
|
+
const match = RANGE_RE.exec(range)
|
|
42
|
+
return Boolean(match) && compareVersions(version, match[1]) >= 0
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function checkMeta(meta, where) {
|
|
46
|
+
if (meta.schema !== 1) fail(`${where}: schema must be 1`)
|
|
47
|
+
if (typeof meta.id !== 'string' || !ID_RE.test(meta.id)) fail(`${where}: id must use lowercase letters, digits and hyphens, starting with a letter`)
|
|
48
|
+
if (typeof meta.version !== 'string' || !VERSION_RE.test(meta.version)) fail(`${where}: version must look like 1.0.0`)
|
|
49
|
+
if (meta.mdeck != null && (typeof meta.mdeck !== 'string' || !RANGE_RE.test(meta.mdeck))) fail(`${where}: mdeck must look like ">=2.3.0"`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// pack.toml and the files of each theme or palette in a pack folder.
|
|
53
|
+
export function readPackDir(dir) {
|
|
54
|
+
const file = resolve(dir, PACK_FILE)
|
|
55
|
+
if (!existsSync(file)) fail(`${dir} has no ${PACK_FILE}`)
|
|
56
|
+
let meta
|
|
57
|
+
try { meta = parse(readFileSync(file, 'utf8')) } catch (error) { fail(`${file}: ${error.message.split('\n')[0]}`) }
|
|
58
|
+
checkMeta(meta, file)
|
|
59
|
+
for (const key of ['title', 'author', 'license']) if (typeof meta[key] !== 'string' || !meta[key].trim()) fail(`${file}: ${key} is required`)
|
|
60
|
+
const folder = dir.split(/[\\/]/).filter(Boolean).at(-1)
|
|
61
|
+
if (meta.id !== folder) fail(`${file}: id "${meta.id}" must be the folder name "${folder}"`)
|
|
62
|
+
const files = {}
|
|
63
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
64
|
+
if (entry === PACK_FILE || entry.startsWith('.') || entry === 'README.md') continue
|
|
65
|
+
const sub = resolve(dir, entry)
|
|
66
|
+
if (!statSync(sub).isDirectory()) fail(`${sub}: a pack holds only ${PACK_FILE}, README.md and one folder per theme or palette`)
|
|
67
|
+
for (const name of readdirSync(sub).sort()) {
|
|
68
|
+
if (!FILES.includes(name)) fail(`${resolve(sub, name)}: a theme or palette in a pack holds only ${FILES.join(' and ')}`)
|
|
69
|
+
files[`${entry}/${name}`] = readFileSync(resolve(sub, name), 'utf8')
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { meta, files }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The published form of a pack folder: one JSON text and its checksum.
|
|
76
|
+
export function bundlePack(dir) {
|
|
77
|
+
const { meta, files } = readPackDir(dir)
|
|
78
|
+
const bundle = { schema: 1, id: meta.id, version: meta.version, ...(meta.mdeck ? { mdeck: meta.mdeck } : {}), files }
|
|
79
|
+
const text = JSON.stringify(bundle, null, 1) + '\n'
|
|
80
|
+
return { meta, bundle, text, sha256: sha256(text) }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function checkCss(text, where) {
|
|
84
|
+
if (/@import/i.test(text)) fail(`${where}: @import is not allowed; list font stylesheets under fonts in extension.toml`)
|
|
85
|
+
for (const match of text.matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi)) {
|
|
86
|
+
if (!match[2].trim().startsWith('data:')) fail(`${where}: url(${match[2]}) is not allowed; only data: URLs`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Checks a pack before it is published or installed. `known` is the registry
|
|
91
|
+
// it will join (built-in extensions at least), for palettes a theme names.
|
|
92
|
+
// Returns the ids it holds, as { themes, palettes }.
|
|
93
|
+
export function checkPack(bundle, { known = { themes: {}, palettes: {} } } = {}) {
|
|
94
|
+
if (!bundle || typeof bundle !== 'object' || typeof bundle.files !== 'object' || !bundle.files) fail('Not a pack')
|
|
95
|
+
checkMeta(bundle, `Pack ${bundle.id ?? '?'}`)
|
|
96
|
+
const byId = {}
|
|
97
|
+
for (const [path, text] of Object.entries(bundle.files)) {
|
|
98
|
+
const match = FILE_RE.exec(path)
|
|
99
|
+
if (!match) fail(`${bundle.id}: "${path}" is not allowed; a pack holds only <id>/extension.toml and <id>/styles.css`)
|
|
100
|
+
if (typeof text !== 'string') fail(`${bundle.id}: ${path} is not text`)
|
|
101
|
+
;(byId[match[1]] ??= {})[match[2]] = text
|
|
102
|
+
}
|
|
103
|
+
if (!Object.keys(byId).length) fail(`${bundle.id}: the pack is empty`)
|
|
104
|
+
const records = []
|
|
105
|
+
for (const [id, files] of Object.entries(byId)) {
|
|
106
|
+
if (!files['extension.toml']) fail(`${bundle.id}: ${id}/extension.toml is missing`)
|
|
107
|
+
const dir = `/pack/${id}`
|
|
108
|
+
const file = `${dir}/extension.toml`
|
|
109
|
+
let record
|
|
110
|
+
try {
|
|
111
|
+
record = validateManifest(parseManifestText(files['extension.toml'], file), { file, dir, folderName: id, fileExists: path => Object.hasOwn(files, relative(dir, path)) })
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error instanceof ManifestError) fail(`${bundle.id}: ${id}/extension.toml: ${error.message}`)
|
|
114
|
+
throw error
|
|
115
|
+
}
|
|
116
|
+
if (!['theme', 'palette'].includes(record.kind)) fail(`${bundle.id}: ${id} is a ${record.kind}; packs hold only themes and palettes`)
|
|
117
|
+
if (files['styles.css'] !== undefined && record.kind !== 'theme') fail(`${bundle.id}: ${id}/styles.css belongs only to a theme`)
|
|
118
|
+
if (record.kind === 'theme') {
|
|
119
|
+
for (const url of record.manifest.fonts) if (!FONT_HOSTS.some(host => url.startsWith(host))) fail(`${bundle.id}: ${id} loads fonts from ${url}; allowed are ${FONT_HOSTS.join(', ')}`)
|
|
120
|
+
checkCss(files['styles.css'] ?? '', `${bundle.id}: ${id}/styles.css`)
|
|
121
|
+
}
|
|
122
|
+
if (known.themes[id] || known.palettes[id]) fail(`${bundle.id}: ${id} is the id of a built-in ${known.themes[id] ? 'theme' : 'palette'}`)
|
|
123
|
+
records.push(record)
|
|
124
|
+
}
|
|
125
|
+
const themes = records.filter(r => r.kind === 'theme').map(r => r.id)
|
|
126
|
+
const palettes = records.filter(r => r.kind === 'palette').map(r => r.id)
|
|
127
|
+
const hasPalette = id => palettes.includes(id) || Boolean(known.palettes[id])
|
|
128
|
+
const hasTheme = id => themes.includes(id) || Boolean(known.themes[id])
|
|
129
|
+
for (const record of records) {
|
|
130
|
+
if (record.kind === 'theme') for (const id of [record.manifest.palette, ...(record.manifest.palettes ?? [])]) if (!hasPalette(id)) fail(`${bundle.id}: the theme ${record.id} names the palette "${id}", which is neither in the pack nor built in`)
|
|
131
|
+
if (record.kind === 'palette' && record.manifest.theme && !hasTheme(record.manifest.theme)) fail(`${bundle.id}: the palette ${record.id} belongs to the theme "${record.manifest.theme}", which is neither in the pack nor built in`)
|
|
132
|
+
}
|
|
133
|
+
return { themes, palettes }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readMarker(dir) {
|
|
137
|
+
try { return JSON.parse(readFileSync(resolve(dir, MARKER), 'utf8')) } catch { return null }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Files changed since the pack was installed, by name.
|
|
141
|
+
function changedFiles(dir, marker) {
|
|
142
|
+
return Object.entries(marker.files ?? {}).filter(([name, hash]) => {
|
|
143
|
+
const file = resolve(dir, name)
|
|
144
|
+
return !existsSync(file) || sha256(readFileSync(file, 'utf8')) !== hash
|
|
145
|
+
}).map(([name]) => name)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The packs installed in an extensions folder: { id: { version, url, folders } }.
|
|
149
|
+
export function installedPacks(root) {
|
|
150
|
+
const packs = {}
|
|
151
|
+
if (!existsSync(root)) return packs
|
|
152
|
+
for (const entry of readdirSync(root).sort()) {
|
|
153
|
+
const marker = readMarker(resolve(root, entry))
|
|
154
|
+
if (!marker?.pack) continue
|
|
155
|
+
const pack = packs[marker.pack] ??= { version: marker.version, url: marker.url, folders: [] }
|
|
156
|
+
pack.folders.push(entry)
|
|
157
|
+
}
|
|
158
|
+
return packs
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const refuseChanged = (root, folder, marker) => {
|
|
162
|
+
const changed = changedFiles(resolve(root, folder), marker)
|
|
163
|
+
if (changed.length) fail(`${resolve(root, folder)} was changed since it was installed (${changed.join(', ')}); add --force to replace or remove it anyway`)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Writes a checked pack into an extensions folder (a deck's, or the user's).
|
|
167
|
+
// A folder of another pack or of the deck itself is never touched; a folder
|
|
168
|
+
// of this pack is replaced unless it was changed by hand.
|
|
169
|
+
export function installPack(bundle, { root, known, url = null, digest = null, force = false } = {}) {
|
|
170
|
+
const { themes, palettes } = checkPack(bundle, { known })
|
|
171
|
+
const ids = [...themes, ...palettes]
|
|
172
|
+
const before = installedPacks(root)[bundle.id]
|
|
173
|
+
for (const id of ids) {
|
|
174
|
+
const dir = resolve(root, id)
|
|
175
|
+
if (!existsSync(dir)) continue
|
|
176
|
+
const marker = readMarker(dir)
|
|
177
|
+
if (marker?.pack !== bundle.id) fail(`${dir} already exists and is not from the pack ${bundle.id}; rename or remove it first`)
|
|
178
|
+
if (!force) refuseChanged(root, id, marker)
|
|
179
|
+
}
|
|
180
|
+
const gone = (before?.folders ?? []).filter(folder => !ids.includes(folder))
|
|
181
|
+
if (!force) for (const folder of gone) refuseChanged(root, folder, readMarker(resolve(root, folder)))
|
|
182
|
+
for (const folder of gone) rmSync(resolve(root, folder), { recursive: true, force: true })
|
|
183
|
+
for (const id of ids) {
|
|
184
|
+
// Files are overwritten in place, not deleted and written again, so a
|
|
185
|
+
// running editor sees a change instead of a file that disappears.
|
|
186
|
+
const dir = resolve(root, id)
|
|
187
|
+
mkdirSync(dir, { recursive: true })
|
|
188
|
+
const hashes = {}
|
|
189
|
+
for (const [path, content] of Object.entries(bundle.files)) {
|
|
190
|
+
if (!path.startsWith(`${id}/`)) continue
|
|
191
|
+
const name = path.slice(id.length + 1)
|
|
192
|
+
writeFileSync(resolve(dir, name), content, 'utf8')
|
|
193
|
+
hashes[name] = sha256(content)
|
|
194
|
+
}
|
|
195
|
+
for (const name of readdirSync(dir)) if (name !== MARKER && !Object.hasOwn(hashes, name)) rmSync(resolve(dir, name), { recursive: true, force: true })
|
|
196
|
+
writeFileSync(resolve(dir, MARKER), JSON.stringify({ pack: bundle.id, version: bundle.version, url, sha256: digest, files: hashes }, null, 2) + '\n')
|
|
197
|
+
}
|
|
198
|
+
return { themes, palettes, replaced: before?.version ?? null }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function removePack(id, { root, force = false } = {}) {
|
|
202
|
+
const pack = installedPacks(root)[id]
|
|
203
|
+
if (!pack) fail(`The pack ${id} is not installed in ${root}`)
|
|
204
|
+
if (!force) for (const folder of pack.folders) refuseChanged(root, folder, readMarker(resolve(root, folder)))
|
|
205
|
+
for (const folder of pack.folders) rmSync(resolve(root, folder), { recursive: true, force: true })
|
|
206
|
+
return pack
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function get(url, as) {
|
|
210
|
+
let response
|
|
211
|
+
try { response = await fetch(url) } catch (error) { fail(`Could not reach ${url}: ${error.cause?.message ?? error.message}`) }
|
|
212
|
+
if (!response.ok) fail(`${url} answered ${response.status}`)
|
|
213
|
+
return as === 'json' ? response.json() : response.text()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function fetchIndex(url = indexUrl()) {
|
|
217
|
+
const index = await get(url, 'json')
|
|
218
|
+
if (index?.schema !== 1 || !Array.isArray(index.packs)) fail(`${url} is not a theme repository index`)
|
|
219
|
+
return { url, packs: index.packs }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Downloads a pack listed in the index and checks that it is the one listed.
|
|
223
|
+
export async function fetchPack(index, entry) {
|
|
224
|
+
const url = new URL(entry.url, index.url).href
|
|
225
|
+
const text = await get(url, 'text')
|
|
226
|
+
if (sha256(text) !== entry.sha256) fail(`${url} does not match its checksum in the index; not installed`)
|
|
227
|
+
let bundle
|
|
228
|
+
try { bundle = JSON.parse(text) } catch { fail(`${url} is not a pack`) }
|
|
229
|
+
if (bundle.id !== entry.id || bundle.version !== entry.version) fail(`${url} holds ${bundle.id} ${bundle.version}, not ${entry.id} ${entry.version}`)
|
|
230
|
+
return { bundle, url, digest: entry.sha256 }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Installs a pack listed in the index: refused when this mdeck is too old.
|
|
234
|
+
export async function installFromIndex(index, entry, { root, known, mdeckVersion, force = false }) {
|
|
235
|
+
if (!satisfies(entry.mdeck, mdeckVersion)) fail(`${entry.id} ${entry.version} needs mdeck ${entry.mdeck}; this is ${mdeckVersion}. Update mdeck first: npm install -g mdeck`)
|
|
236
|
+
const { bundle, url, digest } = await fetchPack(index, entry)
|
|
237
|
+
return installPack(bundle, { root, known, url, digest, force })
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function findPack(index, id) {
|
|
241
|
+
return index.packs.find(pack => pack.id === id) ?? fail(`There is no pack "${id}" in ${index.url}; mdeck themes search lists them`)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// The theme repository as served: index.json and packs/<id>-<version>.json,
|
|
245
|
+
// from a folder of pack folders. Each pack is checked on its own against the
|
|
246
|
+
// built-in extensions, since it is installed alone, and no two packs may
|
|
247
|
+
// share an id. Previews are added by the caller.
|
|
248
|
+
export function buildRepository(packsDir, outDir, { known }) {
|
|
249
|
+
const entries = []
|
|
250
|
+
const owner = {}
|
|
251
|
+
const dirs = readdirSync(packsDir).filter(name => !name.startsWith('.') && statSync(resolve(packsDir, name)).isDirectory()).sort()
|
|
252
|
+
mkdirSync(resolve(outDir, 'packs'), { recursive: true })
|
|
253
|
+
for (const name of dirs) {
|
|
254
|
+
const { meta, bundle, text, sha256: digest } = bundlePack(resolve(packsDir, name))
|
|
255
|
+
const { themes, palettes } = checkPack(bundle, { known })
|
|
256
|
+
for (const id of [...themes, ...palettes]) {
|
|
257
|
+
if (owner[id]) fail(`${id} is in both ${owner[id]} and ${meta.id}`)
|
|
258
|
+
owner[id] = meta.id
|
|
259
|
+
}
|
|
260
|
+
const file = `packs/${meta.id}-${meta.version}.json`
|
|
261
|
+
writeFileSync(resolve(outDir, file), text)
|
|
262
|
+
entries.push({ id: meta.id, title: meta.title, description: meta.description ?? '', version: meta.version, author: meta.author, license: meta.license,
|
|
263
|
+
...(meta.homepage ? { homepage: meta.homepage } : {}), ...(meta.mdeck ? { mdeck: meta.mdeck } : {}), themes, palettes, url: file, sha256: digest })
|
|
264
|
+
}
|
|
265
|
+
const index = { schema: 1, packs: entries }
|
|
266
|
+
writeFileSync(resolve(outDir, 'index.json'), JSON.stringify(index, null, 2) + '\n')
|
|
267
|
+
return index
|
|
268
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Browser-safe helpers shared by the runtime, the CLI and tests.
|
|
2
|
+
|
|
3
|
+
export const TOKEN_NAME_RE = /^--[a-z][a-z0-9-]*$/
|
|
4
|
+
|
|
5
|
+
export function tokensToCss(tokens = {}, selector = ':root') {
|
|
6
|
+
const entries = Object.entries(tokens)
|
|
7
|
+
if (!entries.length) return ''
|
|
8
|
+
return `${selector} {\n ${entries.map(([name, value]) => `${name}: ${value};`).join('\n ')}\n}`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// The colours every palette variant defines. Themes use only these (and
|
|
12
|
+
// mixes of them), so any palette fits any theme.
|
|
13
|
+
export const COLOR_ROLES = ['--bg', '--surface', '--ink', '--ink-soft', '--muted', '--rule', '--accent', '--accent-2', '--on-accent']
|
|
14
|
+
export const APPEARANCES = ['light', 'dark']
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The palettes a theme offers, as manifests: the ones it lists in `palettes`,
|
|
18
|
+
* else every palette that is not private to another theme. A private palette
|
|
19
|
+
* (`theme = "…"`) is only offered to its own theme.
|
|
20
|
+
*/
|
|
21
|
+
export function palettesFor(theme, palettes = {}) {
|
|
22
|
+
const all = Object.values(palettes)
|
|
23
|
+
if (theme?.palettes) return theme.palettes.map(id => palettes[id]).filter(Boolean)
|
|
24
|
+
return all.filter(palette => !palette.theme || palette.theme === theme?.id)
|
|
25
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { h } from 'preact'
|
|
2
|
+
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
3
|
+
import mark from '../../assets/logo/mark.svg'
|
|
4
|
+
import QrCode from '../components/QrCode.jsx'
|
|
5
|
+
import { Icon } from '../components/Icon.jsx'
|
|
6
|
+
import { ConnectedViews } from '../components/ConnectedViews.jsx'
|
|
7
|
+
import { devicesText, phonesText } from '../live/presence.js'
|
|
8
|
+
import { devPath } from '../core/devPath.js'
|
|
9
|
+
|
|
10
|
+
// Where the dev server serves from: / normally, /t/<id>/ with `mdeck run --server`.
|
|
11
|
+
const BASE = devPath('')
|
|
12
|
+
|
|
13
|
+
// The launch page `mdeck run` opens: every way to show, edit, share and check
|
|
14
|
+
// the deck from one place. Data and actions come from /__mdeck/home.
|
|
15
|
+
|
|
16
|
+
async function api(path, body) {
|
|
17
|
+
const response = await fetch(devPath('__mdeck/home') + path, body
|
|
18
|
+
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
|
|
19
|
+
: { cache: 'no-store' })
|
|
20
|
+
const data = await response.json().catch(() => ({}))
|
|
21
|
+
if (!response.ok) throw new Error(data.error ?? `Request failed (${response.status})`)
|
|
22
|
+
return data
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const HomeIcon = ({ name, size = 18 }) => <Icon name={name} size={size} class="home-icon" />
|
|
26
|
+
|
|
27
|
+
function ago(iso) {
|
|
28
|
+
if (!iso) return ''
|
|
29
|
+
const seconds = Math.round((Date.now() - new Date(iso)) / 1000)
|
|
30
|
+
if (seconds < 45) return 'just now'
|
|
31
|
+
const minutes = Math.round(seconds / 60)
|
|
32
|
+
if (minutes < 60) return `${minutes} min ago`
|
|
33
|
+
const hours = Math.round(minutes / 60)
|
|
34
|
+
if (hours < 24) return `${hours} h ago`
|
|
35
|
+
return new Date(iso).toLocaleDateString()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Who is connected, asked of the deck's room server every few seconds; null
|
|
39
|
+
// until it answers. Asking holds no connection: browsers allow six per
|
|
40
|
+
// server, which the open deck windows need.
|
|
41
|
+
const PRESENCE_MS = 3000
|
|
42
|
+
function usePresence(url) {
|
|
43
|
+
const [presence, setPresence] = useState(null)
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!url) return
|
|
46
|
+
const ask = () => fetch(new URL(url, new URL(BASE, location.href)), { cache: 'no-store' })
|
|
47
|
+
.then(response => response.ok ? response.json() : null).catch(() => null).then(setPresence)
|
|
48
|
+
ask()
|
|
49
|
+
const timer = setInterval(ask, PRESENCE_MS)
|
|
50
|
+
return () => clearInterval(timer)
|
|
51
|
+
}, [url])
|
|
52
|
+
return presence
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ViewTile({ icon, title, text, href, open }) {
|
|
56
|
+
return <a class="home-tile" href={href} target="_blank" rel="noopener">
|
|
57
|
+
<span class="home-tile-icon"><HomeIcon name={icon} size={22} /></span>
|
|
58
|
+
<span class="home-tile-body"><strong>{title}{open && <span class="home-open">Open on {open}</span>}</strong><span>{text}</span></span>
|
|
59
|
+
<HomeIcon name="external" size={16} />
|
|
60
|
+
</a>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Opens the tab during the click so pop-up blockers allow it, then points it
|
|
64
|
+
// at the service (and `page` within it) once the server has started.
|
|
65
|
+
async function openService(action, page = '') {
|
|
66
|
+
const tab = window.open('about:blank', '_blank')
|
|
67
|
+
try {
|
|
68
|
+
const { url } = await api('/action', { action })
|
|
69
|
+
const target = new URL(page, url).href
|
|
70
|
+
if (tab) tab.location = target
|
|
71
|
+
else window.open(target, '_blank')
|
|
72
|
+
} catch (error) {
|
|
73
|
+
tab?.close()
|
|
74
|
+
throw error
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function ServiceTile({ icon, title, text, action, url, onError, onStarted }) {
|
|
79
|
+
const [starting, setStarting] = useState(false)
|
|
80
|
+
const launch = async () => {
|
|
81
|
+
setStarting(true)
|
|
82
|
+
try { await openService(action); onStarted() } catch (error) { onError(error.message) } finally { setStarting(false) }
|
|
83
|
+
}
|
|
84
|
+
return <button class="home-tile" onClick={launch} disabled={starting}>
|
|
85
|
+
<span class="home-tile-icon"><HomeIcon name={icon} size={22} /></span>
|
|
86
|
+
<span class="home-tile-body"><strong>{title}</strong><span>{starting ? 'Starting…' : url ? url.replace(/^https?:\/\//, '') : text}</span></span>
|
|
87
|
+
<HomeIcon name="external" size={16} />
|
|
88
|
+
</button>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A small live copy of the deck. The embedded deck hides its controls, takes
|
|
92
|
+
// next/previous commands from this page and reports its slide back; a click
|
|
93
|
+
// opens an audience window at that slide.
|
|
94
|
+
function Preview({ info }) {
|
|
95
|
+
const frame = useRef(null)
|
|
96
|
+
const [index, setIndex] = useState(0)
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const follow = event => {
|
|
99
|
+
if (event.source === frame.current?.contentWindow && Number.isInteger(event.data?.slideIndexChanged)) setIndex(event.data.slideIndexChanged)
|
|
100
|
+
}
|
|
101
|
+
window.addEventListener('message', follow)
|
|
102
|
+
return () => window.removeEventListener('message', follow)
|
|
103
|
+
}, [])
|
|
104
|
+
const control = command => frame.current?.contentWindow?.postMessage({ deckControl: { command } }, window.location.origin)
|
|
105
|
+
const audienceUrl = `${BASE}?view=audience&session=${SESSION}#${index + 1}`
|
|
106
|
+
return <section class="home-section">
|
|
107
|
+
<h2>Preview</h2>
|
|
108
|
+
<div class="home-preview" style={{ aspectRatio: `${info.width} / ${info.height}` }}>
|
|
109
|
+
<iframe ref={frame} src={`${BASE}?view=deck&embedded=1`} title="Deck preview" tabIndex={-1} />
|
|
110
|
+
<a class="home-preview-open" href={audienceUrl} target="_blank" rel="noopener" aria-label="Open the audience window" />
|
|
111
|
+
</div>
|
|
112
|
+
<div class="home-preview-bar">
|
|
113
|
+
<button class="home-btn home-btn--small" onClick={() => control('prev')} aria-label="Previous"><HomeIcon name="prev" size={14} /></button>
|
|
114
|
+
<button class="home-btn home-btn--small" onClick={() => control('next')} aria-label="Next"><HomeIcon name="next" size={14} /></button>
|
|
115
|
+
<span class="home-preview-count">{index + 1} / {info.slides}</span>
|
|
116
|
+
<span class="home-spacer" />
|
|
117
|
+
<a class="home-btn home-btn--small" href={audienceUrl} target="_blank" rel="noopener">Open audience window <HomeIcon name="external" size={13} /></a>
|
|
118
|
+
</div>
|
|
119
|
+
</section>
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const OUTPUT_TEXT = {
|
|
123
|
+
folder: { icon: 'folder', text: 'dist/ with the page, pictures and videos, for a web server or a USB stick.' },
|
|
124
|
+
send: { icon: 'file-code', text: 'A single HTML file without speaker notes that opens in the reader, with the PDF inside.' },
|
|
125
|
+
pdf: { icon: 'file-text', text: 'One page per slide, with interactive slides shown finished.' },
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function OutputRow({ id, output, chrome, onChange, onError }) {
|
|
129
|
+
const running = output.status === 'running'
|
|
130
|
+
const build = async () => {
|
|
131
|
+
onChange(id, { ...output, status: 'running' })
|
|
132
|
+
try { onChange(id, { ...output, ...(await api('/action', { action: 'build', output: id })), exists: true }) }
|
|
133
|
+
catch (error) { onError(error.message); onChange(id, { ...output, status: 'failed' }) }
|
|
134
|
+
}
|
|
135
|
+
const reveal = () => api('/action', { action: 'reveal', output: id }).catch(error => onError(error.message))
|
|
136
|
+
const blocked = output.needsChrome && !chrome
|
|
137
|
+
const status = running ? 'Building…'
|
|
138
|
+
: output.status === 'failed' ? 'Failed'
|
|
139
|
+
: output.status === 'done' ? `Built ${ago(output.finishedAt)}`
|
|
140
|
+
: output.exists ? 'Built earlier' : 'Not built yet'
|
|
141
|
+
return <li class="home-output">
|
|
142
|
+
<span class="home-tile-icon"><HomeIcon name={OUTPUT_TEXT[id].icon} size={20} /></span>
|
|
143
|
+
<div class="home-output-body">
|
|
144
|
+
<strong>{output.title}</strong>
|
|
145
|
+
<span>{OUTPUT_TEXT[id].text}</span>
|
|
146
|
+
<code title={output.file}>{output.file}</code>
|
|
147
|
+
{output.status === 'failed' && output.log && <details><summary>What went wrong</summary><pre>{output.log}</pre></details>}
|
|
148
|
+
{blocked && <span class="home-note">Needs Chrome or Chromium. Set MDECK_CHROME to its path if it is installed somewhere unusual.</span>}
|
|
149
|
+
</div>
|
|
150
|
+
<div class="home-output-actions">
|
|
151
|
+
<span class={`home-status home-status--${running ? 'running' : output.status === 'failed' ? 'failed' : output.exists ? 'done' : 'idle'}`}>{status}</span>
|
|
152
|
+
<button class="home-btn home-btn--primary" onClick={build} disabled={running || blocked}>{output.exists ? 'Build again' : 'Build'}</button>
|
|
153
|
+
<button class="home-btn" onClick={reveal} disabled={!output.exists || running} title="Show in your file manager"><HomeIcon name="folder-open" size={15} />Show</button>
|
|
154
|
+
</div>
|
|
155
|
+
</li>
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function Diagnostics({ diagnostics, name }) {
|
|
159
|
+
if (!diagnostics.length) return <p class="home-ok"><HomeIcon name="check" /> No problems found in {name}.</p>
|
|
160
|
+
return <ul class="home-diagnostics">
|
|
161
|
+
{diagnostics.map((d, i) => <li key={i} class={`home-diagnostic home-diagnostic--${d.severity}`}>
|
|
162
|
+
<span class="home-badge">{d.severity}</span>
|
|
163
|
+
<span>{d.message}</span>
|
|
164
|
+
{d.line > 1 && <span class="home-line">line {d.line}</span>}
|
|
165
|
+
</li>)}
|
|
166
|
+
</ul>
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function CopyButton({ text, label = 'Copy' }) {
|
|
170
|
+
const [copied, setCopied] = useState(false)
|
|
171
|
+
const copy = async () => {
|
|
172
|
+
try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1400) } catch {}
|
|
173
|
+
}
|
|
174
|
+
return <button class="home-btn home-btn--small" onClick={copy}><HomeIcon name={copied ? 'check' : 'copy'} size={14} />{copied ? 'Copied' : label}</button>
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// One session pairs the presenter view with the audience window, so both
|
|
178
|
+
// links on this page drive each other.
|
|
179
|
+
const SESSION = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2)
|
|
180
|
+
|
|
181
|
+
// For decks with a `server` setting: the server's health, the viewers' link
|
|
182
|
+
// and the key (MDECK_SERVER_KEY) that lets a browser steer phones and reset
|
|
183
|
+
// polls.
|
|
184
|
+
function LivePolls({ live, presence, onRecheck }) {
|
|
185
|
+
const presenterUrl = `${BASE}?view=presenter&session=${SESSION}${live.key ? `&serverkey=${encodeURIComponent(live.key)}` : ''}`
|
|
186
|
+
const keyState = !live.key ? null : live.keyAccepted ? ['is-ok', 'Accepted by the server'] : live.reachable ? ['is-bad', 'Not accepted by the server'] : ['is-idle', 'Not checked']
|
|
187
|
+
return <section class="home-section">
|
|
188
|
+
<div class="home-section-head">
|
|
189
|
+
<h2>Polls</h2>
|
|
190
|
+
<button class="home-btn home-btn--small" onClick={onRecheck}>Check again</button>
|
|
191
|
+
</div>
|
|
192
|
+
<dl class="home-live">
|
|
193
|
+
<div>
|
|
194
|
+
<dt>Server</dt>
|
|
195
|
+
<dd>
|
|
196
|
+
{live.server
|
|
197
|
+
? <><code>{live.server}</code><span class={`home-pill ${live.reachable ? 'is-ok' : 'is-bad'}`}>{live.reachable ? `Reachable · ${live.ms} ms` : `Not reachable · ${live.error}`}</span></>
|
|
198
|
+
: <span>Built into this dev server</span>}
|
|
199
|
+
</dd>
|
|
200
|
+
</div>
|
|
201
|
+
<div>
|
|
202
|
+
<dt>Join link for phones</dt>
|
|
203
|
+
<dd>
|
|
204
|
+
{live.joinUrl
|
|
205
|
+
? <><a href={live.joinUrl} target="_blank" rel="noopener"><code>{live.joinUrl}</code></a> <CopyButton text={live.joinUrl} /></>
|
|
206
|
+
: <>
|
|
207
|
+
<p class="home-note">Phones cannot reach this computer. Start with <code>--network</code>, or set <code>server</code> in the deck.</p>
|
|
208
|
+
{live.localJoinUrl && <p><a href={live.localJoinUrl} target="_blank" rel="noopener">Try the answer page here</a> <span>in another browser tab or window.</span></p>}
|
|
209
|
+
</>}
|
|
210
|
+
<p>Session code <code class="home-code">{live.code}</code>, the same for every poll in this deck.</p>
|
|
211
|
+
{presence && <p>{presence.phones ? `${phonesText(presence.phones)} connected now.` : 'No phones connected yet.'}</p>}
|
|
212
|
+
</dd>
|
|
213
|
+
</div>
|
|
214
|
+
{live.server && <div>
|
|
215
|
+
<dt>Server key</dt>
|
|
216
|
+
<dd>
|
|
217
|
+
{live.key
|
|
218
|
+
? <>
|
|
219
|
+
<code class="home-code">{live.key}</code> <CopyButton text={live.key} />
|
|
220
|
+
<span class={`home-pill ${keyState[0]}`}>{keyState[1]}</span>
|
|
221
|
+
<p>The presenter view and audience window buttons above carry it. Or open <a href={presenterUrl} target="_blank" rel="noopener">the presenter view with the key</a>.</p>
|
|
222
|
+
</>
|
|
223
|
+
: <p>Start <code>mdeck run</code> with <code>MDECK_SERVER_KEY</code> set to the server's key to see it here.</p>}
|
|
224
|
+
</dd>
|
|
225
|
+
</div>}
|
|
226
|
+
</dl>
|
|
227
|
+
</section>
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Presenting from an iPad: a one-time QR code opens the presenter view there,
|
|
231
|
+
// paired with this server so it may save ink and steer the projector, which
|
|
232
|
+
// shows the audience window on this computer.
|
|
233
|
+
function Tablet({ pairing, relay, presence, onChange, onError }) {
|
|
234
|
+
const away = (presence?.views ?? []).filter(entry => entry.view === 'presenter' && entry.device !== 'computer')
|
|
235
|
+
const [url, setUrl] = useState(null)
|
|
236
|
+
const offer = () => api('/action', { action: 'pair' }).then(result => { setUrl(result.url); onChange() }).catch(error => onError(error.message))
|
|
237
|
+
const unpair = () => api('/action', { action: 'unpair' }).then(() => { setUrl(null); onChange() }).catch(error => onError(error.message))
|
|
238
|
+
return <section class="home-section home-tablet">
|
|
239
|
+
<div class="home-section-head">
|
|
240
|
+
<h2>Present from an iPad</h2>
|
|
241
|
+
{pairing.devices > 0 && <button class="home-btn home-btn--small" onClick={unpair}>Unpair {pairing.devices === 1 ? 'the device' : `${pairing.devices} devices`}</button>}
|
|
242
|
+
</div>
|
|
243
|
+
{relay && <p class={`home-note home-relay home-relay--${relay.state}`}>{relay.state === 'up' ? 'Connected to your server: the iPad can reach this computer from any network.' : relay.state === 'refused' ? `Connecting to the server failed: ${relay.reason}` : relay.state === 'down' ? 'Lost the connection to the server; reconnecting…' : 'Connecting to your server…'}</p>}
|
|
244
|
+
{away.length > 0 && <p class="home-ok"><HomeIcon name="check" /> Presenter view open on {devicesText(away)}.</p>}
|
|
245
|
+
{!pairing.available
|
|
246
|
+
? <p class="home-note">Start <code>mdeck run</code> with <code>--network</code>, so an iPad in the same network can reach it, or with <code>--server</code> to reach it through your server.</p>
|
|
247
|
+
: url
|
|
248
|
+
? <div class="home-pair">
|
|
249
|
+
<QrCode url={url} size="180" />
|
|
250
|
+
<div>
|
|
251
|
+
<p>Scan this with the iPad's camera. It opens the presenter view there, where you draw and go through the slides.</p>
|
|
252
|
+
<p>Show the <a href={`${BASE}?view=audience&session=${SESSION}`} target="_blank" rel="noopener">audience window</a> on the projector from this computer: it follows the iPad.</p>
|
|
253
|
+
<p class="home-note">The code works once, for ten minutes. {pairing.devices > 0 ? `${pairing.devices} paired so far.` : ''}</p>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
: <>
|
|
257
|
+
<p>The iPad opens the same presenter view as this computer, laid out for its screen: you draw and go through the slides there while this computer drives the projector. Drawings are saved in the drawings file beside the deck.</p>
|
|
258
|
+
<p class="home-note">Pairing is what lets the iPad save drawings and steer the audience window here. Other devices in the network can open the presenter view too, but only watch.</p>
|
|
259
|
+
<button class="home-btn" onClick={offer}>Show pairing code</button>
|
|
260
|
+
{pairing.devices > 0 && <p class="home-note">{pairing.devices === 1 ? 'One device' : `${pairing.devices} devices`} paired.</p>}
|
|
261
|
+
</>}
|
|
262
|
+
</section>
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function Home() {
|
|
266
|
+
const [info, setInfo] = useState(null)
|
|
267
|
+
const [error, setError] = useState('')
|
|
268
|
+
const [loadError, setLoadError] = useState('')
|
|
269
|
+
|
|
270
|
+
const load = () => api('/info').then(data => { setInfo(data); setLoadError('') }).catch(e => setLoadError(e.message))
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
load()
|
|
273
|
+
const icon = document.querySelector('link[rel=icon]') ?? document.head.appendChild(Object.assign(document.createElement('link'), { rel: 'icon' }))
|
|
274
|
+
icon.href = mark
|
|
275
|
+
window.addEventListener('focus', load)
|
|
276
|
+
return () => window.removeEventListener('focus', load)
|
|
277
|
+
}, [])
|
|
278
|
+
useEffect(() => { if (info) document.title = `${info.title} · mdeck` }, [info?.title])
|
|
279
|
+
// The connection to the server comes up a moment after the page; look again until it does.
|
|
280
|
+
const sharing = info?.relay && info.relay.state !== 'up' && info.relay.state !== 'refused'
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
if (!sharing) return
|
|
283
|
+
const timer = setInterval(load, 2000)
|
|
284
|
+
return () => clearInterval(timer)
|
|
285
|
+
}, [sharing])
|
|
286
|
+
|
|
287
|
+
const presence = usePresence(info?.presence)
|
|
288
|
+
|
|
289
|
+
if (!info) return <main class="home home--empty">
|
|
290
|
+
<img src={mark} alt="" width="48" height="48" />
|
|
291
|
+
<p>{loadError ? `The launch page could not load: ${loadError}` : 'Loading…'}</p>
|
|
292
|
+
{loadError && <p><a href={`${BASE}?view=audience`}>Open the audience window</a></p>}
|
|
293
|
+
</main>
|
|
294
|
+
|
|
295
|
+
const errors = info.diagnostics.filter(d => d.severity === 'error').length
|
|
296
|
+
const warnings = info.diagnostics.length - errors
|
|
297
|
+
const setOutput = (id, output) => setInfo(current => ({ ...current, outputs: { ...current.outputs, [id]: output } }))
|
|
298
|
+
// With the server key, every screen opened here may move the phones along.
|
|
299
|
+
const code = info.live?.key ? `&serverkey=${encodeURIComponent(info.live.key)}` : ''
|
|
300
|
+
const openOn = view => { const views = presence?.views.filter(entry => entry.view === view) ?? []; return views.length ? devicesText(views) : null }
|
|
301
|
+
const meta = [`${info.slides} slide${info.slides === 1 ? '' : 's'}`, info.notes ? `${info.notes} with notes` : 'no speaker notes', `theme ${info.theme}${info.palette ? ` · ${info.palette}` : ''}`, `saved ${ago(info.modified)}`]
|
|
302
|
+
|
|
303
|
+
return <main class="home">
|
|
304
|
+
<header class="home-header">
|
|
305
|
+
<img class="home-mark" src={mark} alt="mdeck" width="26" height="26" />
|
|
306
|
+
<div class="home-heading">
|
|
307
|
+
<h1>{info.title}</h1>
|
|
308
|
+
<p class="home-meta">{meta.join(' · ')}</p>
|
|
309
|
+
<p class="home-file"><code>{info.file}</code> <button class="home-link" onClick={() => api('/action', { action: 'reveal', output: 'deck' }).catch(e => setError(e.message))}>Show</button></p>
|
|
310
|
+
</div>
|
|
311
|
+
<a class={`home-health${errors ? ' is-bad' : warnings ? ' is-warn' : ''}`} href="#check">
|
|
312
|
+
<HomeIcon name={errors || warnings ? 'alert' : 'check'} />
|
|
313
|
+
{errors ? `${errors} error${errors === 1 ? '' : 's'}` : warnings ? `${warnings} warning${warnings === 1 ? '' : 's'}` : 'All good'}
|
|
314
|
+
</a>
|
|
315
|
+
</header>
|
|
316
|
+
|
|
317
|
+
{error && <div class="home-error" role="alert"><span>{error}</span><button class="home-link" onClick={() => setError('')}>Dismiss</button></div>}
|
|
318
|
+
|
|
319
|
+
<div class="home-grid">
|
|
320
|
+
<Preview info={info} />
|
|
321
|
+
|
|
322
|
+
<section class="home-section">
|
|
323
|
+
<div class="home-section-head">
|
|
324
|
+
<h2>Present</h2>
|
|
325
|
+
<ConnectedViews presence={presence} phones={!!info.live} />
|
|
326
|
+
</div>
|
|
327
|
+
<div class="home-tiles">
|
|
328
|
+
<ViewTile icon="presenter" title="Presenter view" text="Present and draw, with notes, timer and next slide. Switch to slides only or fullscreen." href={`${BASE}?view=presenter&session=${SESSION}${code}`} open={openOn('presenter')} />
|
|
329
|
+
<ViewTile icon="projector" title="Audience window" text="Slides for the projector, without notes. Navigation stays in sync with the presenter." href={`${BASE}?view=audience&session=${SESSION}${code}`} open={openOn('audience')} />
|
|
330
|
+
<ViewTile icon="reader" title="Reader view" text="Outline, reading mode and look picker, as people you send it to see it." href={`${BASE}?view=reader`} />
|
|
331
|
+
</div>
|
|
332
|
+
</section>
|
|
333
|
+
|
|
334
|
+
<section class={`home-section${info.live ? '' : ' home-section--wide'}`}>
|
|
335
|
+
<h2>Write and learn</h2>
|
|
336
|
+
<div class="home-tiles">
|
|
337
|
+
<ServiceTile icon="editor" title="Visual editor" text="Forms and a live preview; saves into the file and keeps a backup." action="editor" url={info.services.editor} onError={setError} onStarted={load} />
|
|
338
|
+
<ServiceTile icon="guides" title="Guides" text="How to write slides, layouts, presenting, sharing and more." action="docs" url={info.services.docs} onError={setError} onStarted={load} />
|
|
339
|
+
</div>
|
|
340
|
+
</section>
|
|
341
|
+
|
|
342
|
+
{info.live && <LivePolls live={info.live} presence={presence} onRecheck={load} />}
|
|
343
|
+
|
|
344
|
+
{info.pairing && <Tablet pairing={info.pairing} relay={info.relay} presence={presence} onChange={load} onError={setError} />}
|
|
345
|
+
|
|
346
|
+
<section class="home-section home-section--wide">
|
|
347
|
+
<h2>Share</h2>
|
|
348
|
+
<ul class="home-outputs">
|
|
349
|
+
{Object.entries(info.outputs).map(([id, output]) => <OutputRow key={id} id={id} output={output} chrome={info.chrome} onChange={setOutput} onError={setError} />)}
|
|
350
|
+
</ul>
|
|
351
|
+
</section>
|
|
352
|
+
|
|
353
|
+
<section class="home-section home-section--wide" id="check">
|
|
354
|
+
<div class="home-section-head">
|
|
355
|
+
<h2>Check</h2>
|
|
356
|
+
<button class="home-btn home-btn--small" onClick={load}>Check again</button>
|
|
357
|
+
</div>
|
|
358
|
+
<Diagnostics diagnostics={info.diagnostics} name={info.name} />
|
|
359
|
+
</section>
|
|
360
|
+
</div>
|
|
361
|
+
</main>
|
|
362
|
+
}
|