mdeck 2.3.0 → 3.1.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/README.md +9 -9
  3. package/assets/base.css +4 -1
  4. package/assets/extensions/palettes/lagoon/extension.toml +3 -0
  5. package/assets/extensions/palettes/neon/extension.toml +3 -0
  6. package/assets/extensions/palettes/nordic/extension.toml +3 -0
  7. package/assets/extensions/palettes/paper/extension.toml +30 -0
  8. package/assets/extensions/palettes/swiss/extension.toml +3 -0
  9. package/assets/extensions/themes/academic/extension.toml +3 -0
  10. package/assets/extensions/themes/aurora/extension.toml +3 -0
  11. package/assets/extensions/themes/minimal/extension.toml +48 -0
  12. package/assets/extensions/themes/minimal/styles.css +342 -0
  13. package/assets/extensions/themes/neue/extension.toml +3 -0
  14. package/docs/reference/authoring.md +13 -18
  15. package/docs/reference/claude-skill.md +1 -1
  16. package/docs/reference/extensions.md +40 -26
  17. package/docs/reference/palettes.md +8 -12
  18. package/docs/reference/themes.md +8 -7
  19. package/docs/site/content/appearance.md +35 -49
  20. package/docs/site/content/commands.md +3 -2
  21. package/docs/site/content/theme-authoring.md +1 -1
  22. package/docs/site/content/theme-repository.md +44 -32
  23. package/docs/site/examples/first-talk.md +1 -1
  24. package/docs/site/images/themes/academic-dark.webp +0 -0
  25. package/docs/site/images/themes/academic-light.webp +0 -0
  26. package/docs/site/images/themes/academic.webp +0 -0
  27. package/docs/site/images/themes/aurora-dark.webp +0 -0
  28. package/docs/site/images/themes/aurora-light.webp +0 -0
  29. package/docs/site/images/themes/aurora.webp +0 -0
  30. package/docs/site/images/themes/minimal-dark.webp +0 -0
  31. package/docs/site/images/themes/minimal-light.webp +0 -0
  32. package/docs/site/images/themes/minimal.webp +0 -0
  33. package/docs/site/images/themes/neue-dark.webp +0 -0
  34. package/docs/site/images/themes/neue-light.webp +0 -0
  35. package/docs/site/images/themes/neue.webp +0 -0
  36. package/docs/site/pages.js +1 -1
  37. package/examples/custom-layouts/slides.md +1 -1
  38. package/examples/poll/slides.md +1 -1
  39. package/examples/python/slides.md +2 -2
  40. package/examples/showcase/slides.md +3 -3
  41. package/package.json +2 -2
  42. package/skills/write-slides/SKILL.md +2 -2
  43. package/src/build/check.js +0 -7
  44. package/src/build/editorPlugin.js +26 -13
  45. package/src/build/themeCheck.js +17 -6
  46. package/src/cli/args.js +2 -1
  47. package/src/cli/index.js +7 -6
  48. package/src/cli/themes.js +117 -87
  49. package/src/core/validateDeck.js +4 -2
  50. package/src/editor/api.js +15 -6
  51. package/src/editor/components/App.jsx +4 -2
  52. package/src/editor/components/DesignApp.jsx +16 -9
  53. package/src/editor/components/ExtensionEditor.jsx +16 -6
  54. package/src/editor/components/ExtensionsPanel.jsx +28 -34
  55. package/src/editor/designLink.js +18 -1
  56. package/src/editor/sampleDeck.js +3 -1
  57. package/src/extensions/appearance.js +2 -1
  58. package/src/extensions/discover.js +44 -11
  59. package/src/extensions/installed.js +88 -0
  60. package/src/extensions/manifest.js +20 -3
  61. package/src/extensions/moved.js +20 -0
  62. package/src/extensions/packages.js +318 -0
  63. package/src/home/Home.jsx +12 -6
  64. package/src/layouts/layoutApi.jsx +10 -2
  65. package/src/runtime/themeLoader.js +5 -2
  66. package/assets/extensions/palettes/brand/extension.toml +0 -29
  67. package/assets/extensions/palettes/cobalt/extension.toml +0 -27
  68. package/assets/extensions/palettes/ember/extension.toml +0 -27
  69. package/assets/extensions/palettes/forest/extension.toml +0 -27
  70. package/assets/extensions/palettes/graphite/extension.toml +0 -27
  71. package/assets/extensions/palettes/pastel/extension.toml +0 -30
  72. package/assets/extensions/palettes/phosphor/extension.toml +0 -27
  73. package/assets/extensions/palettes/terra/extension.toml +0 -27
  74. package/assets/extensions/themes/duet/extension.toml +0 -46
  75. package/assets/extensions/themes/duet/styles.css +0 -573
  76. package/assets/extensions/themes/editorial/extension.toml +0 -46
  77. package/assets/extensions/themes/editorial/styles.css +0 -574
  78. package/assets/extensions/themes/fhnw/extension.toml +0 -61
  79. package/assets/extensions/themes/fhnw/styles.css +0 -604
  80. package/assets/extensions/themes/sketch/extension.toml +0 -48
  81. package/assets/extensions/themes/sketch/styles.css +0 -633
  82. package/assets/extensions/themes/terminal/extension.toml +0 -38
  83. package/assets/extensions/themes/terminal/styles.css +0 -550
  84. package/docs/site/images/themes/duet-dark.webp +0 -0
  85. package/docs/site/images/themes/duet-light.webp +0 -0
  86. package/docs/site/images/themes/duet.webp +0 -0
  87. package/docs/site/images/themes/editorial-dark.webp +0 -0
  88. package/docs/site/images/themes/editorial-light.webp +0 -0
  89. package/docs/site/images/themes/editorial.webp +0 -0
  90. package/docs/site/images/themes/fhnw.webp +0 -0
  91. package/docs/site/images/themes/sketch-dark.webp +0 -0
  92. package/docs/site/images/themes/sketch-light.webp +0 -0
  93. package/docs/site/images/themes/sketch.webp +0 -0
  94. package/docs/site/images/themes/terminal-dark.webp +0 -0
  95. package/docs/site/images/themes/terminal-light.webp +0 -0
  96. package/docs/site/images/themes/terminal.webp +0 -0
  97. package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T08-36-47Z.json +0 -19
  98. package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T11-27-30Z.json +0 -6
  99. package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T08-08-30Z.json +0 -10
  100. package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T11-41-35Z.json +0 -13
  101. package/src/extensions/packs.js +0 -268
