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,130 @@
|
|
|
1
|
+
// Headless Chrome over the DevTools protocol, used for PDF rendering and
|
|
2
|
+
// screenshots. Serves a folder over loopback so relative assets resolve.
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { existsSync, readFileSync, mkdtempSync, rmSync, statSync } from 'node:fs'
|
|
5
|
+
import { createServer } from 'node:http'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import { resolve, extname, relative, isAbsolute } from 'node:path'
|
|
8
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
9
|
+
|
|
10
|
+
const CANDIDATES = {
|
|
11
|
+
darwin: ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
|
|
12
|
+
linux: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium'],
|
|
13
|
+
win32: ['C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe'],
|
|
14
|
+
}
|
|
15
|
+
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.mp4': 'video/mp4', '.webm': 'video/webm', '.woff2': 'font/woff2', '.woff': 'font/woff', '.pdf': 'application/pdf' }
|
|
16
|
+
|
|
17
|
+
export function findChrome() {
|
|
18
|
+
if (process.env.MDECK_CHROME) return existsSync(process.env.MDECK_CHROME) ? process.env.MDECK_CHROME : null
|
|
19
|
+
return (CANDIDATES[process.platform] ?? []).find(existsSync) ?? null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function serveDirectory(root) {
|
|
23
|
+
return createServer((request, response) => {
|
|
24
|
+
try {
|
|
25
|
+
const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname)
|
|
26
|
+
const file = resolve(root, '.' + pathname)
|
|
27
|
+
const rel = relative(root, file)
|
|
28
|
+
if (rel.startsWith('..') || isAbsolute(rel) || !statSync(file).isFile()) throw new Error('not found')
|
|
29
|
+
response.writeHead(200, { 'Content-Type': MIME[extname(file).toLowerCase()] ?? 'application/octet-stream' })
|
|
30
|
+
response.end(readFileSync(file))
|
|
31
|
+
} catch { response.writeHead(404); response.end() }
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Starts Chrome and a static server for `dir`. `open(path)` returns a page
|
|
36
|
+
// with evaluate(), send() and waitFor(); call close() when done.
|
|
37
|
+
export async function launchChrome({ dir, chrome = findChrome(), timeout = 90000 } = {}) {
|
|
38
|
+
if (!chrome) throw new Error('No Chrome or Chromium found. Set MDECK_CHROME to its executable path.')
|
|
39
|
+
const temp = mkdtempSync(resolve(tmpdir(), 'mdeck-chrome-'))
|
|
40
|
+
const server = serveDirectory(dir)
|
|
41
|
+
await new Promise(done => server.listen(0, '127.0.0.1', done))
|
|
42
|
+
const origin = `http://127.0.0.1:${server.address().port}`
|
|
43
|
+
const flags = ['--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--hide-scrollbars', '--remote-debugging-port=0', `--user-data-dir=${resolve(temp, 'profile')}`,
|
|
44
|
+
// Every tab keeps rendering, like the separate windows of a talk: Chrome
|
|
45
|
+
// otherwise pauses tabs behind the front one, and an input event or an
|
|
46
|
+
// animation frame there waited for a frame that did not come.
|
|
47
|
+
'--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows']
|
|
48
|
+
// Chrome refuses to start as root with its sandbox on, as in CI containers.
|
|
49
|
+
if (process.getuid?.() === 0) flags.push('--no-sandbox')
|
|
50
|
+
const browser = spawn(chrome, [...flags, 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] })
|
|
51
|
+
const pending = new Map()
|
|
52
|
+
let socket
|
|
53
|
+
// `timeout` limits each step (starting Chrome, each command), not the whole
|
|
54
|
+
// session: a long job with many quick steps must not fail at an arbitrary
|
|
55
|
+
// step once a total budget runs out.
|
|
56
|
+
const close = async () => {
|
|
57
|
+
for (const task of pending.values()) clearTimeout(task.timer)
|
|
58
|
+
socket?.close()
|
|
59
|
+
server.close()
|
|
60
|
+
if (browser.exitCode === null) { browser.kill(); await new Promise(done => browser.once('exit', done)) }
|
|
61
|
+
// Chrome's helper processes may still write to the profile for a moment
|
|
62
|
+
// after the browser exits; retry, and never fail a finished job over a
|
|
63
|
+
// temporary folder.
|
|
64
|
+
try { rmSync(temp, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) } catch {}
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const endpoint = await new Promise((resolveEndpoint, reject) => {
|
|
68
|
+
let log = ''
|
|
69
|
+
const startup = setTimeout(() => reject(new Error('Timed out while starting Chrome')), timeout)
|
|
70
|
+
browser.on('error', reject)
|
|
71
|
+
browser.on('exit', code => reject(new Error(`Chrome exited early (${code})`)))
|
|
72
|
+
browser.stderr.on('data', data => { log += data; const match = log.match(/DevTools listening on (ws:\/\/\S+)/); if (match) { clearTimeout(startup); resolveEndpoint(match[1]) } })
|
|
73
|
+
})
|
|
74
|
+
socket = new WebSocket(endpoint)
|
|
75
|
+
await new Promise((done, reject) => { socket.addEventListener('open', done, { once: true }); socket.addEventListener('error', reject, { once: true }) })
|
|
76
|
+
let nextId = 0
|
|
77
|
+
socket.addEventListener('message', ({ data }) => {
|
|
78
|
+
const message = JSON.parse(data)
|
|
79
|
+
if (!message.id) return
|
|
80
|
+
const task = pending.get(message.id)
|
|
81
|
+
pending.delete(message.id)
|
|
82
|
+
clearTimeout(task?.timer)
|
|
83
|
+
if (message.error) task?.reject(new Error(message.error.message))
|
|
84
|
+
else task?.resolve(message.result)
|
|
85
|
+
})
|
|
86
|
+
const send = (method, params = {}, sessionId) => new Promise((resolveTask, reject) => {
|
|
87
|
+
const id = ++nextId
|
|
88
|
+
// Where the command came from, so a timeout names the step that hung.
|
|
89
|
+
const caller = new Error().stack?.split('\n').slice(2).find(line => !line.includes('/src/build/chrome.js'))?.trim() ?? ''
|
|
90
|
+
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timed out while driving Chrome: ${method} did not answer within ${timeout / 1000} s ${caller}`)) }, timeout)
|
|
91
|
+
pending.set(id, { resolve: resolveTask, reject, timer })
|
|
92
|
+
socket.send(JSON.stringify({ id, method, params, sessionId }))
|
|
93
|
+
})
|
|
94
|
+
const open = async path => {
|
|
95
|
+
const url = path.startsWith('http') ? path : origin + '/' + path.replace(/^\//, '')
|
|
96
|
+
const { targetId } = await send('Target.createTarget', { url })
|
|
97
|
+
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true })
|
|
98
|
+
const page = {
|
|
99
|
+
url, sessionId,
|
|
100
|
+
send: (method, params) => send(method, params, sessionId),
|
|
101
|
+
async evaluate(expression) {
|
|
102
|
+
const result = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, sessionId)
|
|
103
|
+
if (result.exceptionDetails) throw new Error(result.exceptionDetails.text)
|
|
104
|
+
return result.result.value
|
|
105
|
+
},
|
|
106
|
+
async waitFor(expression, { attempts = 600, interval = 100 } = {}) {
|
|
107
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
108
|
+
if (await page.evaluate(expression)) return true
|
|
109
|
+
await delay(interval)
|
|
110
|
+
}
|
|
111
|
+
return false
|
|
112
|
+
},
|
|
113
|
+
// Waits until the deck stage exists, fonts are loaded and images decoded.
|
|
114
|
+
waitForDeck: () => page.waitFor("document.querySelector('deck-stage')?.length > 0 && document.fonts.status === 'loaded' && [...document.images].every(image => image.complete)"),
|
|
115
|
+
async screenshot({ width = 1600, height = 900, scale = 1, format = 'png' } = {}) {
|
|
116
|
+
await send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: scale, mobile: false }, sessionId)
|
|
117
|
+
await delay(150)
|
|
118
|
+
const { data } = await send('Page.captureScreenshot', { format, ...(format === 'png' ? {} : { quality: 82 }), captureBeyondViewport: false }, sessionId)
|
|
119
|
+
return Buffer.from(data, 'base64')
|
|
120
|
+
},
|
|
121
|
+
close: () => send('Target.closeTarget', { targetId }),
|
|
122
|
+
}
|
|
123
|
+
return page
|
|
124
|
+
}
|
|
125
|
+
return { origin, open, close }
|
|
126
|
+
} catch (error) {
|
|
127
|
+
await close()
|
|
128
|
+
throw error
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Where a deck's own Preact components come from: its `components/` folder,
|
|
2
|
+
// then the folders its `components:` setting lists, in order. Each `*.jsx`
|
|
3
|
+
// file's default export is registered under its lowercased file name, so
|
|
4
|
+
// `Tokenizer.jsx` becomes `<tokenizer>`. The first folder that has a name wins,
|
|
5
|
+
// and all of them override built-ins of the same name.
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
7
|
+
import { homedir } from 'node:os'
|
|
8
|
+
import { resolve, dirname, basename } from 'node:path'
|
|
9
|
+
import { parseSlides } from '../core/parseSlides.js'
|
|
10
|
+
|
|
11
|
+
function expand(path, deckDir) {
|
|
12
|
+
if (path === '~' || path.startsWith('~/')) return resolve(homedir(), path.slice(2))
|
|
13
|
+
return resolve(deckDir, path)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Folder paths as written in the deck, resolved; entries that are not strings
|
|
17
|
+
// are left to validateDeck to report.
|
|
18
|
+
export function componentFolders(slidesPath, source = readFileSync(slidesPath, 'utf8')) {
|
|
19
|
+
const abs = resolve(slidesPath)
|
|
20
|
+
const deckDir = dirname(abs)
|
|
21
|
+
let listed = []
|
|
22
|
+
try { listed = parseSlides(source).deckConfig?.components ?? [] } catch {}
|
|
23
|
+
return [
|
|
24
|
+
{ dir: resolve(deckDir, 'components'), source: 'deck', path: 'components' },
|
|
25
|
+
...(Array.isArray(listed) ? listed : []).filter(path => typeof path === 'string' && path.trim())
|
|
26
|
+
.map(path => ({ dir: expand(path.trim(), deckDir), source: 'shared', path: path.trim() })),
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isFolder(dir) {
|
|
31
|
+
try { return statSync(dir).isDirectory() } catch { return false }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function componentFiles(slidesPath, folders = componentFolders(slidesPath)) {
|
|
35
|
+
const seen = new Map()
|
|
36
|
+
for (const folder of folders) {
|
|
37
|
+
if (!existsSync(folder.dir) || !isFolder(folder.dir)) continue
|
|
38
|
+
for (const name of readdirSync(folder.dir).filter(name => /\.jsx$/.test(name)).sort()) {
|
|
39
|
+
const tag = basename(name, '.jsx').toLowerCase()
|
|
40
|
+
if (!seen.has(tag)) seen.set(tag, { tag, file: resolve(folder.dir, name), source: folder.source, folder: folder.path })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return [...seen.values()]
|
|
44
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { resolve, dirname } from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import preact from '@preact/preset-vite'
|
|
4
|
+
import { slidesPlugin } from './slidesPlugin.js'
|
|
5
|
+
import { frameworkRoot, runtimeRoot } from '../paths.js'
|
|
6
|
+
import { componentFolders } from './components.js'
|
|
7
|
+
import { userExtensionsDir } from '../extensions/discover.js'
|
|
8
|
+
|
|
9
|
+
// Resolve preact's entry points through Node so the package's `exports` map is
|
|
10
|
+
// honoured — `preact/jsx-dev-runtime` has no directory of its own, so a
|
|
11
|
+
// hand-written path alias would break the dev server. Exact-match regexes keep
|
|
12
|
+
// `preact` from swallowing its own subpaths.
|
|
13
|
+
const PREACT_ENTRIES = [
|
|
14
|
+
'preact',
|
|
15
|
+
'preact/hooks',
|
|
16
|
+
'preact/jsx-runtime',
|
|
17
|
+
'preact/jsx-dev-runtime',
|
|
18
|
+
'preact/compat',
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
function preactAliases() {
|
|
22
|
+
const aliases = []
|
|
23
|
+
for (const spec of PREACT_ENTRIES) {
|
|
24
|
+
let file
|
|
25
|
+
try {
|
|
26
|
+
file = fileURLToPath(import.meta.resolve(spec))
|
|
27
|
+
} catch {
|
|
28
|
+
continue // not installed in this framework copy — skip it
|
|
29
|
+
}
|
|
30
|
+
aliases.push({ find: new RegExp(`^${spec.replace(/\//g, '\\/')}$`), replacement: file })
|
|
31
|
+
}
|
|
32
|
+
return aliases
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Vite config ───────────────────────────────────────────────────────────────
|
|
36
|
+
export function baseConfig(slidesPath, { selfContained = false, defaultView = 'deck', server = null, proxyControls = false, source = undefined } = {}) {
|
|
37
|
+
const abs = resolve(slidesPath)
|
|
38
|
+
return {
|
|
39
|
+
configFile: false,
|
|
40
|
+
root: runtimeRoot,
|
|
41
|
+
plugins: [preact(), slidesPlugin(abs, source === undefined ? {} : { source })],
|
|
42
|
+
// Deck-local components live outside the framework root and have no
|
|
43
|
+
// node_modules of their own, so their preact imports must resolve back to
|
|
44
|
+
// the framework's copy — and to the *same* instance, or hooks break.
|
|
45
|
+
resolve: {
|
|
46
|
+
dedupe: ['preact'],
|
|
47
|
+
alias: preactAliases(),
|
|
48
|
+
},
|
|
49
|
+
server: {
|
|
50
|
+
port: 5173,
|
|
51
|
+
strictPort: false,
|
|
52
|
+
fs: { allow: [frameworkRoot, dirname(abs), userExtensionsDir(), ...componentFolders(abs, source).map(folder => folder.dir)] },
|
|
53
|
+
},
|
|
54
|
+
define: {
|
|
55
|
+
__MDECK_SELF_CONTAINED__: JSON.stringify(selfContained),
|
|
56
|
+
__MDECK_DEFAULT_VIEW__: JSON.stringify(defaultView),
|
|
57
|
+
__MDECK_SERVER__: JSON.stringify(server),
|
|
58
|
+
__MDECK_PROXY_CONTROLS__: JSON.stringify(proxyControls),
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { basename, resolve } from 'node:path'
|
|
3
|
+
import { inkFileFor, oldInkFileFor } from '../core/ink.js'
|
|
4
|
+
|
|
5
|
+
export function drawingsDiagnostic(slidesPath) {
|
|
6
|
+
const file = inkFileFor(resolve(slidesPath)), old = oldInkFileFor(resolve(slidesPath))
|
|
7
|
+
if (existsSync(old) && !existsSync(file)) return { severity: 'error', code: 'renamed-file', message: `${basename(old)} is now ${basename(file)}: run mdeck migrate to keep your drawings`, line: 1, column: 1 }
|
|
8
|
+
return null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function assertDrawings(slidesPath) {
|
|
12
|
+
const problem = drawingsDiagnostic(slidesPath)
|
|
13
|
+
if (problem) throw new Error(problem.message)
|
|
14
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Local editing API for `mdeck edit`. The server is a guarded file writer:
|
|
2
|
+
// it only ever writes the one deck path, rejects stale writes with the
|
|
3
|
+
// current content, and tells the editor when the file changed on disk.
|
|
4
|
+
// Before its first write it copies the file into .mdeck-backups/ beside it.
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, unlinkSync } from 'node:fs'
|
|
7
|
+
import { resolve, basename, dirname, relative } from 'node:path'
|
|
8
|
+
import { builtInRegistry, loadRegistry, serializeRegistry } from '../extensions/discover.js'
|
|
9
|
+
import { fetchIndex, findPack, installedPacks, installFromIndex, PackError } from '../extensions/packs.js'
|
|
10
|
+
import { createRequire } from 'node:module'
|
|
11
|
+
import { ManifestError, KINDS, ID_RE, MANIFEST_FILENAME, parseManifestText, validateManifest } from '../extensions/manifest.js'
|
|
12
|
+
|
|
13
|
+
const VERSION = createRequire(import.meta.url)('../../package.json').version
|
|
14
|
+
|
|
15
|
+
const BODY_LIMIT = 32 * 1024 * 1024
|
|
16
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
|
|
17
|
+
|
|
18
|
+
export function hashSource(source) {
|
|
19
|
+
return createHash('sha1').update(source, 'utf8').digest('hex')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const BACKUP_DIR = '.mdeck-backups'
|
|
23
|
+
const BACKUPS_KEPT = 10
|
|
24
|
+
|
|
25
|
+
// Copies the deck as it is now to .mdeck-backups/<name>-<time>.md and keeps
|
|
26
|
+
// the newest few copies of this deck.
|
|
27
|
+
export function backupDeck(abs, source, now = new Date()) {
|
|
28
|
+
const dir = resolve(dirname(abs), BACKUP_DIR)
|
|
29
|
+
mkdirSync(dir, { recursive: true })
|
|
30
|
+
const name = basename(abs).replace(/\.md$/i, '')
|
|
31
|
+
const stamp = now.toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, 'Z')
|
|
32
|
+
const file = resolve(dir, `${name}-${stamp}.md`)
|
|
33
|
+
writeFileSync(file, source, 'utf8')
|
|
34
|
+
const prefix = `${name}-`
|
|
35
|
+
const copies = readdirSync(dir).filter(entry => entry.startsWith(prefix) && /^\d{4}-\d{2}-\d{2}T[\d-]+Z\.md$/.test(entry.slice(prefix.length))).sort()
|
|
36
|
+
for (const old of copies.slice(0, -BACKUPS_KEPT)) unlinkSync(resolve(dir, old))
|
|
37
|
+
return file
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createDeckFile(abs, { backup = true } = {}) {
|
|
41
|
+
let lastWritten = null
|
|
42
|
+
let backupFile = null
|
|
43
|
+
const read = () => {
|
|
44
|
+
const source = readFileSync(abs, 'utf8')
|
|
45
|
+
return { source, hash: hashSource(source) }
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
path: abs,
|
|
49
|
+
read,
|
|
50
|
+
write(source, base) {
|
|
51
|
+
if (!existsSync(abs)) return { ok: false, conflict: true, source: null, hash: null }
|
|
52
|
+
const current = read()
|
|
53
|
+
if (base !== current.hash) return { ok: false, conflict: true, ...current }
|
|
54
|
+
if (backup && !backupFile && source !== current.source) backupFile = backupDeck(abs, current.source)
|
|
55
|
+
writeFileSync(abs, source, 'utf8')
|
|
56
|
+
lastWritten = source
|
|
57
|
+
return { ok: true, hash: hashSource(source) }
|
|
58
|
+
},
|
|
59
|
+
isExternalChange(content) { return content !== lastWritten },
|
|
60
|
+
get backupFile() { return backupFile },
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hostname(host = '') {
|
|
65
|
+
const match = host.match(/^(\[[^\]]*\]|[^:]+)(?::\d+)?$/)
|
|
66
|
+
return match ? match[1].toLowerCase() : ''
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Names under .localhost always mean this computer and are never looked up
|
|
70
|
+
// in DNS (RFC 6761), so local proxies such as `name.localhost` count too.
|
|
71
|
+
const isLoopbackName = name => LOOPBACK.has(name) || name.endsWith('.localhost')
|
|
72
|
+
|
|
73
|
+
// Browsers send Origin on cross-site requests; a page served by this server
|
|
74
|
+
// has the same host. Everything else is refused, and only loopback hosts are
|
|
75
|
+
// served at all.
|
|
76
|
+
export const TUNNEL_MARK = 'x-mdeck-tunnel'
|
|
77
|
+
|
|
78
|
+
export function isAllowedRequest(request) {
|
|
79
|
+
// Through `mdeck run --server` every request arrives from this computer; the
|
|
80
|
+
// tunnel marks them (src/build/tunnelClient.js), and they must show a paired
|
|
81
|
+
// device's token instead.
|
|
82
|
+
if (request.headers[TUNNEL_MARK] != null) return false
|
|
83
|
+
const peer = request.socket?.remoteAddress
|
|
84
|
+
if (peer && peer !== '::1' && !/^(?:::ffff:)?127\.\d+\.\d+\.\d+$/.test(peer)) return false
|
|
85
|
+
const host = request.headers.host ?? ''
|
|
86
|
+
if (!isLoopbackName(hostname(host))) return false
|
|
87
|
+
const origin = request.headers.origin
|
|
88
|
+
if (origin == null) return true
|
|
89
|
+
try { return new URL(origin).host.toLowerCase() === host.toLowerCase() } catch { return false }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function send(response, status, body) {
|
|
93
|
+
const text = JSON.stringify(body)
|
|
94
|
+
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(text), 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' })
|
|
95
|
+
response.end(text)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function readJson(request) {
|
|
99
|
+
return new Promise((resolvePromise, reject) => {
|
|
100
|
+
const type = request.headers['content-type'] ?? ''
|
|
101
|
+
if (!/^application\/json\b/i.test(type)) return reject(Object.assign(new Error('Send JSON with Content-Type: application/json'), { status: 415 }))
|
|
102
|
+
const chunks = []
|
|
103
|
+
let size = 0
|
|
104
|
+
request.on('data', chunk => {
|
|
105
|
+
size += chunk.length
|
|
106
|
+
if (size > BODY_LIMIT) { reject(Object.assign(new Error('Request body too large'), { status: 413 })); request.destroy(); return }
|
|
107
|
+
chunks.push(chunk)
|
|
108
|
+
})
|
|
109
|
+
request.on('end', () => {
|
|
110
|
+
try { resolvePromise(JSON.parse(Buffer.concat(chunks).toString('utf8'))) } catch { reject(Object.assign(new Error('Request body is not valid JSON'), { status: 400 })) }
|
|
111
|
+
})
|
|
112
|
+
request.on('error', reject)
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Deck-local extensions ────────────────────────────────────────────────
|
|
117
|
+
// Extension folders beside the deck can be read, written and removed. Built-in
|
|
118
|
+
// extensions are readable only. File names are plain basenames inside the
|
|
119
|
+
// extension folder; the manifest must validate before anything is written.
|
|
120
|
+
|
|
121
|
+
const FILE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
122
|
+
const TEXT_FILE_RE = /\.(toml|jsx|js|css|md|txt|json)$/i
|
|
123
|
+
|
|
124
|
+
export function createExtensionStore(deckDir, { registryFor }) {
|
|
125
|
+
const root = resolve(deckDir, 'extensions')
|
|
126
|
+
const folder = id => resolve(root, id)
|
|
127
|
+
const check = (kind, id) => {
|
|
128
|
+
if (!KINDS.includes(kind)) throw Object.assign(new Error('Unknown extension kind'), { status: 400 })
|
|
129
|
+
if (typeof id !== 'string' || !ID_RE.test(id)) throw Object.assign(new Error('Extension ids use lowercase letters, digits and hyphens and start with a letter'), { status: 400 })
|
|
130
|
+
}
|
|
131
|
+
const readFolder = dir => Object.fromEntries(readdirSync(dir, { withFileTypes: true })
|
|
132
|
+
.filter(entry => entry.isFile() && TEXT_FILE_RE.test(entry.name) && FILE_NAME_RE.test(entry.name))
|
|
133
|
+
.map(entry => [entry.name, readFileSync(resolve(dir, entry.name), 'utf8')]))
|
|
134
|
+
return {
|
|
135
|
+
root,
|
|
136
|
+
read(kind, id) {
|
|
137
|
+
check(kind, id)
|
|
138
|
+
const record = registryFor()[`${kind}s`][id]
|
|
139
|
+
if (!record) throw Object.assign(new Error(`No ${kind} "${id}"`), { status: 404 })
|
|
140
|
+
return { kind, id, source: record.source, dir: record.dir, files: readFolder(record.dir), manifest: record.manifest }
|
|
141
|
+
},
|
|
142
|
+
// Validates the manifest against the files that will exist, then writes.
|
|
143
|
+
write(kind, id, files) {
|
|
144
|
+
check(kind, id)
|
|
145
|
+
if (!files || typeof files !== 'object' || Array.isArray(files)) throw Object.assign(new Error('Expected files: { name: text | null }'), { status: 400 })
|
|
146
|
+
for (const [name, text] of Object.entries(files)) {
|
|
147
|
+
if (!FILE_NAME_RE.test(name) || !TEXT_FILE_RE.test(name)) throw Object.assign(new Error(`Cannot write "${name}"`), { status: 400 })
|
|
148
|
+
if (text !== null && typeof text !== 'string') throw Object.assign(new Error(`"${name}" must be text or null`), { status: 400 })
|
|
149
|
+
}
|
|
150
|
+
const registry = registryFor()
|
|
151
|
+
const existing = registry[`${kind}s`][id]
|
|
152
|
+
if (existing && existing.source !== 'local') throw Object.assign(new Error(`"${id}" is ${existing.source === 'user' ? 'installed for every deck' : 'built into mdeck'}; copy it into this deck under a new id to change it`), { status: 403 })
|
|
153
|
+
for (const other of KINDS.filter(k => k !== kind)) if (registry[`${other}s`][id]) throw Object.assign(new Error(`"${id}" is already a ${other}`), { status: 409 })
|
|
154
|
+
const dir = folder(id)
|
|
155
|
+
const current = existsSync(dir) ? readFolder(dir) : {}
|
|
156
|
+
const next = { ...current }
|
|
157
|
+
for (const [name, text] of Object.entries(files)) { if (text === null) delete next[name]; else next[name] = text }
|
|
158
|
+
if (typeof next[MANIFEST_FILENAME] !== 'string') throw Object.assign(new Error(`${MANIFEST_FILENAME} is required`), { status: 400 })
|
|
159
|
+
const manifestPath = resolve(dir, MANIFEST_FILENAME)
|
|
160
|
+
const record = validateManifest(parseManifestText(next[MANIFEST_FILENAME], manifestPath), {
|
|
161
|
+
file: manifestPath, dir, folderName: id, fileExists: path => Object.hasOwn(next, relative(dir, path)),
|
|
162
|
+
})
|
|
163
|
+
if (record.kind !== kind) throw Object.assign(new Error(`The manifest says kind = "${record.kind}", not "${kind}"`), { status: 400 })
|
|
164
|
+
mkdirSync(dir, { recursive: true })
|
|
165
|
+
for (const [name, text] of Object.entries(files)) {
|
|
166
|
+
const path = resolve(dir, name)
|
|
167
|
+
if (text === null) { if (existsSync(path)) unlinkSync(path) } else writeFileSync(path, text, 'utf8')
|
|
168
|
+
}
|
|
169
|
+
return { kind, id, dir, files: readFolder(dir) }
|
|
170
|
+
},
|
|
171
|
+
remove(kind, id) {
|
|
172
|
+
check(kind, id)
|
|
173
|
+
const record = registryFor()[`${kind}s`][id]
|
|
174
|
+
if (!record) throw Object.assign(new Error(`No ${kind} "${id}"`), { status: 404 })
|
|
175
|
+
if (record.source !== 'local') throw Object.assign(new Error(`"${id}" is ${record.source === 'user' ? 'installed for every deck; remove it with mdeck themes remove --global' : 'built into mdeck and cannot be removed'}`), { status: 403 })
|
|
176
|
+
rmSync(record.dir, { recursive: true, force: true })
|
|
177
|
+
return { kind, id }
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function editorMiddleware(deckFile, { registryFor, extensions = createExtensionStore(dirname(deckFile.path), { registryFor }) }) {
|
|
183
|
+
const extensionsDir = resolve(dirname(deckFile.path), 'extensions')
|
|
184
|
+
return async (request, response, next) => {
|
|
185
|
+
const pathname = new URL(request.url, 'http://localhost').pathname
|
|
186
|
+
if (!isAllowedRequest(request)) return send(response, 403, { error: 'Requests are only accepted from this editor on this computer' })
|
|
187
|
+
try {
|
|
188
|
+
const extension = pathname.match(/^\/extension\/([a-z]+)\/([^/]+)$/)
|
|
189
|
+
if (extension) {
|
|
190
|
+
const [, kind, id] = extension
|
|
191
|
+
if (request.method === 'GET') return send(response, 200, extensions.read(kind, id))
|
|
192
|
+
if (request.method === 'PUT') {
|
|
193
|
+
const body = await readJson(request)
|
|
194
|
+
const result = extensions.write(kind, id, body?.files)
|
|
195
|
+
return send(response, 200, { ...result, registry: serializeRegistry(registryFor()) })
|
|
196
|
+
}
|
|
197
|
+
if (request.method === 'DELETE') {
|
|
198
|
+
const result = extensions.remove(kind, id)
|
|
199
|
+
return send(response, 200, { ...result, registry: serializeRegistry(registryFor()) })
|
|
200
|
+
}
|
|
201
|
+
response.setHeader('Allow', 'GET, PUT, DELETE')
|
|
202
|
+
return send(response, 405, { error: 'Method not allowed' })
|
|
203
|
+
}
|
|
204
|
+
// The theme repository: what it offers, and installing a pack into the
|
|
205
|
+
// deck's extensions folder.
|
|
206
|
+
if (pathname === '/themes/index' && request.method === 'GET') {
|
|
207
|
+
const index = await fetchIndex()
|
|
208
|
+
return send(response, 200, { ...index, installed: installedPacks(extensionsDir) })
|
|
209
|
+
}
|
|
210
|
+
if (pathname === '/themes/install' && request.method === 'POST') {
|
|
211
|
+
const body = await readJson(request)
|
|
212
|
+
const index = await fetchIndex()
|
|
213
|
+
const registry = builtInRegistry()
|
|
214
|
+
const result = await installFromIndex(index, findPack(index, String(body?.id ?? '')), { root: extensionsDir, known: { themes: registry.themes, palettes: registry.palettes }, mdeckVersion: VERSION })
|
|
215
|
+
return send(response, 200, { ...result, registry: serializeRegistry(registryFor()) })
|
|
216
|
+
}
|
|
217
|
+
if (pathname === '/deck' && request.method === 'GET') {
|
|
218
|
+
const registry = registryFor()
|
|
219
|
+
const where = deckFile.designOnly ? { path: dirname(deckFile.path), name: basename(dirname(deckFile.path)), designOnly: true } : { path: deckFile.path, name: basename(deckFile.path) }
|
|
220
|
+
return send(response, 200, { ...where, ...deckFile.read(), registry: serializeRegistry(registry), warnings: registry.warnings })
|
|
221
|
+
}
|
|
222
|
+
if (pathname === '/source' && request.method === 'POST' && deckFile.designOnly) return send(response, 403, { error: 'There is no deck to save: the design page was started without one' })
|
|
223
|
+
if (pathname === '/source' && request.method === 'POST') {
|
|
224
|
+
const body = await readJson(request)
|
|
225
|
+
if (typeof body?.source !== 'string' || typeof body?.base !== 'string') return send(response, 400, { error: 'Expected { source: string, base: string }' })
|
|
226
|
+
const result = deckFile.write(body.source, body.base)
|
|
227
|
+
if (result.ok) return send(response, 200, { hash: result.hash })
|
|
228
|
+
return send(response, 409, { error: 'conflict', source: result.source, hash: result.hash })
|
|
229
|
+
}
|
|
230
|
+
if (['/deck', '/source'].includes(pathname)) { response.setHeader('Allow', pathname === '/deck' ? 'GET' : 'POST'); return send(response, 405, { error: 'Method not allowed' }) }
|
|
231
|
+
return next()
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (error instanceof PackError) return send(response, 400, { error: error.message })
|
|
234
|
+
if (error instanceof ManifestError) return send(response, 500, { error: error.message })
|
|
235
|
+
return send(response, error.status ?? 500, { error: error.message })
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// A deck that is only text and is never written: what the design page shows
|
|
241
|
+
// when `mdeck design` starts it without a deck. `abs` places it in the folder
|
|
242
|
+
// whose extensions are edited.
|
|
243
|
+
export function createSampleDeck(abs, source) {
|
|
244
|
+
return { path: abs, designOnly: true, read: () => ({ source, hash: hashSource(source) }), write: () => ({ ok: false, conflict: true, source, hash: hashSource(source) }), isExternalChange: () => false, backupFile: null }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// `source` is given for `mdeck design` without a deck: the editing API then
|
|
248
|
+
// serves it read-only and only extensions are written.
|
|
249
|
+
export function editorPlugin(slidesPath, { source = null } = {}) {
|
|
250
|
+
const abs = resolve(slidesPath)
|
|
251
|
+
const deckFile = source === null ? createDeckFile(abs) : createSampleDeck(abs, source)
|
|
252
|
+
return {
|
|
253
|
+
name: 'vite-plugin-mdeck-editor',
|
|
254
|
+
configureServer(server) {
|
|
255
|
+
server.middlewares.use('/__mdeck', editorMiddleware(deckFile, { registryFor: () => loadRegistry(abs) }))
|
|
256
|
+
if (source !== null) return
|
|
257
|
+
server.watcher.add(abs)
|
|
258
|
+
let timer
|
|
259
|
+
const changed = file => {
|
|
260
|
+
if (resolve(file) !== abs) return
|
|
261
|
+
clearTimeout(timer)
|
|
262
|
+
timer = setTimeout(() => {
|
|
263
|
+
let content
|
|
264
|
+
try { content = readFileSync(abs, 'utf8') } catch { return }
|
|
265
|
+
if (deckFile.isExternalChange(content)) server.ws.send('mdeck:deck-changed', { hash: hashSource(content) })
|
|
266
|
+
}, 75)
|
|
267
|
+
}
|
|
268
|
+
server.watcher.on('change', changed)
|
|
269
|
+
server.watcher.on('add', changed)
|
|
270
|
+
},
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The `mdeck edit` server, also started on demand from the launch page. It
|
|
2
|
+
// listens on loopback only and never reloads pages when the deck changes.
|
|
3
|
+
// `open` is true for the editor, or the path of another page to open.
|
|
4
|
+
import { createServer } from 'vite'
|
|
5
|
+
import preact from '@preact/preset-vite'
|
|
6
|
+
import { dirname, resolve } from 'node:path'
|
|
7
|
+
import { baseConfig } from './config.js'
|
|
8
|
+
import { slidesPlugin } from './slidesPlugin.js'
|
|
9
|
+
import { editorPlugin, BACKUP_DIR } from './editorPlugin.js'
|
|
10
|
+
import { sampleDeck, sampleDataUrl } from '../editor/sampleDeck.js'
|
|
11
|
+
|
|
12
|
+
export function createEditorServer(slidesPath, { port = 5173, strictPort = false, open = false } = {}) {
|
|
13
|
+
const abs = resolve(slidesPath)
|
|
14
|
+
const base = baseConfig(abs)
|
|
15
|
+
return createServer({
|
|
16
|
+
...base,
|
|
17
|
+
plugins: [preact(), slidesPlugin(abs, { editor: true }), editorPlugin(abs)],
|
|
18
|
+
publicDir: dirname(abs),
|
|
19
|
+
server: { ...base.server, host: '127.0.0.1', cors: false, port, strictPort, open: open === true ? '/editor.html' : open || false, watch: { ignored: [`**/${BACKUP_DIR}/**`] } },
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// `mdeck design` without a deck: the design page on the sample deck, making
|
|
24
|
+
// and changing the extensions in `folder`/extensions, where a deck in that
|
|
25
|
+
// folder finds them. No deck file is read or written.
|
|
26
|
+
export function createDesignServer(folder, { port = 5173, strictPort = false, open = false } = {}) {
|
|
27
|
+
const abs = resolve(folder, 'design.md')
|
|
28
|
+
const source = sampleDeck(sampleDataUrl())
|
|
29
|
+
const base = baseConfig(abs, { source })
|
|
30
|
+
return createServer({
|
|
31
|
+
...base,
|
|
32
|
+
plugins: [preact(), slidesPlugin(abs, { editor: true, source, ink: false }), editorPlugin(abs, { source })],
|
|
33
|
+
publicDir: false,
|
|
34
|
+
server: { ...base.server, host: '127.0.0.1', cors: false, port, strictPort, open: open === true ? '/design.html' : open || false },
|
|
35
|
+
})
|
|
36
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// A theme's web fonts, embedded in a single-file build (mdeck send, --single-file,
|
|
2
|
+
// and the page mdeck pdf prints), so a file opened offline and its PDF look
|
|
3
|
+
// like the slides on the presenter's screen instead of falling back to system
|
|
4
|
+
// fonts.
|
|
5
|
+
//
|
|
6
|
+
// The theme's font stylesheets (Google Fonts and the like) are fetched once,
|
|
7
|
+
// limited to the Latin character sets, and every font file becomes a data URI.
|
|
8
|
+
// Fetched files are kept in a cache, so later builds need no network. Without
|
|
9
|
+
// network and cache the build goes on with the fallback fonts and says so.
|
|
10
|
+
import { createHash } from 'node:crypto'
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
// A current browser gets WOFF2, the smallest format.
|
|
16
|
+
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36'
|
|
17
|
+
// The character sets slides in Western languages need; Google Fonts names
|
|
18
|
+
// each block of a stylesheet after its set.
|
|
19
|
+
const SUBSETS = new Set(['latin', 'latin-ext'])
|
|
20
|
+
const FONT_TYPES = { woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf' }
|
|
21
|
+
|
|
22
|
+
export const fontCacheDir = () => process.env.MDECK_CACHE_DIR
|
|
23
|
+
? join(process.env.MDECK_CACHE_DIR, 'fonts')
|
|
24
|
+
: join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'mdeck', 'fonts')
|
|
25
|
+
|
|
26
|
+
const key = text => createHash('sha256').update(text).digest('hex').slice(0, 32)
|
|
27
|
+
|
|
28
|
+
async function cached(url, { fetch: get, cacheDir, binary, timeoutMs }) {
|
|
29
|
+
const file = join(cacheDir, key(url) + (binary ? '.bin' : '.css'))
|
|
30
|
+
if (existsSync(file)) return binary ? readFileSync(file) : readFileSync(file, 'utf8')
|
|
31
|
+
const response = await get(url, { headers: { 'User-Agent': USER_AGENT }, signal: AbortSignal.timeout(timeoutMs) })
|
|
32
|
+
if (!response.ok) throw new Error(`${url} answered ${response.status}`)
|
|
33
|
+
const body = binary ? Buffer.from(await response.arrayBuffer()) : await response.text()
|
|
34
|
+
mkdirSync(cacheDir, { recursive: true })
|
|
35
|
+
writeFileSync(file, body)
|
|
36
|
+
return body
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The @font-face blocks of a stylesheet, without the character sets that
|
|
40
|
+
// slides rarely need (Cyrillic, Greek, Vietnamese …). A variable font is
|
|
41
|
+
// listed once per weight with the same file; those become one face with a
|
|
42
|
+
// weight range, so the file is embedded once.
|
|
43
|
+
export function latinFaces(css) {
|
|
44
|
+
const blocks = [...css.matchAll(/(?:\/\*\s*([\w-]+)\s*\*\/\s*)?(@font-face\s*\{[^}]*\})/g)]
|
|
45
|
+
const faces = blocks.filter(([, subset]) => !subset || SUBSETS.has(subset)).map(([, , face]) => face)
|
|
46
|
+
const groups = new Map()
|
|
47
|
+
for (const face of faces) {
|
|
48
|
+
const src = face.match(/src:\s*([^;]+);/)?.[1]
|
|
49
|
+
const style = face.match(/font-style:\s*([^;]+);/)?.[1] ?? 'normal'
|
|
50
|
+
const weights = (face.match(/font-weight:\s*([^;]+);/)?.[1] ?? '400').trim().split(/\s+/).map(Number)
|
|
51
|
+
const id = `${src}|${style}`
|
|
52
|
+
if (!src || weights.some(Number.isNaN)) { groups.set(face, { face }); continue }
|
|
53
|
+
const group = groups.get(id)
|
|
54
|
+
if (!group) groups.set(id, { face, min: Math.min(...weights), max: Math.max(...weights) })
|
|
55
|
+
else { group.min = Math.min(group.min, ...weights); group.max = Math.max(group.max, ...weights) }
|
|
56
|
+
}
|
|
57
|
+
return [...groups.values()].map(({ face, min, max }) => min == null ? face
|
|
58
|
+
: face.replace(/font-weight:\s*[^;]+;/, `font-weight: ${min === max ? min : `${min} ${max}`};`))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* CSS with the fonts of these stylesheet URLs embedded, and warnings for
|
|
63
|
+
* whatever could not be fetched.
|
|
64
|
+
*/
|
|
65
|
+
export async function embedFonts(urls = [], { fetch: get = fetch, cacheDir = fontCacheDir(), timeoutMs = 15000 } = {}) {
|
|
66
|
+
const faces = [], warnings = []
|
|
67
|
+
for (const url of urls) {
|
|
68
|
+
try {
|
|
69
|
+
const css = await cached(url, { fetch: get, cacheDir, binary: false, timeoutMs })
|
|
70
|
+
for (const face of latinFaces(css)) {
|
|
71
|
+
let embedded = face
|
|
72
|
+
for (const [match, fontUrl] of face.matchAll(/url\((?:'|")?([^'")]+)(?:'|")?\)/g)) {
|
|
73
|
+
const type = FONT_TYPES[fontUrl.split(/[?#]/)[0].split('.').pop()] ?? 'font/woff2'
|
|
74
|
+
const data = await cached(new URL(fontUrl, url).href, { fetch: get, cacheDir, binary: true, timeoutMs })
|
|
75
|
+
embedded = embedded.replace(match, `url(data:${type};base64,${data.toString('base64')})`)
|
|
76
|
+
}
|
|
77
|
+
faces.push(embedded)
|
|
78
|
+
}
|
|
79
|
+
} catch (error) {
|
|
80
|
+
warnings.push(`The font ${url} could not be embedded (${error.cause?.code ?? error.message}); the file uses the fallback fonts.`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { css: faces.join('\n'), warnings }
|
|
84
|
+
}
|