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
@@ -26,13 +26,6 @@ export function checkDeck(slidesPath, registry, source = readFileSync(slidesPath
26
26
  if (record.source !== 'local') continue
27
27
  for (const problem of paletteProblems(record.manifest)) diagnostics.push({ severity: 'warning', code: 'palette-contrast', message: `Palette "${record.id}", ${problem}`, line: 1, column: 1 })
28
28
  }
29
- // A theme or palette installed for this user only: the slide file needs it
30
- // beside it to work on another computer.
31
- const config = deck.deckConfig ?? {}
32
- for (const [kind, id] of [['theme', config.theme], ['palette', config.palette]]) {
33
- const record = id && registry[`${kind}s`][id]
34
- if (record?.source === 'user') diagnostics.push({ severity: 'warning', code: 'user-extension', message: `The ${kind} "${id}" is installed only on this computer (${dirname(record.dir)}); copy it into the deck's extensions folder, for example with mdeck themes install, so the slide file works elsewhere`, line: 1, column: 1 })
35
- }
36
29
  // The deck's drawings file: readable, and only for slides that still exist.
37
30
  const inkPath = inkFileFor(resolve(slidesPath))
38
31
  const renamed = drawingsDiagnostic(slidesPath)
@@ -5,8 +5,9 @@
5
5
  import { createHash } from 'node:crypto'
6
6
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, unlinkSync } from 'node:fs'
7
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'
8
+ import { loadRegistry, removedBuiltIns, serializeRegistry, userExtensionsDir } from '../extensions/discover.js'
9
+ import { findEntry, installedPackages, keyOf, loadCatalogue, PackageError } from '../extensions/packages.js'
10
+ import { installHere, removeHere, target } from '../extensions/installed.js'
10
11
  import { createRequire } from 'node:module'
11
12
  import { ManifestError, KINDS, ID_RE, MANIFEST_FILENAME, parseManifestText, validateManifest } from '../extensions/manifest.js'
12
13
 
@@ -180,7 +181,6 @@ export function createExtensionStore(deckDir, { registryFor }) {
180
181
  }
181
182
 
182
183
  export function editorMiddleware(deckFile, { registryFor, extensions = createExtensionStore(dirname(deckFile.path), { registryFor }) }) {
183
- const extensionsDir = resolve(dirname(deckFile.path), 'extensions')
184
184
  return async (request, response, next) => {
185
185
  const pathname = new URL(request.url, 'http://localhost').pathname
186
186
  if (!isAllowedRequest(request)) return send(response, 403, { error: 'Requests are only accepted from this editor on this computer' })
@@ -201,18 +201,31 @@ export function editorMiddleware(deckFile, { registryFor, extensions = createExt
201
201
  response.setHeader('Allow', 'GET, PUT, DELETE')
202
202
  return send(response, 405, { error: 'Method not allowed' })
203
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) })
204
+ // Themes and palettes: what mdeck and the repository offer, and
205
+ // installing or removing one for every deck (as mdeck themes does).
206
+ if (pathname === '/themes/catalogue' && request.method === 'GET') {
207
+ const catalogue = await loadCatalogue()
208
+ const hidden = removedBuiltIns()
209
+ const installed = installedPackages(userExtensionsDir())
210
+ const entries = catalogue.entries.map(({ dir, ...entry }) => {
211
+ const key = keyOf(entry.kind, entry.id)
212
+ const removed = entry.builtIn && hidden.includes(key)
213
+ return { ...entry, removed, available: Boolean(installed[key]) || (entry.builtIn && !removed), ...(entry.preview ? { preview: new URL(entry.preview, catalogue.url).href } : {}) }
214
+ })
215
+ return send(response, 200, { url: catalogue.url, offline: catalogue.offline, entries })
209
216
  }
210
217
  if (pathname === '/themes/install' && request.method === 'POST') {
211
218
  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()) })
219
+ const kind = String(body?.kind ?? ''), id = String(body?.id ?? '')
220
+ const catalogue = await loadCatalogue()
221
+ findEntry(catalogue, kind, id)
222
+ const steps = await installHere(catalogue, kind, id, target(null, false), { mdeckVersion: VERSION })
223
+ return send(response, 200, { steps, registry: serializeRegistry(registryFor()) })
224
+ }
225
+ if (pathname === '/themes/remove' && request.method === 'POST') {
226
+ const body = await readJson(request)
227
+ const removed = removeHere(String(body?.kind ?? ''), String(body?.id ?? ''), target(null, false))
228
+ return send(response, 200, { removed, registry: serializeRegistry(registryFor()) })
216
229
  }