@@ -0,0 +1,318 @@
1
+ // Packages: every theme and every palette can be installed on its own, from
2
+ // mdeck itself (the built-in ones) or from the theme repository
3
+ // (https://gh.tschieber.de/mdeck-themes/). A package is one extension folder;
4
+ // it travels as one JSON file of its text files, so installing it is checking
5
+ // it and writing those files. Only extension.toml and, for a theme,
6
+ // styles.css are allowed: a package never carries code.
7
+ // What a package needs follows from its manifest: a theme needs its default
8
+ // palette, and a palette that belongs to one theme needs that theme. Those
9
+ // are installed along with it.
10
+ import { createHash } from 'node:crypto'
11
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
12
+ import { resolve, relative, basename } from 'node:path'
13
+ import { ID_RE, ManifestError, RANGE_RE, parseManifestText, validateManifest } from './manifest.js'
14
+ import { builtinExtensionsRoot } from '../paths.js'
15
+
16
+ export const CATALOGUE_URL = 'https://gh.tschieber.de/mdeck-themes/catalogue.json'
17
+ export const MARKER = '.mdeck-package.json'
18
+ const PACKAGE_KINDS = ['theme', 'palette']
19
+ const FILES = { theme: ['extension.toml', 'styles.css'], palette: ['extension.toml'] }
20
+ // Where theme fonts may come from: stylesheet services that only serve fonts.
21
+ export const FONT_HOSTS = ['https://fonts.googleapis.com/', 'https://fonts.bunny.net/']
22
+
23
+ export class PackageError extends Error {}
24
+ const fail = message => { throw new PackageError(message) }
25
+
26
+ export const sha256 = text => createHash('sha256').update(text, 'utf8').digest('hex')
27
+ export const keyOf = (kind, id) => `${kind}:${id}`
28
+
29
+ export function catalogueUrl() {
30
+ return process.env.MDECK_THEMES_URL || CATALOGUE_URL
31
+ }
32
+
33
+ const parts = version => version.split('.').map(Number)
34
+ export function compareVersions(a, b) {
35
+ const [x, y] = [parts(a), parts(b)]
36
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i]
37
+ return 0
38
+ }
39
+
40
+ // Ranges are only of the form ">=1.2.3"; no range means any version.
41
+ export function satisfies(range, version) {
42
+ if (!range) return true
43
+ const match = RANGE_RE.exec(range)
44
+ return Boolean(match) && compareVersions(version, match[1]) >= 0
45
+ }
46
+
47
+ function checkCss(text, where) {
48
+ if (/@import/i.test(text)) fail(`${where}: @import is not allowed; list font stylesheets under fonts in extension.toml`)
49
+ for (const match of text.matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi)) {
50
+ if (!match[2].trim().startsWith('data:')) fail(`${where}: url(${match[2]}) is not allowed; only data: URLs`)
51
+ }
52
+ }
53
+
54
+ // Checks one package's files: what it may hold, its manifest, its fonts and
55
+ // stylesheet. `published` also requires version, author and license, as the
56
+ // theme repository does. Returns the validated record.
57
+ export function checkPackage({ kind, id, files }, { published = false } = {}) {
58
+ if (!PACKAGE_KINDS.includes(kind)) fail(`${kind} is not a kind of package; only themes and palettes are`)
59
+ if (typeof id !== 'string' || !ID_RE.test(id)) fail(`"${id}" is not an id: lowercase letters, digits and hyphens, starting with a letter`)
60
+ if (!files || typeof files !== 'object') fail(`The ${kind} ${id} has no files`)
61
+ for (const [name, text] of Object.entries(files)) {
62
+ if (!FILES[kind].includes(name)) fail(`The ${kind} ${id}: "${name}" is not allowed; a ${kind} holds only ${FILES[kind].join(' and ')}`)
63
+ if (typeof text !== 'string') fail(`The ${kind} ${id}: ${name} is not text`)
64
+ }
65
+ if (!files['extension.toml']) fail(`The ${kind} ${id}: extension.toml is missing`)
66
+ const dir = `/package/${id}`
67
+ const file = `${dir}/extension.toml`
68
+ let record
69
+ try {
70
+ record = validateManifest(parseManifestText(files['extension.toml'], file), { file, dir, folderName: id, fileExists: path => Object.hasOwn(files, relative(dir, path)) })
71
+ } catch (error) {
72
+ if (error instanceof ManifestError) fail(`The ${kind} ${id}: ${error.message.replace(`${file}: `, '')}`)
73
+ throw error
74
+ }
75
+ if (record.kind !== kind) fail(`${id}/extension.toml says kind = "${record.kind}", but it is in the ${kind}s`)
76
+ if (published) for (const key of ['version', 'author', 'license']) if (!record.package?.[key]) fail(`The ${kind} ${id}: ${key} is required in the theme repository`)
77
+ if (kind === 'theme') {
78
+ for (const url of record.manifest.fonts) if (!FONT_HOSTS.some(host => url.startsWith(host))) fail(`The theme ${id} loads fonts from ${url}; allowed are ${FONT_HOSTS.join(', ')}`)
79
+ checkCss(files['styles.css'] ?? '', `The theme ${id}: styles.css`)
80
+ }
81
+ return record
82
+ }
83
+
84
+ // What a package needs installed with it: a theme its default palette, a
85
+ // palette of one theme that theme.
86
+ export function dependencies(kind, manifest) {
87
+ if (kind === 'theme') return [{ kind: 'palette', id: manifest.palette }]
88
+ return manifest.theme ? [{ kind: 'theme', id: manifest.theme }] : []
89
+ }
90
+
91
+ // A package folder's files and record; `kind` comes from the manifest.
92
+ export function readPackageDir(dir, { published = false } = {}) {
93
+ const id = basename(dir)
94
+ const files = {}
95
+ for (const name of readdirSync(dir).sort()) {
96
+ if (name.startsWith('.') || name === 'README.md') continue
97
+ if (!statSync(resolve(dir, name)).isFile()) fail(`${resolve(dir, name)}: a package holds only files`)
98
+ files[name] = readFileSync(resolve(dir, name), 'utf8')
99
+ }
100
+ const kind = /^kind\s*=\s*"([a-z]+)"/m.exec(files['extension.toml'] ?? '')?.[1]
101
+ const record = checkPackage({ kind, id, files }, { published })
102
+ return { kind, id, files, record }
103
+ }
104
+
105
+ // The published form of a package folder: one JSON text and its checksum.
106
+ export function bundlePackage(dir, options) {
107
+ const { kind, id, files, record } = readPackageDir(dir, options)
108
+ const bundle = { schema: 2, kind, id, version: record.package?.version ?? '0.0.0', ...(record.package?.mdeck ? { mdeck: record.package.mdeck } : {}), files }
109
+ const text = JSON.stringify(bundle, null, 1) + '\n'
110
+ return { bundle, text, sha256: sha256(text), record }
111
+ }
112
+
113
+ // ─── Installed packages ───────────────────────────────────────────────────
114
+
115
+ const readJson = file => { try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return null } }
116
+ const packageDir = (root, kind, id) => resolve(root, `${kind}s`, id)
117
+
118
+ // Files changed since the package was installed, by name.
119
+ function changedFiles(dir, marker) {
120
+ return Object.entries(marker.files ?? {}).filter(([name, hash]) => {
121
+ const file = resolve(dir, name)
122
+ return !existsSync(file) || sha256(readFileSync(file, 'utf8')) !== hash
123
+ }).map(([name]) => name)
124
+ }
125
+
126
+ const refuseChanged = (dir, marker) => {
127
+ const changed = changedFiles(dir, marker)
128
+ if (changed.length) fail(`${dir} was changed since it was installed (${changed.join(', ')}); add --force to replace or remove it anyway`)
129
+ }
130
+
131
+ // The packages installed in an extensions folder (in its themes/ and
132
+ // palettes/), by "kind:id".
133
+ export function installedPackages(root) {
134
+ const found = {}
135
+ for (const kind of PACKAGE_KINDS) {
136
+ const dir = resolve(root, `${kind}s`)
137
+ if (!existsSync(dir)) continue
138
+ for (const id of readdirSync(dir).sort()) {
139
+ const marker = readJson(resolve(dir, id, MARKER))
140
+ if (marker?.kind === kind) found[keyOf(kind, id)] = { kind, id, version: marker.version ?? null, url: marker.url ?? null, dir: resolve(dir, id), marker }
141
+ }
142
+ }
143
+ return found
144
+ }
145
+
146
+ // Writes a checked package into an extensions folder (a deck's, or the
147
+ // user's), as <kind>s/<id>. A folder of the deck's own is never touched; one
148
+ // installed before is replaced unless it was changed by hand.
149
+ export function installPackage(bundle, { root, url = null, digest = null, force = false } = {}) {
150
+ const { kind, id } = bundle
151
+ const record = checkPackage(bundle)
152
+ const dir = packageDir(root, kind, id)
153
+ const before = installedPackages(root)[keyOf(kind, id)]
154
+ if (existsSync(dir) && !readJson(resolve(dir, MARKER))) fail(`${dir} already exists and was not installed by mdeck; rename or remove it first`)
155
+ if (before && !force) refuseChanged(before.dir, before.marker)
156
+ // Files are overwritten in place, not deleted and written again, so a
157
+ // running editor sees a change instead of a file that disappears.
158
+ mkdirSync(dir, { recursive: true })
159
+ const hashes = {}
160
+ for (const [name, content] of Object.entries(bundle.files)) {
161
+ writeFileSync(resolve(dir, name), content, 'utf8')
162
+ hashes[name] = sha256(content)
163
+ }
164
+ for (const name of readdirSync(dir)) if (name !== MARKER && !Object.hasOwn(hashes, name)) rmSync(resolve(dir, name), { recursive: true, force: true })
165
+ writeFileSync(resolve(dir, MARKER), JSON.stringify({ kind, id, version: bundle.version, url, sha256: digest, files: hashes }, null, 2) + '\n')
166
+ return { record, dir, replaced: before?.version ?? null }
167
+ }
168
+
169
+ export function removePackage(kind, id, { root, force = false } = {}) {
170
+ const installed = installedPackages(root)[keyOf(kind, id)]
171
+ if (!installed) fail(`The ${kind} ${id} is not installed in ${root}`)
172
+ if (!force) refuseChanged(installed.dir, installed.marker)
173
+ rmSync(installed.dir, { recursive: true, force: true })
174
+ return installed
175
+ }
176
+
177
+ // ─── The catalogue: built-in packages and the repository ──────────────────
178
+
179
+ async function get(url, as) {
180
+ let response
181
+ try { response = await fetch(url) } catch (error) { fail(`Could not reach ${url}: ${error.cause?.message ?? error.message}`) }
182
+ if (!response.ok) fail(`${url} answered ${response.status}`)
183
+ return as === 'json' ? response.json() : response.text()
184
+ }
185
+
186
+ export async function fetchCatalogue(url = catalogueUrl()) {
187
+ const data = await get(url, 'json')
188
+ if (data?.schema !== 2 || !Array.isArray(data.themes) || !Array.isArray(data.palettes)) fail(`${url} is not a theme repository catalogue`)
189
+ return data
190
+ }
191
+
192
+ // One entry of the catalogue, from a package's record.
193
+ function entryOf(kind, record) {
194
+ return {
195
+ kind, id: record.id, title: record.title, description: record.description ?? '',
196
+ version: record.package?.version ?? '0.0.0', ...(record.package?.author ? { author: record.package.author } : {}), ...(record.package?.license ? { license: record.package.license } : {}),
197
+ ...(record.package?.homepage ? { homepage: record.package.homepage } : {}), ...(record.package?.mdeck ? { mdeck: record.package.mdeck } : {}),
198
+ ...(kind === 'theme' ? { palette: record.manifest.palette, ...(record.manifest.palettes?.length ? { palettes: record.manifest.palettes } : {}), appearance: record.manifest.appearance ?? 'light' } : {}),
199
+ ...(kind === 'palette' && record.manifest.theme ? { theme: record.manifest.theme } : {}),
200
+ }
201
+ }
202
+
203
+ // The themes and palettes that come with mdeck, as catalogue entries.
204
+ export function builtInEntries(root = builtinExtensionsRoot) {
205
+ const entries = []
206
+ for (const kind of PACKAGE_KINDS) {
207
+ const dir = resolve(root, `${kind}s`)
208
+ if (!existsSync(dir)) continue
209
+ for (const id of readdirSync(dir).sort()) {
210
+ if (id.startsWith('.')) continue
211
+ const { record } = readPackageDir(resolve(dir, id))
212
+ entries.push({ ...entryOf(kind, record), builtIn: true, dir: resolve(dir, id) })
213
+ }
214
+ }
215
+ return entries
216
+ }
217
+
218
+ // Every theme and palette mdeck can install: the built-in ones, and the
219
+ // repository's when it answers. Of two versions the newer is offered; the
220
+ // built-in one on a tie, since it needs no download. `offline` says why the
221
+ // repository is missing.
222
+ export async function loadCatalogue({ url = catalogueUrl(), online = true } = {}) {
223
+ const entries = new Map(builtInEntries().map(entry => [keyOf(entry.kind, entry.id), entry]))
224
+ let offline = null
225
+ let remote = { themes: [], palettes: [] }
226
+ if (online) { try { remote = await fetchCatalogue(url) } catch (error) { offline = error.message } }
227
+ for (const entry of [...remote.themes.map(e => ({ ...e, kind: 'theme' })), ...remote.palettes.map(e => ({ ...e, kind: 'palette' }))]) {
228
+ const key = keyOf(entry.kind, entry.id)
229
+ const have = entries.get(key)
230
+ if (entry.builtIn) { if (have) have.preview = entry.preview; continue }
231
+ if (!have || compareVersions(entry.version, have.version) > 0) entries.set(key, { ...entry, builtIn: false, builtInVersion: have?.version })
232
+ }
233
+ const sorted = [...entries.values()].sort((a, b) => a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id, 'en'))
234
+ return { url, offline, entries: sorted, get: (kind, id) => entries.get(keyOf(kind, id)) }
235
+ }
236
+
237
+ export function findEntry(catalogue, kind, id) {
238
+ return catalogue.get(kind, id) ?? fail(`There is no ${kind} "${id}"${catalogue.offline ? ` among those that come with mdeck, and the theme repository could not be reached (${catalogue.offline})` : `; mdeck ${kind}s search lists them`}`)
239
+ }
240
+
241
+ // A package's files: read from mdeck's folder, or downloaded and checked
242
+ // against the repository's checksum.
243
+ export async function obtainPackage(catalogue, entry) {
244
+ if (entry.builtIn) { const { bundle, sha256: digest } = bundlePackage(entry.dir); return { bundle, url: null, digest } }
245
+ const url = new URL(entry.url, catalogue.url).href
246
+ const text = await get(url, 'text')
247
+ if (sha256(text) !== entry.sha256) fail(`${url} does not match its checksum in the catalogue; not installed`)
248
+ let bundle
249
+ try { bundle = JSON.parse(text) } catch { fail(`${url} is not a package`) }
250
+ if (bundle.kind !== entry.kind || bundle.id !== entry.id || bundle.version !== entry.version) fail(`${url} holds the ${bundle.kind} ${bundle.id} ${bundle.version}, not the ${entry.kind} ${entry.id} ${entry.version}`)
251
+ return { bundle, url, digest: entry.sha256 }
252
+ }
253
+
254
+ // A package and what it needs, in the order to install them: what is needed
255
+ // first, the package itself last. A theme and its own palette need each
256
+ // other; each is installed once.
257
+ export function withDependencies(catalogue, kind, id, seen = new Set()) {
258
+ const key = keyOf(kind, id)
259
+ if (seen.has(key)) return []
260
+ seen.add(key)
261
+ const entry = findEntry(catalogue, kind, id)
262
+ const needs = kind === 'theme' ? [{ kind: 'palette', id: entry.palette }] : entry.theme ? [{ kind: 'theme', id: entry.theme }] : []
263
+ const order = []
264
+ for (const need of needs) for (const item of withDependencies(catalogue, need.kind, need.id, seen)) if (!order.includes(item)) order.push(item)
265
+ return [...order, entry]
266
+ }
267
+
268
+ // ─── The repository itself ────────────────────────────────────────────────
269
+
270
+ // The theme repository as served: catalogue.json, and <kind>s/<id>-<version>.json
271
+ // for each package, from a folder with themes/ and palettes/. mdeck's
272
+ // built-in packages are listed too (marked builtIn), so the repository's
273
+ // themes may use built-in palettes. Every package is checked, no id appears
274
+ // twice in a kind, and what each needs is in the catalogue. Previews are added
275
+ // by the caller.
276
+ export function buildRepository(repoDir, outDir) {
277
+ const builtIn = builtInEntries()
278
+ const records = {}
279
+ const add = (kind, id, value) => {
280
+ if (records[keyOf(kind, id)]) fail(`There are two ${kind}s "${id}"${value.builtIn || records[keyOf(kind, id)].builtIn ? '; one comes with mdeck, change it there' : ''}`)
281
+ records[keyOf(kind, id)] = value
282
+ }
283
+ for (const entry of builtIn) add(entry.kind, entry.id, { entry, builtIn: true, ...bundlePackage(entry.dir) })
284
+ for (const kind of PACKAGE_KINDS) {
285
+ const dir = resolve(repoDir, `${kind}s`)
286
+ if (!existsSync(dir)) continue
287
+ for (const id of readdirSync(dir).sort()) {
288
+ if (id.startsWith('.') || !statSync(resolve(dir, id)).isDirectory()) continue
289
+ const read = bundlePackage(resolve(dir, id), { published: true })
290
+ if (read.bundle.kind !== kind) fail(`${kind}s/${id} holds a ${read.bundle.kind}`)
291
+ add(kind, id, { entry: entryOf(kind, read.record), builtIn: false, dir: resolve(dir, id), ...read })
292
+ }
293
+ }
294
+ // What each needs must be there, and a theme may offer only palettes that
295
+ // are open to it.
296
+ for (const { record, bundle } of Object.values(records)) {
297
+ for (const need of dependencies(bundle.kind, record.manifest)) {
298
+ if (!records[keyOf(need.kind, need.id)]) fail(`The ${bundle.kind} ${bundle.id} needs the ${need.kind} "${need.id}", which is neither in the repository nor built into mdeck`)
299
+ }
300
+ if (bundle.kind === 'theme') for (const id of [record.manifest.palette, ...(record.manifest.palettes ?? [])]) {
301
+ const palette = records[keyOf('palette', id)]
302
+ if (!palette) fail(`The theme ${bundle.id} offers the palette "${id}", which is neither in the repository nor built into mdeck`)
303
+ const owner = palette.record.manifest.theme
304
+ if (owner && owner !== bundle.id) fail(`The theme ${bundle.id} offers the palette ${id}, which belongs to the theme ${owner}`)
305
+ }
306
+ }
307
+ const catalogue = { schema: 2, themes: [], palettes: [] }
308
+ for (const key of Object.keys(records).sort()) {
309
+ const { entry, text, sha256: digest, bundle, builtIn: isBuiltIn } = records[key]
310
+ const file = `${bundle.kind}s/${bundle.id}-${bundle.version}.json`
311
+ mkdirSync(resolve(outDir, `${bundle.kind}s`), { recursive: true })
312
+ writeFileSync(resolve(outDir, file), text)
313
+ const { dir, ...listed } = entry
314
+ catalogue[`${bundle.kind}s`].push({ ...listed, ...(isBuiltIn ? { builtIn: true } : {}), url: file, sha256: digest })
315
+ }
316
+ writeFileSync(resolve(outDir, 'catalogue.json'), JSON.stringify(catalogue, null, 2) + '\n')
317
+ return { catalogue, dirs: Object.fromEntries(Object.entries(records).map(([key, value]) => [key, value.dir ?? value.entry.dir])) }
318
+ }
package/src/home/Home.jsx CHANGED
@@ -66,24 +66,29 @@ async function openService(action, page = '') {
66
66
  const tab = window.open('about:blank', '_blank')
67
67
  try {
68
68
  const { url } = await api('/action', { action })
69
- const target = new URL(page, url).href
70
- if (tab) tab.location = target
71
- else window.open(target, '_blank')
69
+ const target = new URL(page, url)
70
+ // The editors link back here.
71
+ if (action === 'editor') target.searchParams.set('home', location.href.split('#')[0])
72
+ if (tab) tab.location = target.href
73
+ else window.open(target.href, '_blank')
72
74
  } catch (error) {
73
75
  tab?.close()
74
76
  throw error
75
77
  }
76
78
  }
