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,208 @@
|
|
|
1
|
+
// Launch page API for `mdeck run`: what the deck contains, what `mdeck check`
|
|
2
|
+
// says about it, the state of its server, and buttons that start the
|
|
3
|
+
// editor and the guides or run a build. Like the editor API it answers loopback
|
|
4
|
+
// requests from its own pages only, even when the server is shared on the
|
|
5
|
+
// network for phones.
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
8
|
+
import { resolve, dirname, basename } from 'node:path'
|
|
9
|
+
import { loadRegistry } from '../extensions/discover.js'
|
|
10
|
+
import { parseSlides } from '../core/parseSlides.js'
|
|
11
|
+
import { checkDeck } from './check.js'
|
|
12
|
+
import { sessionCode } from '../live/code.js'
|
|
13
|
+
import { roomsIn } from '../live/roomTag.js'
|
|
14
|
+
import { isAllowedRequest, send, readJson } from './editorPlugin.js'
|
|
15
|
+
import { findChrome } from './chrome.js'
|
|
16
|
+
import { frameworkRoot } from '../paths.js'
|
|
17
|
+
|
|
18
|
+
// Each output is one ordinary CLI run beside the deck, so the launch page
|
|
19
|
+
// builds exactly what the command line would.
|
|
20
|
+
export function outputsFor(slidesPath) {
|
|
21
|
+
const abs = resolve(slidesPath), dir = dirname(abs), name = basename(abs).replace(/\.md$/i, '')
|
|
22
|
+
return {
|
|
23
|
+
folder: { title: 'Folder to host', args: ['build', abs, '--reader'], file: resolve(dir, 'dist/index.html') },
|
|
24
|
+
send: { title: 'One file to send', args: ['send', abs, '-o', `${name}.html`], file: resolve(dir, `${name}.html`), chrome: true },
|
|
25
|
+
pdf: { title: 'PDF', args: ['pdf', abs, '-o', `${name}.pdf`], file: resolve(dir, `${name}.pdf`), chrome: true },
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function runCli(args, cwd) {
|
|
30
|
+
return new Promise(done => {
|
|
31
|
+
const child = spawn(process.execPath, [resolve(frameworkRoot, 'bin/mdeck.js'), ...args], { cwd, env: { ...process.env, FORCE_COLOR: '0' }, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
32
|
+
let output = ''
|
|
33
|
+
child.stdout.on('data', data => { output += data })
|
|
34
|
+
child.stderr.on('data', data => { output += data })
|
|
35
|
+
child.on('error', error => done({ code: 1, output: error.message }))
|
|
36
|
+
child.on('close', code => done({ code, output: output.replace(/\x1b\[[0-9;]*m/g, '').trim() }))
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function reveal(file) {
|
|
41
|
+
const [command, args] = process.platform === 'darwin' ? ['open', ['-R', file]]
|
|
42
|
+
: process.platform === 'win32' ? ['explorer', [`/select,${file}`]]
|
|
43
|
+
: ['xdg-open', [dirname(file)]]
|
|
44
|
+
spawn(command, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The server named in the `server` setting: whether it answers, and whether it
|
|
48
|
+
// accepts the key (MDECK_SERVER_KEY, as given to the server).
|
|
49
|
+
export async function liveStatus(live, { key = null, fetch: get = fetch, timeoutMs = 2500 } = {}) {
|
|
50
|
+
const started = Date.now()
|
|
51
|
+
try {
|
|
52
|
+
const response = await get(`${live.server.replace(/\/+$/, '')}/info`, { headers: key ? { Authorization: `Bearer ${key}` } : {}, signal: AbortSignal.timeout(timeoutMs) })
|
|
53
|
+
if (!response.ok) return { reachable: false, error: `It answered with status ${response.status}` }
|
|
54
|
+
const body = await response.json()
|
|
55
|
+
return { reachable: true, ms: Date.now() - started, keyAccepted: key ? !!body.canReset : null }
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return { reachable: false, error: error.name === 'TimeoutError' ? `No answer within ${timeoutMs / 1000} s` : error.cause?.code ?? error.message }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// `urls()` returns the server's addresses; `services` start the editor and the
|
|
62
|
+
// guides; `fetch` reaches the server. All are injected so tests can run
|
|
63
|
+
// without a browser or network.
|
|
64
|
+
export function homeMiddleware(slidesPath, { urls = () => ({ local: [], network: [] }), services = {}, pairing = null, relay = null, server: serverAddress = null, run = runCli, open = reveal, serverKey = process.env.MDECK_SERVER_KEY || null, fetch: get = fetch } = {}) {
|
|
65
|
+
const abs = resolve(slidesPath)
|
|
66
|
+
const outputs = outputsFor(abs)
|
|
67
|
+
const jobs = {}
|
|
68
|
+
const started = {}, serviceUrls = {}
|
|
69
|
+
|
|
70
|
+
async function info(request) {
|
|
71
|
+
const source = readFileSync(abs, 'utf8')
|
|
72
|
+
let registry = null, registryError = null
|
|
73
|
+
try { registry = loadRegistry(abs) } catch (error) { registryError = error.message }
|
|
74
|
+
const { deck, diagnostics } = registry ? checkDeck(abs, registry, source) : { deck: parseSlides(source), diagnostics: [] }
|
|
75
|
+
const config = deck.deckConfig ?? {}
|
|
76
|
+
const { local: serverLocal = [], network = [] } = urls() ?? {}
|
|
77
|
+
// The address this page was opened with, such as a local proxy's name.
|
|
78
|
+
const local = request?.headers?.host ? [`http://${request.headers.host}/`, ...serverLocal] : serverLocal
|
|
79
|
+
return {
|
|
80
|
+
file: abs,
|
|
81
|
+
name: basename(abs),
|
|
82
|
+
modified: statSync(abs).mtime.toISOString(),
|
|
83
|
+
title: config.meta?.title ?? basename(abs).replace(/\.md$/i, ''),
|
|
84
|
+
author: config.meta?.author ?? null,
|
|
85
|
+
slides: deck.slides.length,
|
|
86
|
+
width: config.width ?? 1920,
|
|
87
|
+
height: config.height ?? 1080,
|
|
88
|
+
notes: deck.slides.filter(slide => slide.meta.notes).length,
|
|
89
|
+
theme: config.theme ?? 'neue',
|
|
90
|
+
palette: config.palette ?? null,
|
|
91
|
+
diagnostics: [...(registry?.warnings ?? []).map(message => ({ severity: 'warning', code: 'extension', message })), ...(registryError ? [{ severity: 'error', code: 'extension', message: registryError }] : []), ...diagnostics],
|
|
92
|
+
live: await live(config, source, { local, network }),
|
|
93
|
+
presence: presenceUrl(config),
|
|
94
|
+
// Presenting from an iPad needs an address it can reach (`--host`).
|
|
95
|
+
pairing: pairing ? { available: !!relay || !!network[0], devices: pairing.devices } : null,
|
|
96
|
+
relay: relay ? { url: relay.url, state: relay.state, reason: relay.reason } : null,
|
|
97
|
+
chrome: !!findChrome(),
|
|
98
|
+
services: Object.fromEntries(Object.keys(services).map(key => [key, serviceUrls[key] ?? null])),
|
|
99
|
+
outputs: Object.fromEntries(Object.entries(outputs).map(([key, output]) => [key, {
|
|
100
|
+
title: output.title,
|
|
101
|
+
file: output.file,
|
|
102
|
+
exists: existsSync(output.file),
|
|
103
|
+
needsChrome: !!output.chrome,
|
|
104
|
+
...(jobs[key] ?? { status: 'idle' }),
|
|
105
|
+
}])),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Where the launch page asks who is connected: the deck's stage room, on
|
|
110
|
+
// the deck's own server, or here when this server passes the rooms on
|
|
111
|
+
// (with MDECK_SERVER_KEY) or keeps them itself. Relative to the page.
|
|
112
|
+
function presenceUrl(config) {
|
|
113
|
+
const address = serverAddress ?? config.server
|
|
114
|
+
const room = `rooms/${encodeURIComponent(`${sessionCode(config)}.stage`)}/presence`
|
|
115
|
+
return typeof address === 'string' && !serverKey ? `${address.replace(/\/+$/, '')}/${room}` : `__mdeck/live/${room}`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Decks with activities, or with a server of their own, get this
|
|
119
|
+
// section: the server and its health, the join link phones open and,
|
|
120
|
+
// for a server of their own, the presenter code.
|
|
121
|
+
async function live(config, source, { local, network }) {
|
|
122
|
+
const address = serverAddress ?? config.server
|
|
123
|
+
const settings = { server: address }
|
|
124
|
+
const own = typeof address === 'string'
|
|
125
|
+
if (!own && roomsIn(source).length === 0) return null
|
|
126
|
+
const code = sessionCode(config)
|
|
127
|
+
const builtIn = base => new URL(`__mdeck/live/${code}`, base).href
|
|
128
|
+
const joinUrl = own ? `${settings.server.replace(/\/+$/, '')}/${code}` : network[0] ? builtIn(network[0]) : null
|
|
129
|
+
return {
|
|
130
|
+
server: own ? settings.server : null,
|
|
131
|
+
code,
|
|
132
|
+
...(own ? await liveStatus(settings, { key: serverKey, fetch: get }) : { reachable: true }),
|
|
133
|
+
key: own ? serverKey : null,
|
|
134
|
+
joinUrl,
|
|
135
|
+
localJoinUrl: joinUrl ?? (local[0] ? builtIn(local[0]) : null),
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Each service starts once; a failed start can be retried.
|
|
140
|
+
async function start(key) {
|
|
141
|
+
started[key] ??= services[key]().then(service => { serviceUrls[key] = service.url; return service }, error => { delete started[key]; throw error })
|
|
142
|
+
return (await started[key]).url
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The one-time address an iPad opens to present from: the presenter view,
|
|
146
|
+
// with a one-time pairing offer; the server's master key stays here.
|
|
147
|
+
function pairUrl() {
|
|
148
|
+
const network = relay?.url ?? urls()?.network?.[0]
|
|
149
|
+
if (!pairing || !network) throw Object.assign(new Error('Start mdeck run with --network or --server so an iPad can reach it'), { status: 409 })
|
|
150
|
+
const url = new URL(network)
|
|
151
|
+
url.searchParams.set('view', 'presenter')
|
|
152
|
+
url.searchParams.set('pair', pairing.offer())
|
|
153
|
+
return url.href
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function act({ action, output } = {}) {
|
|
157
|
+
if (Object.hasOwn(services, action)) return { url: await start(action) }
|
|
158
|
+
if (action === 'pair') return { url: pairUrl() }
|
|
159
|
+
if (action === 'unpair') { pairing?.revoke(); return { ok: true } }
|
|
160
|
+
if (action === 'build') {
|
|
161
|
+
if (!Object.hasOwn(outputs, output)) throw Object.assign(new Error(`Unknown output: ${output}`), { status: 400 })
|
|
162
|
+
if (jobs[output]?.status === 'running') throw Object.assign(new Error('That build is already running'), { status: 409 })
|
|
163
|
+
jobs[output] = { status: 'running', startedAt: new Date().toISOString() }
|
|
164
|
+
const { code, output: log } = await run(outputs[output].args, dirname(abs))
|
|
165
|
+
jobs[output] = { status: code === 0 ? 'done' : 'failed', log, finishedAt: new Date().toISOString() }
|
|
166
|
+
return jobs[output]
|
|
167
|
+
}
|
|
168
|
+
if (action === 'reveal') {
|
|
169
|
+
const target = outputs[output]?.file ?? (output === 'deck' ? abs : null)
|
|
170
|
+
if (!target || !existsSync(target)) throw Object.assign(new Error('Nothing to show yet'), { status: 404 })
|
|
171
|
+
open(target)
|
|
172
|
+
return { ok: true }
|
|
173
|
+
}
|
|
174
|
+
throw Object.assign(new Error(`Unknown action: ${action}`), { status: 400 })
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return async (request, response, next) => {
|
|
178
|
+
const pathname = new URL(request.url, 'http://localhost').pathname
|
|
179
|
+
if (!['/info', '/action'].includes(pathname)) return next()
|
|
180
|
+
if (!isAllowedRequest(request)) return send(response, 403, { error: 'The launch page only answers on this computer' })
|
|
181
|
+
try {
|
|
182
|
+
if (pathname === '/info' && request.method === 'GET') return send(response, 200, await info(request))
|
|
183
|
+
if (pathname === '/action' && request.method === 'POST') return send(response, 200, await act(await readJson(request)))
|
|
184
|
+
response.setHeader('Allow', pathname === '/info' ? 'GET' : 'POST')
|
|
185
|
+
return send(response, 405, { error: 'Method not allowed' })
|
|
186
|
+
} catch (error) {
|
|
187
|
+
return send(response, error.status ?? 500, { error: error.message })
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function homePlugin(slidesPath, { services, pairing, relay = null, server: serverAddress = null } = {}) {
|
|
193
|
+
return {
|
|
194
|
+
name: 'vite-plugin-mdeck-home',
|
|
195
|
+
configureServer(server) {
|
|
196
|
+
// The bare address is the launch page; the slides are at ?view=deck.
|
|
197
|
+
// Only a plain page visit is redirected: every view, preview and editor
|
|
198
|
+
// frame carries a query.
|
|
199
|
+
const base = server.config.base
|
|
200
|
+
server.middlewares.use((request, response, next) => {
|
|
201
|
+
if (request.method !== 'GET' || ![base, `${base}index.html`].includes(request.url) || !/text\/html/.test(request.headers.accept ?? '')) return next()
|
|
202
|
+
response.writeHead(302, { Location: `${base}home.html` })
|
|
203
|
+
response.end()
|
|
204
|
+
})
|
|
205
|
+
server.middlewares.use('/__mdeck/home', homeMiddleware(slidesPath, { urls: () => server.resolvedUrls, services, pairing, relay, server: serverAddress }))
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Saving ink while `mdeck run` runs: the file beside the deck is the source of
|
|
2
|
+
// truth, and devices send changes as operations (see applyOp in core/ink.js),
|
|
3
|
+
// so strokes drawn on two devices at once never overwrite each other.
|
|
4
|
+
//
|
|
5
|
+
// GET /__mdeck/ink { ink, deckHash }
|
|
6
|
+
// POST /__mdeck/ink/ops { deckHash, ops } → { deckHash, renamed }
|
|
7
|
+
//
|
|
8
|
+
// A slide that gets ink but has no `id:` of its own gets one, written into
|
|
9
|
+
// its settings, so the ink stays with it when slides are inserted. Ids by
|
|
10
|
+
// position (slide-3) are only trusted when the device saw the current deck
|
|
11
|
+
// (deckHash); otherwise the answer is 409 and the device reloads. The server's
|
|
12
|
+
// own writes do not reload the open windows (ownWrites.js); instead it sends
|
|
13
|
+
// `mdeck:ink`, and the pages fetch the ink again.
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs'
|
|
15
|
+
import { resolve, dirname, basename } from 'node:path'
|
|
16
|
+
import { parseSlides } from '../core/parseSlides.js'
|
|
17
|
+
import { setSlideMeta } from '../core/editDeck.js'
|
|
18
|
+
import { slideTitle } from '../core/outline.js'
|
|
19
|
+
import { slug } from '../live/code.js'
|
|
20
|
+
import { inkFileFor, normalizeInk, emptyInk, applyOp, serializeInk } from '../core/ink.js'
|
|
21
|
+
import { hashSource, isAllowedRequest, send, readJson, BACKUP_DIR } from './editorPlugin.js'
|
|
22
|
+
import { markOwnWrite } from './ownWrites.js'
|
|
23
|
+
|
|
24
|
+
const ID_RE = /^[a-zA-Z][a-zA-Z0-9_-]*$/
|
|
25
|
+
const BACKUPS_KEPT = 10
|
|
26
|
+
|
|
27
|
+
function writeAtomic(path, text) {
|
|
28
|
+
const temp = `${path}.${process.pid}.tmp`
|
|
29
|
+
writeFileSync(temp, text, 'utf8')
|
|
30
|
+
renameSync(temp, path)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A readable, unique id from the slide's heading, as ids must start with a letter.
|
|
34
|
+
function newSlideId(deck, slide, index) {
|
|
35
|
+
const taken = new Set(deck.slides.map(s => s.id))
|
|
36
|
+
let base = slug(slideTitle(slide, index, null)) || 'slide'
|
|
37
|
+
if (!/^[a-z]/.test(base)) base = `slide-${base}`
|
|
38
|
+
let id = base, n = 2
|
|
39
|
+
while (taken.has(id) || !ID_RE.test(id)) id = `${base}-${n++}`
|
|
40
|
+
return id
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createInkFile(deckPath, { debounceMs = 300, onChange = () => {} } = {}) {
|
|
44
|
+
const abs = resolve(deckPath)
|
|
45
|
+
const inkPath = inkFileFor(abs)
|
|
46
|
+
let ink = null, timer = null, backedUp = false
|
|
47
|
+
// Ids given to slides, for devices that still saw the deck from before:
|
|
48
|
+
// { from, to, before } applies only to requests made against `before`.
|
|
49
|
+
const renames = []
|
|
50
|
+
let knownIds = null
|
|
51
|
+
|
|
52
|
+
const readDeck = () => { const source = readFileSync(abs, 'utf8'); return { source, deck: parseSlides(source), hash: hashSource(source) } }
|
|
53
|
+
const size = deck => ({ width: deck.deckConfig?.width ?? 1920, height: deck.deckConfig?.height ?? 1080 })
|
|
54
|
+
|
|
55
|
+
// The file on disk, unless changes are still waiting to be written.
|
|
56
|
+
function current(deck) {
|
|
57
|
+
if (timer && ink) return ink
|
|
58
|
+
try { ink = existsSync(inkPath) ? normalizeInk(JSON.parse(readFileSync(inkPath, 'utf8')), size(deck)) : emptyInk(size(deck)) }
|
|
59
|
+
catch { ink ??= emptyInk(size(deck)) }
|
|
60
|
+
return ink
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function backupOnce() {
|
|
64
|
+
if (backedUp || !existsSync(inkPath)) { backedUp = true; return }
|
|
65
|
+
const dir = resolve(dirname(abs), BACKUP_DIR)
|
|
66
|
+
mkdirSync(dir, { recursive: true })
|
|
67
|
+
const name = basename(inkPath, '.json')
|
|
68
|
+
writeFileSync(resolve(dir, `${name}-${new Date().toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, 'Z')}.json`), readFileSync(inkPath))
|
|
69
|
+
const copies = readdirSync(dir).filter(entry => entry.startsWith(`${name}-`) && entry.endsWith('.json')).sort()
|
|
70
|
+
for (const old of copies.slice(0, -BACKUPS_KEPT)) unlinkSync(resolve(dir, old))
|
|
71
|
+
backedUp = true
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function flush() {
|
|
75
|
+
clearTimeout(timer)
|
|
76
|
+
timer = null
|
|
77
|
+
if (!ink) return
|
|
78
|
+
backupOnce()
|
|
79
|
+
const text = serializeInk(ink)
|
|
80
|
+
markOwnWrite(inkPath, text)
|
|
81
|
+
writeAtomic(inkPath, text)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A slide whose `id:` was changed in the editor or by hand, at the same
|
|
85
|
+
// place in a deck with the same number of slides, keeps its ink. Ink of a
|
|
86
|
+
// removed slide stays in the file (mdeck check reports it), so undoing the
|
|
87
|
+
// removal brings it back.
|
|
88
|
+
function deckChanged() {
|
|
89
|
+
let deck
|
|
90
|
+
try { deck = readDeck().deck } catch { return [] }
|
|
91
|
+
const ids = deck.slides.map(slide => slide.id)
|
|
92
|
+
const before = knownIds
|
|
93
|
+
knownIds = ids
|
|
94
|
+
if (!before || before.length !== ids.length) return []
|
|
95
|
+
current(deck)
|
|
96
|
+
const moved = []
|
|
97
|
+
ids.forEach((id, i) => {
|
|
98
|
+
const from = before[i]
|
|
99
|
+
if (from !== id && !ids.includes(from) && !before.includes(id) && ink.slides[from]) {
|
|
100
|
+
ink = applyOp(ink, { type: 'rename', from, to: id })
|
|
101
|
+
moved.push({ from, to: id })
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
if (moved.length) flush()
|
|
105
|
+
return moved
|
|
106
|
+
}
|
|
107
|
+
try { knownIds = readDeck().deck.slides.map(slide => slide.id) } catch {}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
path: inkPath,
|
|
111
|
+
deckChanged,
|
|
112
|
+
read() {
|
|
113
|
+
const { deck, hash } = readDeck()
|
|
114
|
+
return { ink: current(deck), deckHash: hash }
|
|
115
|
+
},
|
|
116
|
+
apply(ops, deckHash) {
|
|
117
|
+
let { source, deck, hash } = readDeck()
|
|
118
|
+
current(deck)
|
|
119
|
+
const renamed = []
|
|
120
|
+
const seen = deckHash
|
|
121
|
+
const alias = new Map(renames.filter(r => r.before === seen).map(r => [r.from, r.to]))
|
|
122
|
+
for (const raw of Array.isArray(ops) ? ops : []) {
|
|
123
|
+
const op = { ...raw, slideId: alias.get(raw?.slideId) ?? raw?.slideId }
|
|
124
|
+
if (op.type === 'add') {
|
|
125
|
+
const index = deck.slides.findIndex(slide => slide.id === op.slideId)
|
|
126
|
+
if (index < 0) return { status: 409, error: `No slide "${op.slideId}" in the deck any more`, deckHash: hash }
|
|
127
|
+
const slide = deck.slides[index]
|
|
128
|
+
if (slide.meta.id == null) {
|
|
129
|
+
// An id by position: only trusted for the deck the device saw.
|
|
130
|
+
if (deckHash !== hash) return { status: 409, error: 'The deck changed; reload', deckHash: hash }
|
|
131
|
+
const id = newSlideId(deck, slide, index)
|
|
132
|
+
renames.push({ from: op.slideId, to: id, before: hash })
|
|
133
|
+
source = setSlideMeta(deck, op.slideId, { id })
|
|
134
|
+
markOwnWrite(abs, source)
|
|
135
|
+
writeAtomic(abs, source)
|
|
136
|
+
deck = parseSlides(source)
|
|
137
|
+
hash = hashSource(source)
|
|
138
|
+
deckHash = hash
|
|
139
|
+
ink = applyOp(ink, { type: 'rename', from: op.slideId, to: id })
|
|
140
|
+
renamed.push({ from: op.slideId, to: id })
|
|
141
|
+
alias.set(op.slideId, id)
|
|
142
|
+
op.slideId = id
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
ink = applyOp(ink, op)
|
|
146
|
+
}
|
|
147
|
+
knownIds = deck.slides.map(slide => slide.id)
|
|
148
|
+
clearTimeout(timer)
|
|
149
|
+
timer = setTimeout(flush, debounceMs)
|
|
150
|
+
onChange({ renamed })
|
|
151
|
+
return { status: 200, deckHash: hash, renamed }
|
|
152
|
+
},
|
|
153
|
+
flush,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// `authorize(request)` may allow requests beyond this computer (pairing).
|
|
158
|
+
export function inkMiddleware(file, { authorize = isAllowedRequest } = {}) {
|
|
159
|
+
return async (request, response, next) => {
|
|
160
|
+
const { pathname } = new URL(request.url, 'http://localhost')
|
|
161
|
+
if (pathname !== '/' && pathname !== '/ops') return next()
|
|
162
|
+
if (!authorize(request)) return send(response, 403, { error: 'Only the presenter can change the ink' })
|
|
163
|
+
try {
|
|
164
|
+
if (pathname === '/' && request.method === 'GET') return send(response, 200, file.read())
|
|
165
|
+
if (pathname === '/ops' && request.method === 'POST') {
|
|
166
|
+
const { deckHash, ops } = await readJson(request)
|
|
167
|
+
const result = file.apply(ops, deckHash)
|
|
168
|
+
return send(response, result.status, result)
|
|
169
|
+
}
|
|
170
|
+
response.setHeader('Allow', pathname === '/' ? 'GET' : 'POST')
|
|
171
|
+
return send(response, 405, { error: 'Method not allowed' })
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return send(response, error.status ?? 500, { error: error.message })
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function inkPlugin(slidesPath, { authorize } = {}) {
|
|
179
|
+
return {
|
|
180
|
+
name: 'vite-plugin-mdeck-ink',
|
|
181
|
+
configureServer(server) {
|
|
182
|
+
const file = createInkFile(slidesPath, { onChange: ({ renamed }) => server.ws.send('mdeck:ink', { renamed }) })
|
|
183
|
+
server.middlewares.use('/__mdeck/ink', inkMiddleware(file, { authorize }))
|
|
184
|
+
server.watcher.on('change', changed => {
|
|
185
|
+
if (resolve(changed) !== resolve(slidesPath)) return
|
|
186
|
+
const renamed = file.deckChanged()
|
|
187
|
+
if (renamed.length) server.ws.send('mdeck:ink', { renamed })
|
|
188
|
+
})
|
|
189
|
+
server.httpServer?.once('close', () => file.flush())
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Files mdeck itself just wrote while the dev server runs, so the watcher can
|
|
2
|
+
// tell its own writes (a new slide id, saved ink) from edits by the author:
|
|
3
|
+
// its own writes must not reload the open windows in the middle of a talk.
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
import { resolve } from 'node:path'
|
|
6
|
+
|
|
7
|
+
const written = new Map()
|
|
8
|
+
const hash = content => createHash('sha1').update(content).digest('hex')
|
|
9
|
+
|
|
10
|
+
export function markOwnWrite(path, content) {
|
|
11
|
+
written.set(resolve(path), hash(content))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// True when `content` is what mdeck last wrote to `path`. The mark stays, as
|
|
15
|
+
// watchers may report one write twice; the same content needs no reload.
|
|
16
|
+
export function isOwnWrite(path, content) {
|
|
17
|
+
return written.get(resolve(path)) === hash(content)
|
|
18
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Pairing another device (an iPad) with `mdeck run`, so it may do what only
|
|
2
|
+
// this computer may: save ink and steer the rooms. The launch page, which
|
|
3
|
+
// only answers on this computer, offers a one-time address as a QR code; the
|
|
4
|
+
// device trades the one-time token in it for a token of its own, which it
|
|
5
|
+
// sends as `Authorization: Bearer …`. Tokens live in memory: restarting
|
|
6
|
+
// `mdeck run` or "Unpair" on the launch page ends every pairing.
|
|
7
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
8
|
+
import { send, readJson } from './editorPlugin.js'
|
|
9
|
+
|
|
10
|
+
const OFFER_MS = 10 * 60 * 1000
|
|
11
|
+
const token = () => randomBytes(24).toString('base64url')
|
|
12
|
+
const same = (a, b) => { const x = Buffer.from(a), y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y) }
|
|
13
|
+
|
|
14
|
+
export function createPairing({ now = Date.now } = {}) {
|
|
15
|
+
const offers = new Map() // one-time token → expiry
|
|
16
|
+
const devices = new Set()
|
|
17
|
+
const listeners = new Set()
|
|
18
|
+
const changed = () => { for (const listener of listeners) listener() }
|
|
19
|
+
const bearer = request => (request.headers?.authorization ?? '').replace(/^Bearer\s+/i, '')
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
/** A one-time token for the QR code, valid for ten minutes. */
|
|
23
|
+
offer() {
|
|
24
|
+
for (const [offer, expires] of offers) if (expires < now()) offers.delete(offer)
|
|
25
|
+
const offer = token()
|
|
26
|
+
offers.set(offer, now() + OFFER_MS)
|
|
27
|
+
return offer
|
|
28
|
+
},
|
|
29
|
+
/** The device's own token for a one-time token, or null. */
|
|
30
|
+
claim(offer) {
|
|
31
|
+
if (typeof offer !== 'string') return null
|
|
32
|
+
for (const [known, expires] of offers) {
|
|
33
|
+
if (!same(known, offer)) continue
|
|
34
|
+
offers.delete(known)
|
|
35
|
+
if (expires < now()) return null
|
|
36
|
+
const device = token()
|
|
37
|
+
devices.add(device)
|
|
38
|
+
changed()
|
|
39
|
+
return device
|
|
40
|
+
}
|
|
41
|
+
return null
|
|
42
|
+
},
|
|
43
|
+
/** Whether a request comes from a paired device. */
|
|
44
|
+
allows(request) {
|
|
45
|
+
const given = bearer(request)
|
|
46
|
+
if (!given) return false
|
|
47
|
+
for (const device of devices) if (same(device, given)) return true
|
|
48
|
+
return false
|
|
49
|
+
},
|
|
50
|
+
revoke() { devices.clear(); offers.clear(); changed() },
|
|
51
|
+
/** The paired devices' tokens, for the server of `mdeck run --server`. */
|
|
52
|
+
tokens: () => [...devices],
|
|
53
|
+
/** Calls back when a device pairs or all are unpaired; returns a function that stops it. */
|
|
54
|
+
onChange(listener) { listeners.add(listener); return () => listeners.delete(listener) },
|
|
55
|
+
get devices() { return devices.size },
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// POST /claim { token } → { token }: open to the network, like the page the
|
|
60
|
+
// QR code leads to; everything else about pairing is on the launch page.
|
|
61
|
+
export function pairingMiddleware(pairing) {
|
|
62
|
+
return async (request, response, next) => {
|
|
63
|
+
const { pathname } = new URL(request.url, 'http://localhost')
|
|
64
|
+
if (pathname !== '/claim') return next()
|
|
65
|
+
if (request.method !== 'POST') { response.setHeader('Allow', 'POST'); return send(response, 405, { error: 'Method not allowed' }) }
|
|
66
|
+
try {
|
|
67
|
+
const device = pairing.claim((await readJson(request))?.token)
|
|
68
|
+
if (!device) return send(response, 403, { error: 'This pairing code was used or is too old; show a new one on the launch page' })
|
|
69
|
+
return send(response, 200, { token: device })
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return send(response, error.status ?? 500, { error: error.message })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function pairingPlugin(pairing) {
|
|
77
|
+
return {
|
|
78
|
+
name: 'vite-plugin-mdeck-pairing',
|
|
79
|
+
configureServer(server) { server.middlewares.use('/__mdeck/pair', pairingMiddleware(pairing)) },
|
|
80
|
+
}
|
|
81
|
+
}
|
package/src/build/pdf.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Renders a built deck to PDF with a local headless Chrome, using the deck's
|
|
2
|
+
// own print rules: one slide per page at the design size, vector text.
|
|
3
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { resolve, dirname, basename } from 'node:path'
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
6
|
+
import { launchChrome, findChrome } from './chrome.js'
|
|
7
|
+
|
|
8
|
+
export { findChrome }
|
|
9
|
+
|
|
10
|
+
export async function renderPdf({ htmlFile, output, chrome = findChrome(), timeout = 90000 }) {
|
|
11
|
+
const html = resolve(htmlFile)
|
|
12
|
+
const browser = await launchChrome({ dir: dirname(html), chrome, timeout })
|
|
13
|
+
try {
|
|
14
|
+
const page = await browser.open(`${encodeURIComponent(basename(html))}?view=deck&embedded=1`)
|
|
15
|
+
await page.waitForDeck()
|
|
16
|
+
// Reveal every step and let interactive slides render their final state.
|
|
17
|
+
await page.evaluate("document.querySelector('deck-stage').printing = true")
|
|
18
|
+
await delay(300)
|
|
19
|
+
const { data } = await page.send('Page.printToPDF', { printBackground: true, preferCSSPageSize: true, displayHeaderFooter: false, marginTop: 0, marginBottom: 0, marginLeft: 0, marginRight: 0 })
|
|
20
|
+
writeFileSync(output, Buffer.from(data, 'base64'))
|
|
21
|
+
return output
|
|
22
|
+
} finally {
|
|
23
|
+
await browser.close()
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Points the share view at the PDF: a relative link for folders, an embedded
|
|
28
|
+
// data URI for single files.
|
|
29
|
+
export function attachPdf(htmlFile, pdfFile, { embed = false } = {}) {
|
|
30
|
+
const html = readFileSync(htmlFile, 'utf8').replace(/<link rel="alternate" type="application\/pdf"[^>]*>/, '')
|
|
31
|
+
const href = embed ? `data:application/pdf;base64,${readFileSync(pdfFile).toString('base64')}` : encodeURIComponent(basename(pdfFile))
|
|
32
|
+
const link = `<link rel="alternate" type="application/pdf" href="${href}">`
|
|
33
|
+
writeFileSync(htmlFile, html.includes('</head>') ? html.replace('</head>', `${link}</head>`) : link + html)
|
|
34
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readFileSync, realpathSync } from 'node:fs'
|
|
2
|
+
import { resolve, dirname } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { shareable, resourceKey } from './tunnelClient.js'
|
|
5
|
+
import { collectLocalAssetRefs } from './slidesPlugin.js'
|
|
6
|
+
import { parseSlides } from '../core/parseSlides.js'
|
|
7
|
+
import { sessionCode } from '../live/code.js'
|
|
8
|
+
|
|
9
|
+
const canonical = file => { try { return realpathSync(file) } catch { return null } }
|
|
10
|
+
|
|
11
|
+
// Use the entry's import graph, rather than Vite's filesystem allow list.
|
|
12
|
+
// Unrelated files and modules that a local editor loaded stay private.
|
|
13
|
+
export function createRelayAccess(server, slidesPath) {
|
|
14
|
+
const base = server.config.base
|
|
15
|
+
const root = server.config.root
|
|
16
|
+
const graph = server.environments.client.moduleGraph
|
|
17
|
+
const at = path => `${base}${path.replace(/^\//, '')}`
|
|
18
|
+
const bootstrap = ['main.jsx', '@vite/client', '@vite/env', '@id/__x00__virtual:prefresh']
|
|
19
|
+
const knownFiles = new Map()
|
|
20
|
+
let knownSource = null
|
|
21
|
+
return async (address, method) => {
|
|
22
|
+
let source
|
|
23
|
+
try { source = readFileSync(slidesPath, 'utf8') } catch { return false }
|
|
24
|
+
if (source !== knownSource) { knownFiles.clear(); knownSource = source }
|
|
25
|
+
const resources = new Set(bootstrap.map(path => resourceKey(at(path))))
|
|
26
|
+
const files = new Map()
|
|
27
|
+
const addFile = (file, address) => {
|
|
28
|
+
const real = canonical(file)
|
|
29
|
+
if (!real) return
|
|
30
|
+
if (!knownFiles.has(file)) knownFiles.set(file, real)
|
|
31
|
+
if (knownFiles.get(file) !== real) return
|
|
32
|
+
resources.add(resourceKey(address))
|
|
33
|
+
files.set(resourceKey(address), { file, real })
|
|
34
|
+
}
|
|
35
|
+
const seen = new Set()
|
|
36
|
+
const visit = mod => {
|
|
37
|
+
if (!mod || seen.has(mod)) return
|
|
38
|
+
seen.add(mod)
|
|
39
|
+
const url = mod.url.startsWith(base) ? mod.url : at(mod.url)
|
|
40
|
+
if (mod.file && !mod.id?.startsWith('\0')) addFile(mod.file, url)
|
|
41
|
+
else resources.add(resourceKey(url))
|
|
42
|
+
if (mod.id?.startsWith('\0')) resources.add(resourceKey(at(`@id/__x00__${mod.id.slice(1)}`)))
|
|
43
|
+
for (const child of mod.importedModules) visit(child)
|
|
44
|
+
// Vite rewrites CSS url() references without adding them as JS imports.
|
|
45
|
+
if (mod.file?.endsWith('.css')) {
|
|
46
|
+
let css
|
|
47
|
+
try { css = readFileSync(mod.file, 'utf8') } catch { return }
|
|
48
|
+
for (const match of css.matchAll(/url\(\s*["']?([^\s"')]+)["']?\s*\)/g)) {
|
|
49
|
+
const ref = match[1]
|
|
50
|
+
if (/^(?:[a-z]+:|\/\/|#)/i.test(ref)) continue
|
|
51
|
+
const file = ref.startsWith('/') ? resolve(root, `.${ref}`) : resolve(dirname(mod.file), ref.split(/[?#]/)[0])
|
|
52
|
+
addFile(file, at(`@fs/${file.replace(/^\//, '')}`))
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
visit(graph.getModuleById(resolve(root, 'main.jsx')))
|
|
57
|
+
// The HTML-injected Vite client is a second trusted entry point.
|
|
58
|
+
visit(await graph.getModuleByUrl(at('@vite/client')))
|
|
59
|
+
const viteEnv = fileURLToPath(new URL('../client/env.mjs', import.meta.resolve('vite')))
|
|
60
|
+
addFile(viteEnv, at(`@fs/${viteEnv.replace(/^\//, '')}`))
|
|
61
|
+
addFile(resolve(root, 'main.jsx'), at('main.jsx'))
|
|
62
|
+
addFile(resolve(root, 'index.html'), at('index.html'))
|
|
63
|
+
addFile(fileURLToPath(new URL('../client/client.mjs', import.meta.resolve('vite'))), at('@vite/client'))
|
|
64
|
+
for (const ref of collectLocalAssetRefs(source)) {
|
|
65
|
+
// Root-relative assets belong to the deck's public directory.
|
|
66
|
+
const file = resolve(dirname(slidesPath), ref.replace(/^\//, ''))
|
|
67
|
+
const path = ref.replace(/^\.\//, '').replace(/^\//, '')
|
|
68
|
+
addFile(file, at(path))
|
|
69
|
+
addFile(file, at(`@fs/${file.replace(/^\//, '')}`))
|
|
70
|
+
}
|
|
71
|
+
const allowed = shareable(address, method, { base, resources, session: sessionCode(parseSlides(source).deckConfig) })
|
|
72
|
+
if (!allowed) return false
|
|
73
|
+
// Recheck symlinks before forwarding, even if Vite cached an older graph.
|
|
74
|
+
const entry = files.get(resourceKey(address))
|
|
75
|
+
if (entry && canonical(entry.file) !== entry.real) return false
|
|
76
|
+
// Vite serves public files ahead of framework modules. A file beside the
|
|
77
|
+
// deck must not shadow an approved runtime URL and become shareable.
|
|
78
|
+
const path = decodeURIComponent(new URL(address, 'http://localhost').pathname).slice(base.length)
|
|
79
|
+
if (path && !path.startsWith('__mdeck/')) {
|
|
80
|
+
const publicFile = canonical(resolve(dirname(slidesPath), path))
|
|
81
|
+
if (publicFile && publicFile !== entry?.real) return false
|
|
82
|
+
}
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
}
|