217
230
  if (pathname === '/deck' && request.method === 'GET') {
218
231
  const registry = registryFor()
@@ -230,7 +243,7 @@ export function editorMiddleware(deckFile, { registryFor, extensions = createExt
230
243
  if (['/deck', '/source'].includes(pathname)) { response.setHeader('Allow', pathname === '/deck' ? 'GET' : 'POST'); return send(response, 405, { error: 'Method not allowed' }) }
231
244
  return next()
232
245
  } catch (error) {
233
- if (error instanceof PackError) return send(response, 400, { error: error.message })
246
+ if (error instanceof PackageError) return send(response, 400, { error: error.message })
234
247
  if (error instanceof ManifestError) return send(response, 500, { error: error.message })
235
248
  return send(response, error.status ?? 500, { error: error.message })
236
249
  }
@@ -11,6 +11,7 @@ import { resolve } from 'node:path'
11
11
  import { launchChrome } from './chrome.js'
12
12
  import { frameworkRoot } from '../paths.js'
13
13
  import { sampleDeck, SAMPLE_PICTURE } from '../editor/sampleDeck.js'
14
+ import { loadRegistry } from '../extensions/discover.js'
14
15
 
15
16
  // Runs in the page before the deck: keeps what the page reports.
16
17
  const RECORDER = `window.__report = [];
@@ -80,18 +81,28 @@ const MEASURE = `(() => {
80
81
  return problems
81
82
  })()`
82
83
 
83
- // Builds the sample deck with `extensions` ({ id: folder }) beside it and
84
- // opens it in Chrome for `use({ open, ids })`. Installed user extensions are
85
- // left out, so the result depends only on what is given.
86
- export async function withSampleDeck({ extensions = {} } = {}, use) {
84
+ // Builds the sample deck with `extensions` ({ id: folder }) beside it into
85
+ // `outDir`, as a folder to host. Installed user extensions are left out, so
86
+ // the result depends only on what is given. Returns the registry it used.
87
+ export function buildSampleDeck({ extensions = {}, outDir }) {
87
88
  const temp = mkdtempSync(resolve(tmpdir(), 'mdeck-sample-'))
88
89
  try {
89
90
  writeFileSync(resolve(temp, 'slides.md'), sampleDeck())
90
91
  writeFileSync(resolve(temp, 'picture.svg'), SAMPLE_PICTURE)
91
- for (const [id, dir] of Object.entries(extensions)) cpSync(dir, resolve(temp, 'extensions', id), { recursive: true })
92
+ // Keyed by id, or by "kind:id" to keep a theme and a palette of one name apart.
93
+ for (const [key, dir] of Object.entries(extensions)) cpSync(dir, resolve(temp, 'extensions', ...key.split(':').map((part, i, all) => all.length > 1 && i === 0 ? `${part}s` : part)), { recursive: true })
92
94
  mkdirSync(resolve(temp, 'home'))
93
- execFileSync(process.execPath, [resolve(frameworkRoot, 'bin/mdeck.js'), 'build', resolve(temp, 'slides.md'), '-o', resolve(temp, 'out/index.html')],
95
+ execFileSync(process.execPath, [resolve(frameworkRoot, 'bin/mdeck.js'), 'build', resolve(temp, 'slides.md'), '-o', resolve(outDir, 'index.html')],
94
96
  { cwd: temp, stdio: 'pipe', env: { ...process.env, MDECK_HOME: resolve(temp, 'home') } })
97
+ return loadRegistry(resolve(temp, 'slides.md'), { userRoot: null })
98
+ } finally { rmSync(temp, { recursive: true, force: true }) }
99
+ }
100
+
101
+ // The sample deck built with `extensions`, opened in Chrome for `use({ open })`.
102
+ export async function withSampleDeck({ extensions = {} } = {}, use) {
103
+ const temp = mkdtempSync(resolve(tmpdir(), 'mdeck-sample-'))
104
+ try {
105
+ buildSampleDeck({ extensions, outDir: resolve(temp, 'out') })
95
106
  const browser = await launchChrome({ dir: resolve(temp, 'out') })
96
107
  try {
97
108
  // A page with the deck in a look, recording what it reports.
package/src/cli/args.js CHANGED
@@ -7,7 +7,8 @@ const commands = {
7
7
  run: { ...common, '--network': flag, '--server': optional, max: 1 },
8
8
  edit: { ...common, max: 1 },
9
9
  design: { ...common, max: 1 },
10
- themes: { '--global': flag, '--force': flag, '--check': flag, '--output': value, '-o': '--output', max: 4 },
10
+ themes: { '--local': flag, '--force': flag, '--check': flag, '--output': value, '-o': '--output', max: 4 },
11
+ palettes: { '--local': flag, '--force': flag, max: 4 },
11
12
  build: { ...output, '--single-file': flag, '--launchers': flag, '--reader': flag, '--notes': flag, max: 1 },
12
13
  send: { ...output, '--notes': flag, '--no-pdf': flag, max: 1 },
13
14
  pdf: { ...output, max: 1 },
package/src/cli/index.js CHANGED
@@ -19,7 +19,7 @@ import { createPairing, pairingPlugin } from '../build/pairing.js'
19
19
  import { isAllowedRequest } from '../build/editorPlugin.js'
20
20
  import { renderPdf, attachPdf, findChrome } from '../build/pdf.js'
21
21
  import { installSkill, readSkill, TARGETS } from './skill.js'
22
- import { runThemes } from './themes.js'
22
+ import { runPackages } from './themes.js'
23
23
  import { ManifestError, KINDS } from '../extensions/manifest.js'
24
24
 
25
25
  import { frameworkRoot } from '../paths.js'
@@ -313,9 +313,10 @@ const HELP = `
313
313
  ${c.green}mdeck edit${c.reset} [slides.md] Edit slides in the browser; saves to the file
314
314
  ${c.green}mdeck design${c.reset} [folder | slides.md] Look at and fine-tune themes and palettes on a sample deck;
315
315
  saves to the folder's extensions/ (default: this folder)
316
- ${c.green}mdeck themes${c.reset} search [words] Themes and palettes from the online repository
317
- install <pack> [slides.md | folder] into the deck's extensions/ (--global: for every deck)
318
- remove <pack>, update [pack], list --force replaces files changed by hand
316
+ ${c.green}mdeck themes${c.reset} search [words] Themes from mdeck and the theme repository; install <theme>
317
+ install <theme>, remove <theme> brings its default palette along; for every deck, or add
318
+ update [theme], list slides.md or --local for one deck; --force replaces edited files
319
+ ${c.green}mdeck palettes${c.reset} search | install | remove | update | list The same for palettes
319
320
 
320
321
  ${c.dim}Make something to hand out${c.reset}
321
322
  ${c.green}mdeck build${c.reset} [slides.md] [-o dir/index.html] A folder to host
@@ -613,8 +614,8 @@ if (command === 'new') {
613
614
  tip(`Changes are saved to the file as you type. Before the first change, a copy goes to ${BACKUP_DIR}/ beside it.\n`)
614
615
 
615
616
  // ── themes ────────────────────────────────────────────────────────────────────
616
- } else if (command === 'themes') {
617
- await runThemes({ positionals: positionals(), flag: name => hasFlag(name), output: outputOption, ui: { ok, err, tip, c } })
617
+ } else if (command === 'themes' || command === 'palettes') {
618
+ await runPackages({ kind: command.slice(0, -1), positionals: positionals(), flag: name => hasFlag(name), output: outputOption, ui: { ok, err, tip, c } })
618
619
 
619
620
  // ── design ────────────────────────────────────────────────────────────────────
620
621
  // The design page: with a deck, beside its editor; with a folder (or nothing),
package/src/cli/themes.js CHANGED
@@ -1,140 +1,170 @@
1
- // `mdeck themes`: themes and palettes from the theme repository. Installs go
2
- // into a deck's extensions/ folder, or with --global into the user's
3
- // (~/.mdeck/extensions), where every deck finds them. `build` is for the
4
- // repository itself: it checks the packs and writes what is served.
5
- import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
1
+ // `mdeck themes` and `mdeck palettes`: install, update and remove themes and
2
+ // palettes, each on its own. Some come with mdeck, the others from the theme
3
+ // repository. A theme brings its default palette along, a palette that
4
+ // belongs to one theme brings that theme. They are installed for every deck
5
+ // (~/.mdeck/extensions) unless a deck or folder is named, or --local is
6
+ // given: then into the extensions folder beside it, so the slide folder
7
+ // carries them. A built-in one is removed by hiding it and installed again
8
+ // from mdeck's own copy, so that works offline. `mdeck themes build` is for
9
+ // the repository itself.
10
+ import { existsSync, statSync, writeFileSync } from 'node:fs'
6
11
  import { createRequire } from 'node:module'
7
- import { dirname, resolve } from 'node:path'
8
- import { builtInRegistry, loadRegistry, userExtensionsDir } from '../extensions/discover.js'
12
+ import { resolve } from 'node:path'
13
+ import { loadRegistry, manifestsOf, removedBuiltIns, userExtensionsDir } from '../extensions/discover.js'
14
+ import { target, installHere, removeHere } from '../extensions/installed.js'
15
+ import { palettesFor } from '../extensions/tokens.js'
9
16
  import { ManifestError } from '../extensions/manifest.js'
10
- import { buildRepository, compareVersions, fetchIndex, findPack, installedPacks, installFromIndex, PackError, removePack } from '../extensions/packs.js'
17
+ import { buildRepository, compareVersions, findEntry, installedPackages, keyOf, loadCatalogue, PackageError } from '../extensions/packages.js'
11
18
 
12
19
  const VERSION = createRequire(import.meta.url)('../../package.json').version
13
20
 
14
- const USAGE = 'search [words] | install <pack> | remove <pack> | update [pack] | list, each with [slides.md or folder] and --global; build <packs> -o <out> [--check]'
21
+ const usage = kind => `search [words] | install <${kind}> | remove <${kind}> | update [${kind}] | list — for every deck, or beside a deck with [slides.md or folder] or --local${kind === 'theme' ? '; build <repository> -o <out> [--check]' : ''}`
15
22
 
16
- // The built-in themes and palettes a pack may name and must not reuse the ids of.
17
- const builtIns = () => {
18
- const registry = builtInRegistry()
19
- return { themes: registry.themes, palettes: registry.palettes }
20
- }
21
-
22
- // The extensions folder a command works on, and a slides path to load the
23
- // registry with: the user's with --global, else beside the deck or in the folder.
24
- function target(where, global) {
25
- if (global) return { root: userExtensionsDir(), deck: resolve('slides.md'), label: 'for every deck' }
26
- const given = resolve(where ?? '.')
27
- const folder = existsSync(given) && statSync(given).isDirectory() ? given : dirname(given)
28
- return { root: resolve(folder, 'extensions'), deck: existsSync(given) && !statSync(given).isDirectory() ? given : resolve(folder, 'slides.md'), label: `in ${resolve(folder, 'extensions')}` }
23
+ // Every theme with the palettes it offers, and every palette's colours, each
24
+ // marked as built in or from the repository: for the theme browser.
25
+ function looksOf(registry, catalogue) {
26
+ const builtIn = new Set([...catalogue.themes, ...catalogue.palettes].filter(entry => entry.builtIn).map(entry => entry.id + (catalogue.themes.includes(entry) ? ':t' : ':p')))
27
+ const palettes = manifestsOf(registry, 'palette')
28
+ const colours = tokens => Object.fromEntries(['--bg', '--surface', '--ink', '--accent', '--accent-2'].map(key => [key, tokens?.[key] ?? null]))
29
+ return {
30
+ mdeck: VERSION,
31
+ themes: Object.values(manifestsOf(registry, 'theme')).map(theme => ({ id: theme.id, title: theme.title, description: theme.description ?? '', builtIn: builtIn.has(theme.id + ':t'),
32
+ palette: theme.palette, appearance: theme.appearance ?? 'light', palettes: palettesFor(theme, palettes).map(p => p.id) })),
33
+ palettes: Object.values(palettes).map(palette => ({ id: palette.id, title: palette.title, description: palette.description ?? '', builtIn: builtIn.has(palette.id + ':p'),
34
+ ...(palette.theme ? { theme: palette.theme } : {}), light: colours(palette.light), dark: colours(palette.dark) })),
35
+ }
29
36
  }
30
37
 
31
- const install = (index, entry, { root, force }) => installFromIndex(index, entry, { root, known: builtIns(), mdeckVersion: VERSION, force })
32
-
33
- export async function runThemes({ positionals, flag, output, ui: { ok, err, tip, c } }) {
38
+ export async function runPackages({ kind, positionals, flag, output, ui: { ok, err, tip, c } }) {
39
+ const kinds = `${kind}s`
34
40
  const [sub = 'search', ...rest] = positionals
35
- const global = flag('--global'), force = flag('--force')
41
+ const local = flag('--local'), force = flag('--force')
42
+ const warnOffline = catalogue => { if (catalogue.offline) tip(`The theme repository could not be reached (${catalogue.offline}); showing the ${kinds} that come with mdeck.`) }
43
+ const label = step => `the ${step.kind} ${step.id}`
36
44
  try {
37
45
  if (sub === 'search') {
38
- const index = await fetchIndex()
46
+ const catalogue = await loadCatalogue()
47
+ warnOffline(catalogue)
39
48
  const words = rest.map(word => word.toLowerCase())
40
- const local = installedPacks(target(null, false).root), mine = installedPacks(userExtensionsDir())
41
- const found = index.packs.filter(pack => words.every(word => [pack.id, pack.title, pack.description, ...pack.themes, ...pack.palettes].join(' ').toLowerCase().includes(word)))
42
- if (!found.length) return tip(`No pack matches${words.length ? ` "${words.join(' ')}"` : ''} in ${index.url}`)
43
- for (const pack of found) {
44
- const have = local[pack.id] ?? mine[pack.id]
45
- const state = !have ? '' : compareVersions(pack.version, have.version) > 0 ? ` ${c.yellow}(${have.version} installed, update available)${c.reset}` : ` ${c.green}(installed)${c.reset}`
46
- console.log(`\n ${c.cyan}${pack.id}${c.reset} ${pack.version} — ${pack.title}${state}`)
47
- if (pack.description) console.log(` ${pack.description}`)
48
- console.log(` ${c.dim}${[pack.themes.length ? `themes: ${pack.themes.join(', ')}` : '', pack.palettes.length ? `palettes: ${pack.palettes.join(', ')}` : ''].filter(Boolean).join(' · ')} · by ${pack.author}, ${pack.license}${c.reset}`)
49
+ const forEvery = installedPackages(userExtensionsDir()), here = installedPackages(resolve('extensions')), hidden = removedBuiltIns()
50
+ const found = catalogue.entries.filter(entry => entry.kind === kind && words.every(word => [entry.id, entry.title, entry.description, entry.palette ?? '', entry.theme ?? ''].join(' ').toLowerCase().includes(word)))
51
+ if (!found.length) return tip(`No ${kind} matches${words.length ? ` "${words.join(' ')}"` : ''}`)
52
+ for (const entry of found) {
53
+ const key = keyOf(kind, entry.id)
54
+ const have = here[key] ?? forEvery[key]
55
+ const state = have ? (compareVersions(entry.version, have.version ?? '0.0.0') > 0 ? ` ${c.yellow}(${have.version} installed, update available)${c.reset}` : ` ${c.green}(installed)${c.reset}`)
56
+ : entry.builtIn ? (hidden.includes(key) ? ` ${c.dim}(comes with mdeck, removed)${c.reset}` : ` ${c.green}(comes with mdeck)${c.reset}`)
57
+ : entry.builtInVersion ? ` ${c.yellow}(comes with mdeck as ${entry.builtInVersion}, update available)${c.reset}` : ''
58
+ console.log(`\n ${c.cyan}${entry.id}${c.reset} ${entry.version} — ${entry.title}${state}`)
59
+ if (entry.description) console.log(` ${entry.description}`)
60
+ const facts = [kind === 'theme' ? `palette: ${entry.palette}` : entry.theme ? `only for the theme ${entry.theme}` : '', entry.author ? `by ${entry.author}` : '', entry.license].filter(Boolean)
61
+ if (facts.length) console.log(` ${c.dim}${facts.join(' · ')}${c.reset}`)
49
62
  }
50
63
  console.log()
51
- return tip('Install one with: mdeck themes install <pack> [slides.md] (--global for every deck)')
64
+ return tip(`Install one for every deck with: mdeck ${kinds} install <${kind}> (beside a deck: add slides.md or --local)`)
52
65
  }
53
66
 
54
67
  if (sub === 'install') {
55
68
  const [id, where] = rest
56
- if (!id) { err('Name a pack.'); return tip('Usage: mdeck themes install <pack> [slides.md or folder] [--global]; mdeck themes search lists them.') }
57
- const { root, deck, label } = target(where, global)
58
- const index = await fetchIndex()
59
- const result = await install(index, findPack(index, id), { root, force })
60
- loadRegistry(deck)
61
- ok(`${result.replaced ? `Updated ${id} from ${result.replaced}` : `Installed ${id}`} ${label}`)
62
- if (result.themes.length) tip(`Themes: ${result.themes.join(', ')}. Use one with "theme: ${result.themes[0]}" in the deck settings.`)
63
- if (result.palettes.length) tip(`Palettes: ${result.palettes.join(', ')}. Use one with "palette: ${result.palettes[0]}".`)
64
- return tip('Look at it on a sample deck: mdeck design')
69
+ if (!id) { err(`Name a ${kind}.`); return tip(`Usage: mdeck ${kinds} install <${kind}> [slides.md or folder] [--local]; mdeck ${kinds} search lists them.`) }
70
+ const at = target(where, local)
71
+ const catalogue = await loadCatalogue()
72
+ warnOffline(catalogue)
73
+ findEntry(catalogue, kind, id)
74
+ for (const step of await installHere(catalogue, kind, id, at, { mdeckVersion: VERSION, force })) {
75
+ const what = step.requested ? label(step) : `${label(step)}, which it needs,`
76
+ if (step.kept) tip(`${what[0].toUpperCase() + what.slice(1)} is there already`)
77
+ else if (step.builtIn) step.wasRemoved || step.requested ? ok(`${label(step)[0].toUpperCase() + label(step).slice(1)} comes with mdeck${step.wasRemoved ? ' and is offered again' : ''}`) : null
78
+ else ok(`${step.replaced ? `Updated ${label(step)} from ${step.replaced} to ${step.version}` : `Installed ${label(step)} ${step.version}`} ${at.label}`)
79
+ }
80
+ tip(`Use it with "${kind}: ${id}" at the top of a slide file. Look at it on a sample deck: mdeck design`)
81
+ return
65
82
  }
66
83
 
67
84
  if (sub === 'remove') {
68
85
  const [id, where] = rest
69
- if (!id) { err('Name a pack.'); return tip('Usage: mdeck themes remove <pack> [slides.md or folder] [--global]') }
70
- const { root, label } = target(where, global)
71
- const pack = removePack(id, { root, force })
72
- return ok(`Removed ${id} ${label} (${pack.folders.join(', ')})`)
86
+ if (!id) { err(`Name a ${kind}.`); return tip(`Usage: mdeck ${kinds} remove <${kind}> [slides.md or folder] [--local]`) }
87
+ const at = target(where, local)
88
+ for (const step of removeHere(kind, id, at, { force })) {
89
+ ok(step.hidden ? `${label(step)[0].toUpperCase() + label(step).slice(1)} comes with mdeck and is no longer offered; mdeck ${step.kind}s install ${step.id} brings it back` : `Removed ${label(step)} ${at.label}`)
90
+ }
91
+ return
73
92
  }
74
93
 
75
94
  if (sub === 'update') {
76
95
  const [first, second] = rest
77
96
  const named = first && !/\.md$/i.test(first) && !(existsSync(first) && statSync(first).isDirectory()) ? first : null
78
- const { root, deck, label } = target(named ? second : first, global)
79
- const installed = installedPacks(root)
80
- const ids = named ? [named] : Object.keys(installed)
81
- if (!ids.length) return tip(`No packs installed ${label}`)
82
- const index = await fetchIndex()
97
+ const at = target(named ? second : first, local)
98
+ const catalogue = await loadCatalogue()
99
+ warnOffline(catalogue)
100
+ const installed = Object.values(installedPackages(at.root)).filter(item => item.kind === kind)
101
+ const ids = named ? [named] : installed.map(item => item.id)
102
+ if (!ids.length) return tip(`No ${kinds} installed ${at.label}`)
83
103
  for (const id of ids) {
84
- if (!installed[id]) throw new PackError(`The pack ${id} is not installed ${label}`)
85
- const entry = findPack(index, id)
86
- if (compareVersions(entry.version, installed[id].version) <= 0) { tip(`${id} ${installed[id].version} is up to date`); continue }
87
- await install(index, entry, { root, force })
88
- ok(`Updated ${id} ${installed[id].version} → ${entry.version}`)
104
+ const have = installed.find(item => item.id === id)
105
+ if (!have) throw new PackageError(`The ${kind} ${id} is not installed ${at.label}`)
106
+ const entry = findEntry(catalogue, kind, id)
107
+ if (entry.builtIn || compareVersions(entry.version, have.version ?? '0.0.0') <= 0) { tip(`The ${kind} ${id} ${have.version} is up to date`); continue }
108
+ await installHere(catalogue, kind, id, at, { mdeckVersion: VERSION, force })
109
+ ok(`Updated the ${kind} ${id} ${have.version} → ${entry.version}`)
89
110
  }
90
- loadRegistry(deck)
111
+ loadRegistry(at.deck)
91
112
  return
92
113
  }
93
114
 
94
115
  if (sub === 'list') {
95
116
  const [where] = rest
96
- for (const [label, root] of [['This folder', target(where, false).root], ['Every deck (--global)', userExtensionsDir()]]) {
97
- const packs = installedPacks(root)
98
- console.log(`\n ${c.bold}${label}${c.reset} ${c.dim}${root}${c.reset}`)
99
- if (!Object.keys(packs).length) console.log(` ${c.dim}none${c.reset}`)
100
- for (const [id, pack] of Object.entries(packs)) console.log(` ${c.cyan}${id}${c.reset} ${pack.version} — ${pack.folders.join(', ')}`)
117
+ const hidden = removedBuiltIns()
118
+ const catalogue = await loadCatalogue({ online: false })
119
+ console.log(`\n ${c.bold}Come with mdeck${c.reset}`)
120
+ for (const entry of catalogue.entries.filter(entry => entry.kind === kind)) console.log(` ${c.cyan}${entry.id}${c.reset}${hidden.includes(keyOf(kind, entry.id)) ? ` ${c.dim}(removed; mdeck ${kinds} install ${entry.id} brings it back)${c.reset}` : ''}`)
121
+ const sections = [['For every deck', userExtensionsDir()], ...(where || local ? [['Beside the deck', target(where, true).root]] : [])]
122
+ for (const [title, root] of sections) {
123
+ const items = Object.values(installedPackages(root)).filter(item => item.kind === kind)
124
+ console.log(`\n ${c.bold}${title}${c.reset} ${c.dim}${root}${c.reset}`)
125
+ if (!items.length) console.log(` ${c.dim}none${c.reset}`)
126
+ for (const item of items) console.log(` ${c.cyan}${item.id}${c.reset} ${item.version}`)
101
127
  }
102
128
  console.log()
103
129
  return
104
130
  }
105
131
 
106
- if (sub === 'build') {
107
- const [packsDir] = rest
132
+ if (sub === 'build' && kind === 'theme') {
133
+ const [repoDir] = rest
108
134
  const out = output()
109
- if (!packsDir || !out) { err('Name the packs folder and the output folder.'); return tip('Usage: mdeck themes build <packs folder> -o <output folder> [--check]') }
110
- const index = buildRepository(resolve(packsDir), resolve(out), { known: builtIns() })
111
- // Every theme and palette of every pack, from the pack folders.
112
- const extensions = Object.fromEntries(index.packs.flatMap(pack => [...pack.themes, ...pack.palettes].map(id => [id, resolve(packsDir, pack.id, id)])))
113
- const paletteTheme = id => {
114
- const text = readFileSync(resolve(extensions[id], 'extension.toml'), 'utf8')
115
- return /^theme\s*=\s*"([^"]+)"/m.exec(text)?.[1] ?? 'neue'
116
- }
117
- const { checkLooks, renderPreviews } = await import('../build/themeCheck.js')
118
- const looks = index.packs.flatMap(pack => [...pack.themes.map(theme => ({ id: theme, theme })), ...pack.palettes.map(palette => ({ id: palette, theme: paletteTheme(palette), palette }))])
135
+ if (!repoDir || !out) { err('Name the repository folder and the output folder.'); return tip('Usage: mdeck themes build <repository folder> -o <output folder> [--check]') }
136
+ const { catalogue, dirs } = buildRepository(resolve(repoDir), resolve(out))
137
+ // The repository's own themes and palettes go beside the sample deck;
138
+ // the built-in ones are in it already.
139
+ const repo = [...catalogue.themes.map(e => ({ ...e, kind: 'theme' })), ...catalogue.palettes.map(e => ({ ...e, kind: 'palette' }))].filter(entry => !entry.builtIn)
140
+ const extensions = Object.fromEntries(repo.map(entry => [keyOf(entry.kind, entry.id), dirs[keyOf(entry.kind, entry.id)]]))
141
+ const { checkLooks, renderPreviews, buildSampleDeck } = await import('../build/themeCheck.js')
142
+ const looks = [...catalogue.themes, ...catalogue.palettes.map(p => ({ ...p, isPalette: true }))].map(entry => entry.isPalette
143
+ ? { key: `palettes/${entry.id}`, theme: entry.theme ?? 'neue', palette: entry.id }
144
+ : { key: `themes/${entry.id}`, theme: entry.id })
119
145
  if (flag('--check')) {
120
146
  const failures = await checkLooks(looks, { extensions })
121
147
  if (failures.length) { err(`The theme check found ${failures.length} problem(s):`); for (const line of failures) console.error(` ${line}`); process.exitCode = 1; return }
122
- ok(`Checked ${looks.map(look => look.id).join(', ')}, light and dark, on every kind of slide`)
148
+ ok(`Checked ${catalogue.themes.length} themes and ${catalogue.palettes.length} palettes, light and dark, on every kind of slide`)
123
149
  }
124
150
  // Themes on the title slide, palettes on the chapter slide (the third).
125
- await renderPreviews(looks.map(look => ({ file: resolve(out, 'previews', `${look.id}.webp`), theme: look.theme, palette: look.palette ?? '', slide: look.palette ? 3 : 1 })), { extensions })
126
- for (const pack of index.packs) pack.previews = Object.fromEntries([...pack.themes, ...pack.palettes].map(id => [id, `previews/${id}.webp`]))
127
- writeFileSync(resolve(out, 'index.json'), JSON.stringify(index, null, 2) + '\n')
128
- return ok(`Built ${index.packs.length} pack(s) into ${resolve(out)}`)
151
+ await renderPreviews(looks.map(look => ({ file: resolve(out, 'previews', `${look.key}.webp`), theme: look.theme, palette: look.palette ?? '', slide: look.palette ? 3 : 1 })), { extensions })
152
+ for (const entry of catalogue.themes) entry.preview = `previews/themes/${entry.id}.webp`
153
+ for (const entry of catalogue.palettes) entry.preview = `previews/palettes/${entry.id}.webp`
154
+ writeFileSync(resolve(out, 'catalogue.json'), JSON.stringify(catalogue, null, 2) + '\n')
155
+ // For the repository's theme browser: the sample deck with every theme
156
+ // and palette, and what each theme offers.
157
+ const registry = buildSampleDeck({ extensions, outDir: resolve(out, 'preview') })
158
+ writeFileSync(resolve(out, 'looks.json'), JSON.stringify(looksOf(registry, catalogue), null, 2) + '\n')
159
+ return ok(`Built ${catalogue.themes.length} themes and ${catalogue.palettes.length} palettes into ${resolve(out)}`)
129
160
  }
130
161
 
131
- err(`Unknown: mdeck themes ${sub}`)
132
- tip(`Use: mdeck themes ${USAGE}`)
162
+ err(`Unknown: mdeck ${kinds} ${sub}`)
163
+ tip(`Use: mdeck ${kinds} ${usage(kind)}`)
133
164
  process.exitCode = 1
134
165
  } catch (error) {
135
- if (!(error instanceof PackError || error instanceof ManifestError)) throw error
166
+ if (!(error instanceof PackageError || error instanceof ManifestError)) throw error
136
167
  err(error.message)
137
168
  process.exitCode = 1
138
169
  }
139
170
  }
140
-
@@ -3,6 +3,7 @@ import { isPlainObject, RENAMED_DECK_KEYS } from './parseSlides.js'
3
3
  import { palettesFor } from '../extensions/tokens.js'
4
4
  import { LABEL_KEYS, CALLOUT_TYPES } from './labels.js'
5
5
  import { resolveLayoutProps, propertyErrors } from '../layouts/layoutProps.js'
6
+ import { movedHint } from '../extensions/moved.js'
6
7
 
7
8
  // `layouts`, `themes` and `palettes` are manifest maps from the extension
8
9
  // registry (see src/extensions/discover.js). Checks for a kind are skipped when
@@ -49,11 +50,12 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
49
50
  if (config.show[key] != null && !values.includes(config.show[key])) add('invalid-config', `show.${key} must be one of: ${values.join(', ')}`, deck.configSource?.start)
50
51
  }
51
52
  }
52
- if (themes && config.theme != null && !Object.hasOwn(themes, config.theme)) add('unknown-theme', `Unknown theme "${config.theme}"; available: ${Object.keys(themes).join(', ')}`, deck.configSource?.start)
53
+ if (themes && config.theme != null && !Object.hasOwn(themes, config.theme)) add('unknown-theme', movedHint('theme', config.theme) ?? `Unknown theme "${config.theme}"; available: ${Object.keys(themes).join(', ') || 'none'}. mdeck themes search lists more to install`, deck.configSource?.start)
54
+ if (themes && config.theme == null && !Object.hasOwn(themes, 'neue')) add('unknown-theme', `This deck sets no theme, so it uses neue, which is not installed: run mdeck themes install neue, or set a theme${Object.keys(themes).length ? ` (${Object.keys(themes).join(', ')})` : ''}`, deck.configSource?.start ?? 0)
53
55
  if (palettes && themes && config.palette != null) {
54
56
  const theme = themes[config.theme ?? 'neue']
55
57
  const offered = theme ? palettesFor(theme, palettes).map(palette => palette.id) : Object.keys(palettes)
56
- if (!Object.hasOwn(palettes, config.palette)) add('unknown-palette', `Unknown palette "${config.palette}"; available: ${offered.join(', ')}`, deck.configSource?.start)
58
+ if (!Object.hasOwn(palettes, config.palette)) add('unknown-palette', movedHint('palette', config.palette) ?? `Unknown palette "${config.palette}"; available: ${offered.join(', ')}`, deck.configSource?.start)
57
59
  else if (theme && !offered.includes(config.palette)) add('unknown-palette', `The theme "${theme.id}" does not offer the palette "${config.palette}"; available: ${offered.join(', ')}`, deck.configSource?.start)
58
60
  }
59
61
  if (themes && config.params != null && isPlainObject(config.params) && Object.hasOwn(themes, config.theme ?? 'neue')) {
package/src/editor/api.js CHANGED
@@ -52,16 +52,25 @@ export async function deleteExtension(kind, id) {
52
52
  return response.json()
53
53
  }
54
54
 
55
- // The theme repository: its packs, with those installed beside the deck.
56
- export async function loadThemeIndex() {
57
- const response = await fetch('/__mdeck/themes/index', { headers: { Accept: 'application/json' } })
55
+ // Every theme and palette mdeck and the theme repository offer, each marked
56
+ // whether it is available to every deck already.
57
+ export async function loadCatalogue() {
58
+ const response = await fetch('/__mdeck/themes/catalogue', { headers: { Accept: 'application/json' } })
58
59
  if (!response.ok) throw await failure(response)
59
60
  return response.json()
60
61
  }
61
62
 
62
- // Installs a pack into the deck's extensions folder. Returns the new registry.
63
- export async function installThemePack(id) {
64
- const response = await fetch('/__mdeck/themes/install', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) })
63
+ // Installs a theme (with its default palette) or a palette for every deck.
64
+ // Returns the steps and the new registry.
65
+ export async function installPackage(kind, id) {
66
+ const response = await fetch('/__mdeck/themes/install', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind, id }) })
67
+ if (!response.ok) throw await failure(response)
68
+ return response.json()
69
+ }
70
+
71
+ // Removes a theme or palette for every deck (a built-in one is hidden).
72
+ export async function removePackage(kind, id) {
73
+ const response = await fetch('/__mdeck/themes/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind, id }) })
65
74
  if (!response.ok) throw await failure(response)
66
75
  return response.json()
67
76
  }
@@ -10,7 +10,7 @@ import { DeckSettings } from './DeckSettings.jsx'
10
10
  import { LayoutPicker } from './LayoutPicker.jsx'
11
11
  import { ConflictBanner } from './ConflictBanner.jsx'
12
12
  import { Resizer, readInspectorWidth, storeInspectorWidth } from './Resizer.jsx'
13
- import { DESIGN_URL, DESIGN_TAB } from '../designLink.js'
13
+ import { designUrl, DESIGN_TAB, launchPageUrl } from '../designLink.js'
14
14
  import markUrl from '../../../assets/logo/mark.svg'
15
15
 
16
16
  const STATUS = { saved: 'Saved', unsaved: 'Unsaved changes', saving: 'Saving…', conflict: 'Conflict', error: 'Could not save' }
@@ -20,6 +20,7 @@ export function App() {
20
20
  const [picker, setPicker] = useState(false)
21
21
  const [inspectorWidth, setInspectorWidth] = useState(readInspectorWidth)
22
22
  const resize = width => { setInspectorWidth(width); storeInspectorWidth(width) }
23
+ const [home] = useState(launchPageUrl)
23
24
 
24
25
  const select = index => dispatch({ type: 'select', index })
25
26
  const actions = {
@@ -50,7 +51,8 @@ export function App() {
50
51
  <span class="spacer" />
51
52
  <button class="btn is-small" onClick={actions.undo} disabled={!state.history.past.length} title="Undo (⌘Z)">Undo</button>
52
53
  <button class="btn is-small" onClick={actions.redo} disabled={!state.history.future.length} title="Redo (⇧⌘Z)">Redo</button>
53
- <a class="btn is-small" href={DESIGN_URL} target={DESIGN_TAB} title="Make and change themes, palettes and layouts, on a sample deck">Design themes</a>
54
+ {home && <a class="btn is-small" href={home}>Launch page</a>}
55
+ <a class="btn is-small" href={designUrl()} target={DESIGN_TAB} title="Make and change themes, palettes and layouts, on a sample deck">Design themes</a>
54
56
  <a class="btn is-small" href="/?view=presenter" target="_blank" rel="noopener">Present</a>
55
57
  <a class="btn is-small" href="/" target="_blank" rel="noopener">Open deck</a>
56
58
  </header>