77
79
 
78
- function ServiceTile({ icon, title, text, action, url, onError, onStarted }) {
80
+ // `page` opens another page of the same service, such as the editor's
81
+ // design page.
82
+ function ServiceTile({ icon, title, text, action, page = '', url, onError, onStarted }) {
79
83
  const [starting, setStarting] = useState(false)
80
84
  const launch = async () => {
81
85
  setStarting(true)
82
- try { await openService(action); onStarted() } catch (error) { onError(error.message) } finally { setStarting(false) }
86
+ try { await openService(action, page); onStarted() } catch (error) { onError(error.message) } finally { setStarting(false) }
83
87
  }
88
+ const shown = url && new URL(page, url).href
84
89
  return <button class="home-tile" onClick={launch} disabled={starting}>
85
90
  <span class="home-tile-icon"><HomeIcon name={icon} size={22} /></span>
86
- <span class="home-tile-body"><strong>{title}</strong><span>{starting ? 'Starting…' : url ? url.replace(/^https?:\/\//, '') : text}</span></span>
91
+ <span class="home-tile-body"><strong>{title}</strong><span>{starting ? 'Starting…' : shown ? shown.replace(/^https?:\/\//, '') : text}</span></span>
87
92
  <HomeIcon name="external" size={16} />
88
93
  </button>
89
94
  }
@@ -335,6 +340,7 @@ export function Home() {
335
340
  <h2>Write and learn</h2>
336
341
  <div class="home-tiles">
337
342
  <ServiceTile icon="editor" title="Visual editor" text="Forms and a live preview; saves into the file and keeps a backup." action="editor" url={info.services.editor} onError={setError} onStarted={load} />
343
+ <ServiceTile icon="look" title="Theme editor" text="Try and fine-tune themes and palettes on a sample deck; install more." action="editor" page="design.html" url={info.services.editor} onError={setError} onStarted={load} />
338
344
  <ServiceTile icon="guides" title="Guides" text="How to write slides, layouts, presenting, sharing and more." action="docs" url={info.services.docs} onError={setError} onStarted={load} />
339
345
  </div>
340
346
  </section>
@@ -62,8 +62,16 @@ export function extractContent(markdown, { headingLevels = [], paragraph = false
62
62
 
63
63
  // An SVG picture that uses the theme's colour variables is drawn inline, so it
64
64
  // follows the theme, palette and light or dark. The build lists them by the
65
- // path the deck uses.
66
- export const themedSvg = src => themedSvgs[src] ?? themedSvgs[String(src ?? '').split(/[?#]/)[0]]
65
+ // path the deck uses; an SVG data URL carries its drawing itself.
66
+ export const themedSvg = src => themedSvgs[src] ?? themedSvgs[String(src ?? '').split(/[?#]/)[0]] ?? themedDataSvg(src)
67
+
68
+ function themedDataSvg(src) {
69
+ const match = /^data:image\/svg\+xml(;base64)?,(.*)$/s.exec(String(src ?? ''))
70
+ if (!match) return null
71
+ let markup
72
+ try { markup = match[1] ? atob(match[2]) : decodeURIComponent(match[2]) } catch { return null }
73
+ return /var\(--/.test(markup) && /^\s*<svg\b/.test(markup) ? markup : null
74
+ }
67
75
 
68
76
  const attribute = (tag, name) => tag.match(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i'))?.slice(1).find(v => v != null)
69
77
 
@@ -1,6 +1,7 @@
1
1
  import baseCSS from '../../assets/base.css?inline'
2
2
  import { themes, palettes } from 'virtual:mdeck-extensions'
3
3
  import { buildAppearance } from '../extensions/appearance.js'
4
+ import { movedHint } from '../extensions/moved.js'
4
5
 
5
6
  const SELF_CONTAINED = typeof __MDECK_SELF_CONTAINED__ !== 'undefined'
6
7
  && __MDECK_SELF_CONTAINED__
@@ -48,8 +49,10 @@ function syncThemeFonts(urls) {
48
49
  }
49
50
 
50
51
  export async function loadTheme({ theme = 'neue', palette, appearance, params = {}, meta = {} } = {}) {
51
- const entry = themeEntry(theme)
52
- if (!entry) throw new Error(`Unknown theme: "${theme}". Available: ${THEME_NAMES.join(', ')}`)
52
+ // A deck without a theme gets neue, or the first installed theme when neue
53
+ // was removed.
54
+ const entry = themeEntry(theme) ?? (theme === 'neue' && THEME_NAMES.length ? themeEntry(THEME_NAMES[0]) : null)
55
+ if (!entry) throw new Error(movedHint('theme', theme) ?? `Unknown theme: "${theme}". Available: ${THEME_NAMES.join(', ') || 'none; install one with mdeck themes install'}`)
53
56
 
54
57
  const styles = await entry.load()
55
58
  const look = buildAppearance({
@@ -1,29 +0,0 @@
1
- schema = 1
2
- kind = "palette"
3
- id = "brand"
4
- title = "FHNW"
5
- description = "The FHNW corporate colours: black, white and FHNW yellow."
6
- # Only for the fhnw theme: its corporate colours.
7
- theme = "fhnw"
8
-
9
- [light]
10
- "--bg" = "#ffffff"
11
- "--surface" = "#f1f1ee"
12
- "--ink" = "#000000"
13
- "--ink-soft" = "#262626"
14
- "--muted" = "#6e6e6e"
15
- "--rule" = "#deded9"
16
- "--accent" = "#fde70e"
17
- "--accent-2" = "#000000"
18
- "--on-accent" = "#000000"
19
-
20
- [dark]
21
- "--bg" = "#000000"
22
- "--surface" = "#1c1c1c"
23
- "--ink" = "#ffffff"
24
- "--ink-soft" = "#dcdcdc"
25
- "--muted" = "#9a9a9a"
26
- "--rule" = "#333333"
27
- "--accent" = "#fde70e"
28
- "--accent-2" = "#ffffff"
29
- "--on-accent" = "#000000"
@@ -1,27 +0,0 @@
1
- schema = 1
2
- kind = "palette"
3
- id = "cobalt"
4
- title = "Cobalt"
5
- description = "Strong ultramarine on white, with vivid orange; bold and clear."
6
-
7
- [light]
8
- "--bg" = "#ffffff"
9
- "--surface" = "#eef1fb"
10
- "--ink" = "#0c1033"
11
- "--ink-soft" = "#2a2f55"
12
- "--muted" = "#5b6080"
13
- "--rule" = "#d5dbf0"
14
- "--accent" = "#1f3fd1"
15
- "--accent-2" = "#e8590c"
16
- "--on-accent" = "#ffffff"
17
-
18
- [dark]
19
- "--bg" = "#0a1030"
20
- "--surface" = "#121a44"
21
- "--ink" = "#eef1ff"
22
- "--ink-soft" = "#c9cff0"
23
- "--muted" = "#8e97c4"
24
- "--rule" = "#212b5e"
25
- "--accent" = "#6f8cff"
26
- "--accent-2" = "#ff922b"
27
- "--on-accent" = "#0a1030"
@@ -1,27 +0,0 @@
1
- schema = 1
2
- kind = "palette"
3
- id = "ember"
4
- title = "Ember"
5
- description = "Sand and espresso with a glowing orange."
6
-
7
- [light]
8
- "--bg" = "#fbf6ef"
9
- "--surface" = "#f1e7da"
10
- "--ink" = "#2a1a10"
11
- "--ink-soft" = "#4a3324"
12
- "--muted" = "#7a6656"
13
- "--rule" = "#e6d6c3"
14
- "--accent" = "#c2410c"
15
- "--accent-2" = "#8f5a3c"
16
- "--on-accent" = "#ffffff"
17
-
18
- [dark]
19
- "--bg" = "#121005"
20
- "--surface" = "#1f1e19"
21
- "--ink" = "#eae0d5"
22
- "--ink-soft" = "#c6ac8f"
23
- "--muted" = "#9a8a77"
24
- "--rule" = "#2b2a29"
25
- "--accent" = "#eb5e28"
26
- "--accent-2" = "#c98b6b"
27
- "--on-accent" = "#0d0a05"
@@ -1,27 +0,0 @@
1
- schema = 1
2
- kind = "palette"
3
- id = "forest"
4
- title = "Forest"
5
- description = "Deep green with moss and ochre; calm, with contrast."
6
-
7
- [light]
8
- "--bg" = "#f6f5ef"
9
- "--surface" = "#e9eadf"
10
- "--ink" = "#12261c"
11
- "--ink-soft" = "#2c4436"
12
- "--muted" = "#5a6c60"
13
- "--rule" = "#d3d8c8"
14
- "--accent" = "#2e7d32"
15
- "--accent-2" = "#9a6b00"
16
- "--on-accent" = "#ffffff"
17
-
18
- [dark]
19
- "--bg" = "#0e1a14"
20
- "--surface" = "#16261d"
21
- "--ink" = "#e9f1e6"
22
- "--ink-soft" = "#c4d3c0"
23
- "--muted" = "#8ea493"
24
- "--rule" = "#24382c"
25
- "--accent" = "#7bd389"
26
- "--accent-2" = "#e6c35c"
27
- "--on-accent" = "#0e1a14"
@@ -1,27 +0,0 @@
1
- schema = 1
2
- kind = "palette"
3
- id = "graphite"
4
- title = "Graphite"
5
- description = "Black and white with a steel-blue accent."
6
-
7
- [light]
8
- "--bg" = "#fbfaf8"
9
- "--surface" = "#efeeeb"
10
- "--ink" = "#141414"
11
- "--ink-soft" = "#3a3a3a"
12
- "--muted" = "#6a6a6a"
13
- "--rule" = "#d9d8d4"
14
- "--accent" = "#3d5a73"
15
- "--accent-2" = "#8a6d3b"
16
- "--on-accent" = "#ffffff"
17
-
18
- [dark]
19
- "--bg" = "#141414"
20
- "--surface" = "#1f1f1f"
21
- "--ink" = "#f2f2f0"
22
- "--ink-soft" = "#cfcfcc"
23
- "--muted" = "#8e8e8b"
24
- "--rule" = "#2f2f2f"
25
- "--accent" = "#9fc2e0"
26
- "--accent-2" = "#d9b878"
27
- "--on-accent" = "#141414"
@@ -1,30 +0,0 @@
1
- schema = 1
2
- kind = "palette"
3
- id = "pastel"
4
- title = "Pastel"
5
- description = "Soft pastels in the Catppuccin style: Latte by day, Mocha by night, mauve and peach."
6
-
7
- # Catppuccin Latte; the peach and the muted grey are a shade darker so they
8
- # can be read on the base.
9
- [light]
10
- "--bg" = "#eff1f5"
11
- "--surface" = "#e6e9ef"
12
- "--ink" = "#4c4f69"
13
- "--ink-soft" = "#5c5f77"
14
- "--muted" = "#676a80"
15
- "--rule" = "#ccd0da"
16
- "--accent" = "#8839ef"
17
- "--accent-2" = "#dc5c0c"
18
- "--on-accent" = "#eff1f5"
19
-
20
- # Catppuccin Mocha.
21
- [dark]
22
- "--bg" = "#1e1e2e"
23
- "--surface" = "#313244"
24
- "--ink" = "#cdd6f4"
25
- "--ink-soft" = "#bac2de"
26
- "--muted" = "#a6adc8"
27
- "--rule" = "#45475a"
28
- "--accent" = "#cba6f7"
29
- "--accent-2" = "#fab387"
30
- "--on-accent" = "#1e1e2e"
@@ -1,27 +0,0 @@
1
- schema = 1
2
- kind = "palette"
3
- id = "phosphor"
4
- title = "Phosphor"
5
- description = "A terminal screen: near-black with phosphor green and amber; in light, a printout on paper."
6
-
7
- [light]
8
- "--bg" = "#fbfbf7"
9
- "--surface" = "#eeefe8"
10
- "--ink" = "#141714"
11
- "--ink-soft" = "#2f362f"
12
- "--muted" = "#5c665c"
13
- "--rule" = "#d6dad0"
14
- "--accent" = "#16803a"
15
- "--accent-2" = "#a05a00"
16
- "--on-accent" = "#ffffff"
17
-
18
- [dark]
19
- "--bg" = "#050805"
20
- "--surface" = "#0e160f"
21
- "--ink" = "#b6ffc4"
22
- "--ink-soft" = "#86e29a"
23
- "--muted" = "#5c9e6a"
24
- "--rule" = "#1a2e1d"
25
- "--accent" = "#39ff6e"
26
- "--accent-2" = "#ffb000"
27
- "--on-accent" = "#050805"
@@ -1,27 +0,0 @@
1
- schema = 1
2
- kind = "palette"
3
- id = "terra"
4
- title = "Terra"
5
- description = "Cream, deep navy and brick red; warm and grounded."
6
-
7
- [light]
8
- "--bg" = "#fdf0d5"
9
- "--surface" = "#f1e3c3"
10
- "--ink" = "#002b42"
11
- "--ink-soft" = "#12425c"
12
- "--muted" = "#5a6870"
13
- "--rule" = "#e3cfa3"
14
- "--accent" = "#b3121d"
15
- "--accent-2" = "#c25e00"
16
- "--on-accent" = "#fff7e6"
17
-
18
- [dark]
19
- "--bg" = "#001f30"
20
- "--surface" = "#062c42"
21
- "--ink" = "#fdf0d5"
22
- "--ink-soft" = "#e8d9b8"
23
- "--muted" = "#9fb0b8"
24
- "--rule" = "#10405a"
25
- "--accent" = "#ff6b5b"
26
- "--accent-2" = "#ffb347"
27
- "--on-accent" = "#001f30"