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
@@ -1,10 +1,10 @@
1
1
  import { h } from 'preact'
2
2
  import { useEffect, useRef, useState } from 'preact/hooks'
3
3
  import { useDeckDocument } from '../useDeckDocument.js'
4
- import { loadExtension, saveExtension, deleteExtension, installThemePack } from '../api.js'
4
+ import { loadExtension, saveExtension, deleteExtension, installPackage, removePackage } from '../api.js'
5
5
  import { starterFiles, parseManifest, toModel, toRuntimeManifest, MANIFEST } from '../extensions.js'
6
6
  import { sampleDeck, sampleDataUrl } from '../sampleDeck.js'
7
- import { parseTarget } from '../designLink.js'
7
+ import { parseTarget, launchPageUrl } from '../designLink.js'
8
8
  import { palettesFor } from '../../extensions/tokens.js'
9
9
  import { parseSlides } from '../../core/parseSlides.js'
10
10
  import { setDeckConfig } from '../../core/editDeck.js'
@@ -36,6 +36,7 @@ export function DesignApp() {
36
36
  const [slide, setSlide] = useState(0)
37
37
  const [inspectorWidth, setInspectorWidth] = useState(readInspectorWidth)
38
38
  const resize = width => { setInspectorWidth(width); storeInspectorWidth(width) }
39
+ const [home] = useState(launchPageUrl)
39
40
  const timer = useRef(null)
40
41
  const currentRef = useRef(current)
41
42
  currentRef.current = current
@@ -117,13 +118,18 @@ export function DesignApp() {
117
118
  } catch (caught) { setError(caught.message) }
118
119
  }
119
120
 
120
- // A pack from the theme repository, installed beside the deck; its first
121
- // theme or palette of the kind shown is opened.
122
- const installOnline = async (pack, kind) => {
123
- const result = await installThemePack(pack.id)
121
+ // A theme or palette installed for every deck, then opened.
122
+ const installOnline = async (kind, id) => {
123
+ const result = await installPackage(kind, id)
124
124
  dispatch({ type: 'setRegistry', registry: result.registry })
125
- const id = pack[`${kind}s`][0]
126
- if (id) await select(kind, id)
125
+ await select(kind, id)
126
+ }
127
+
128
+ // Removes a theme or palette for every deck; the deck's look is not changed.
129
+ const removeOnline = async (kind, id) => {
130
+ const result = await removePackage(kind, id)
131
+ dispatch({ type: 'setRegistry', registry: result.registry })
132
+ setCurrent(null)
127
133
  }
128
134
 
129
135
  const remove = async () => {
@@ -232,6 +238,7 @@ export function DesignApp() {
232
238
  {state.designOnly
233
239
  ? <span class="muted" title={`${state.path}/extensions`}>Saved in {state.name}/extensions</span>
234
240
  : <a class="btn is-small" href="/editor.html">Back to the slides</a>}
241
+ {home && <a class="btn is-small" href={home}>Launch page</a>}
235
242
  </header>
236
243
  {state.status === 'conflict' && <ConflictBanner onReload={() => resolve('reload')} onOverwrite={() => resolve('overwrite')} />}
237
244
  <div class="editor-main" style={{ gridTemplateColumns: `290px minmax(0, 1fr) ${inspectorWidth}px` }}>
@@ -244,7 +251,7 @@ export function DesignApp() {
244
251
  ? <div class="form"><p class="empty">Choose one of {state.designOnly ? "this folder's" : "this deck's"} themes and palettes on the left to change it, or start from a built-in one. What you change is saved as you type, in the <code>extensions</code> folder {state.designOnly ? 'of this folder, where decks in it find them' : "beside the deck. The deck's look is chosen in the editor's Deck settings"}.{error ? ` ${error}` : ''}</p></div>
245
252
  : current.source === 'local'
246
253
  ? <>{usage}<ExtensionEditor extension={current} palettes={manifests.palettes} status={status} error={error} note={note} onFiles={onFiles} onDelete={remove} onCopy={() => copyDialog(current.kind, current.id)} /></>
247
- : <BuiltInCard extension={current} manifest={manifests[`${current.kind}s`][current.id]} onCopy={() => copyDialog(current.kind, current.id)} />}
254
+ : <BuiltInCard extension={current} manifest={manifests[`${current.kind}s`][current.id]} onCopy={() => copyDialog(current.kind, current.id)} onRemove={removeOnline} />}
248
255
  </aside>
249
256
  </div>
250
257
  {dialog && <NewExtensionDialog kind={dialog.kind} initial={dialog.initial ?? null} registry={state.registry} existing={existing} onCreate={create} onClose={() => setDialog(null)} />}
@@ -45,19 +45,29 @@ export function ExtensionEditor({ extension, palettes = {}, status, error, note,
45
45
  </>
46
46
  }
47
47
 
48
- // A built-in or installed extension: what it is, and the one way to change
49
- // it, a copy in the deck's extensions folder.
50
- export function BuiltInCard({ extension, manifest, onCopy }) {
48
+ // A built-in or installed theme or palette: what it is, the one way to
49
+ // change it (a copy in the deck's extensions folder), and removing it.
50
+ export function BuiltInCard({ extension, manifest, onCopy, onRemove }) {
51
51
  const { kind, id, files } = extension
52
52
  const user = extension.source === 'user'
53
+ const [removing, setRemoving] = useState(null)
53
54
  return <div class="form">
54
- <p class="section-title">{user ? `Installed ${kind}, for every deck` : `Built-in ${kind}`}</p>
55
+ <p class="section-title">{user ? `Installed for every deck` : `Comes with mdeck`}{manifest?.version ? ` · ${manifest.version}` : ''}</p>
55
56
  <h2 class="builtin-title">{manifest?.title ?? id} <span class="muted">{id}</span></h2>
56
57
  {manifest?.description && <p class="builtin-description">{manifest.description}</p>}
57
58
  {kind === 'palette' && manifest && ['light', 'dark'].map(mode => <Field key={mode} label={mode === 'light' ? 'Light' : 'Dark'}><Swatches tokens={manifest[mode] ?? {}} /></Field>)}
58
59
  {kind === 'theme' && manifest?.palette && <p class="builtin-description">Its default palette is <code>{manifest.palette}</code>{manifest.palettes?.length ? `; it offers only ${manifest.palettes.join(', ')}` : ''}.</p>}
59
- <p class="note">{user ? `Installed ${kind}s are changed only by updating them (mdeck themes update --global).` : `Built-in ${kind}s cannot be changed.`} Copy this one into the deck's <code>extensions</code> folder and change the copy. The preview shows every change as you make it.</p>
60
- <div class="row" style={{ display: 'flex', gap: '8px' }}><button class="btn is-primary" onClick={onCopy}>Copy to customise</button></div>
60
+ <p class="note">{user ? `Installed ${kind}s change only when they are updated (mdeck ${kind}s update).` : `This ${kind} cannot be changed where it is.`} Copy this one into the deck's <code>extensions</code> folder and change the copy. The preview shows every change as you make it.</p>
61
+ <div class="row" style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
62
+ <button class="btn is-primary" onClick={onCopy}>Copy to customise</button>
63
+ {onRemove && (removing === 'confirm'
64
+ ? <><span class="muted" style={{ alignSelf: 'center' }}>Remove the {kind} {id} for every deck?</span>
65
+ <button class="btn" onClick={async () => { setRemoving('busy'); try { await onRemove(kind, id); setRemoving(null) } catch (error) { setRemoving({ error: error.message }) } }}>Remove</button>
66
+ <button class="btn" onClick={() => setRemoving(null)}>Keep</button></>
67
+ : <button class="btn" disabled={removing === 'busy'} onClick={() => setRemoving('confirm')}>Remove {kind}</button>)}
68
+ </div>
69
+ {removing?.error && <p class="online-error">{removing.error}</p>}
70
+ {!user && <p class="hint-text">Removing a {kind} that comes with mdeck hides it; install it again from “More to install”.</p>}
61
71
  <details class="builtin-source">
62
72
  <summary>{MANIFEST}</summary>
63
73
  <pre>{files?.[MANIFEST] ?? ''}</pre>
@@ -3,13 +3,13 @@ import { useEffect, useState } from 'preact/hooks'
3
3
  import { ID_RE } from '../extensions.js'
4
4
  import { Icon } from '../../components/Icon.jsx'
5
5
  import { LookThumbnail } from './LookThumbnail.jsx'
6
- import { loadThemeIndex } from '../api.js'
6
+ import { loadCatalogue } from '../api.js'
7
7
 
8
8
  const KINDS = [['theme', 'Themes'], ['palette', 'Palettes']]
9
9
 
10
10
  // The deck's own themes or palettes, to change; those installed for every
11
- // deck; packs from the theme repository; and the built-in ones to start
12
- // from. A new theme always starts as a copy; a palette can also start from
11
+ // deck; the built-in ones; and those to install from the theme repository.
12
+ // A new theme always starts as a copy; a palette can also start from
13
13
  // readable plain colours. The pictures show the sample deck as `view` says:
14
14
  // palettes in the preview's theme where it offers them, themes with its
15
15
  // palette where they offer it, both in its light or dark.
@@ -37,56 +37,50 @@ export function ExtensionsPanel({ registry, view = {}, owner = "This deck's", se
37
37
  </div>
38
38
  {own.length
39
39
  ? <ol class="outline-list">{own.map(row)}</ol>
40
- : <p class="panel-hint">None yet. Start from a built-in one below{kind === 'palette' ? ', or with + from plain colours' : ''}.</p>}
40
+ : <p class="panel-hint">None yet. Copy one below to start{kind === 'palette' ? ', or with + from plain colours' : ''}.</p>}
41
41
  {installed.length > 0 && <>
42
42
  <div class="panel-section-head"><p class="section-title">Installed for every deck</p></div>
43
43
  <ol class="outline-list">{installed.map(row)}</ol>
44
44
  </>}
45
- <OnlinePacks key="online" kind={kind} owner={owner} onInstall={onInstall} />
46
- <div class="panel-section-head"><p class="section-title">Built in, to start from</p></div>
45
+ <div class="panel-section-head"><p class="section-title">Come with mdeck</p></div>
47
46
  <ol class="outline-list">{builtIn.map(row)}</ol>
47
+ <OnlinePackages key="online" kind={kind} onInstall={onInstall} />
48
48
  </aside>
49
49
  }
50
50
 
51
- // Packs from the theme repository with this kind of extension, loaded on
52
- // request since it needs the network. Installing puts a pack beside the deck.
53
- function OnlinePacks({ kind, owner, onInstall }) {
51
+ // Themes or palettes not available yet: from the theme repository, and
52
+ // built-in ones that were removed. Loaded on request, since it needs the
53
+ // network. Installing makes one available to every deck; a theme brings its
54
+ // default palette along.
55
+ function OnlinePackages({ kind, onInstall }) {
54
56
  const [state, setState] = useState({ status: 'idle' })
55
57
  const [busy, setBusy] = useState(null)
56
58
  const load = () => {
57
- setState({ status: 'loading' })
58
- loadThemeIndex().then(index => setState({ status: 'ready', index }), error => setState({ status: 'error', error: error.message }))
59
+ setState(prev => ({ ...prev, status: prev.catalogue ? 'ready' : 'loading' }))
60
+ loadCatalogue().then(catalogue => setState({ status: 'ready', catalogue }), error => setState({ status: 'error', error: error.message }))
59
61
  }
60
- const install = async pack => {
61
- setBusy(pack.id)
62
- try {
63
- await onInstall(pack, kind)
64
- setState(prev => ({ ...prev, index: { ...prev.index, installed: { ...prev.index.installed, [pack.id]: { version: pack.version } } } }))
65
- } catch (error) { setState(prev => ({ ...prev, error: error.message })) }
62
+ const install = async entry => {
63
+ setBusy(entry.id)
64
+ try { await onInstall(entry.kind, entry.id); load() } catch (error) { setState(prev => ({ ...prev, error: error.message })) }
66
65
  setBusy(null)
67
66
  }
68
- const head = <div class="panel-section-head"><p class="section-title">From the theme repository</p></div>
67
+ const head = <div class="panel-section-head"><p class="section-title">More to install</p></div>
69
68
  if (state.status !== 'ready') return <>{head}<div class="panel-hint">
70
- {state.status === 'loading' ? 'Loading…' : <button class="btn is-small" onClick={load}>Show what others made</button>}
69
+ {state.status === 'loading' ? 'Loading…' : <button class="btn is-small" onClick={load}>Show what you can install</button>}
71
70
  {state.error && <p class="online-error">{state.error}</p>}
72
71
  </div></>
73
- const { index } = state
74
- const packs = index.packs.filter(pack => pack[`${kind}s`].length)
72
+ const { catalogue } = state
73
+ const entries = catalogue.entries.filter(entry => entry.kind === kind && !entry.available)
75
74
  return <>{head}
76
75
  {state.error && <p class="panel-hint online-error">{state.error}</p>}
77
- {!packs.length && <p class="panel-hint">No {kind}s there yet.</p>}
78
- <ol class="outline-list">{packs.map(pack => {
79
- const id = pack[`${kind}s`][0]
80
- const have = index.installed?.[pack.id]
81
- return <li key={pack.id} class="outline-item has-thumb online-pack">
82
- {pack.previews?.[id] && <div class="thumb"><img src={new URL(pack.previews[id], index.url).href} alt="" loading="lazy" /></div>}
83
- <span><span class="title" style={{ display: 'block' }}>{pack.title} <span class="muted">{pack.version}</span></span>
84
- <span class="online-meta">{[...pack.themes, ...pack.palettes].join(', ')} · {pack.author}</span></span>
85
- {have
86
- ? <span class="muted">{`Installed${have.version !== pack.version ? ` (${have.version})` : ''} in ${owner === "This folder's" ? 'this folder' : 'this deck'}`}</span>
87
- : <button class="btn is-small" disabled={busy === pack.id} onClick={() => install(pack)}>{busy === pack.id ? 'Installing…' : 'Install'}</button>}
88
- </li>
89
- })}</ol>
76
+ {catalogue.offline && <p class="panel-hint">The theme repository could not be reached; showing what comes with mdeck.</p>}
77
+ {!entries.length && <p class="panel-hint">Every {kind} there is, is installed.</p>}
78
+ <ol class="outline-list">{entries.map(entry => <li key={entry.id} class="outline-item has-thumb online-pack">
79
+ {entry.preview && <div class="thumb"><img src={entry.preview} alt="" loading="lazy" /></div>}
80
+ <span><span class="title" style={{ display: 'block' }}>{entry.title} <span class="muted">{entry.version}</span></span>
81
+ <span class="online-meta">{entry.removed ? 'comes with mdeck, removed' : entry.author}{kind === 'theme' ? ` · with ${entry.palette}` : entry.theme ? ` · for ${entry.theme}` : ''}</span></span>
82
+ <button class="btn is-small" disabled={busy === entry.id} onClick={() => install(entry)}>{busy === entry.id ? 'Installing…' : 'Install'}</button>
83
+ </li>)}</ol>
90
84
  </>
91
85
  }
92
86
 
@@ -4,11 +4,15 @@ export const DESIGN_URL = '/design.html'
4
4
  export const DESIGN_TAB = 'mdeck-design'
5
5
 
6
6
  // `copy` starts a copy of a built-in extension to change; `open` opens one of
7
- // the deck's own. Both are `kind:id`.
7
+ // the deck's own. Both are `kind:id`. The launch page's address, if this
8
+ // editor came from it, is passed on.
8
9
  export function designUrl({ copy = null, open = null } = {}) {
9
10
  const query = new URLSearchParams()
10
11
  if (copy) query.set('copy', copy)
11
12
  if (open) query.set('open', open)
13
+ // A new tab does not share this one's memory: the way back goes along.
14
+ const home = typeof location === 'undefined' ? null : launchPageUrl()
15
+ if (home) query.set('home', home)
12
16
  return query.size ? `${DESIGN_URL}?${query}` : DESIGN_URL
13
17
  }
14
18
 
@@ -17,3 +21,16 @@ export function parseTarget(value) {
17
21
  const match = /^(theme|palette|layout):([a-z][a-z0-9-]*)$/.exec(value ?? '')
18
22
  return match ? { kind: match[1], id: match[2] } : null
19
23
  }
24
+
25
+ // The launch page of `mdeck run` that opened this editor, if one did: it
26
+ // passes its address as ?home=…, kept for this tab so that switching
27
+ // between the editor and the design page keeps the way back.
28
+ const HOME_KEY = 'mdeck-launch-page'
29
+ export function launchPageUrl() {
30
+ let home = new URLSearchParams(location.search).get('home')
31
+ try {
32
+ if (home) sessionStorage.setItem(HOME_KEY, home)
33
+ else home = sessionStorage.getItem(HOME_KEY)
34
+ } catch {}
35
+ try { return home && /^https?:$/.test(new URL(home).protocol) ? home : null } catch { return null }
36
+ }
@@ -2,7 +2,9 @@
2
2
  // ordinary amounts. The design page previews themes and palettes on it, and
3
3
  // `npm run test:themes` checks every theme against it.
4
4
 
5
- export const SAMPLE_PICTURE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300"><rect width="400" height="300" fill="#6b7f99"/><circle cx="140" cy="150" r="80" fill="#e0c068"/><rect x="220" y="80" width="120" height="140" fill="#2d3e50"/></svg>'
5
+ // A drawing in the palette's colours (with plain colours elsewhere): a chart
6
+ // over hills under a sun, for every layout that shows a picture.
7
+ export const SAMPLE_PICTURE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 500"><rect width="800" height="500" fill="var(--surface, #eef1f4)"/><g stroke="var(--rule, #d5dbe1)" stroke-width="2"><path d="M0 100H800M0 200H800M0 300H800M0 400H800M100 0V500M200 0V500M300 0V500M400 0V500M500 0V500M600 0V500M700 0V500"/></g><circle cx="580" cy="165" r="128" fill="none" stroke="var(--accent, #3d5a73)" stroke-width="4" opacity="0.35"/><circle cx="580" cy="165" r="88" fill="var(--accent, #3d5a73)"/><path d="M0 360C140 292 262 300 384 338S628 424 800 330V500H0Z" fill="var(--accent-2, #c2410c)"/><path d="M0 418C168 366 330 392 470 422S690 474 800 434V500H0Z" fill="var(--accent, #3d5a73)" opacity="0.6"/><polyline points="80,262 170,214 260,236 350,170 440,196" fill="none" stroke="var(--ink, #1b2733)" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/><g fill="var(--bg, #ffffff)" stroke="var(--ink, #1b2733)" stroke-width="5"><circle cx="80" cy="262" r="10"/><circle cx="170" cy="214" r="10"/><circle cx="260" cy="236" r="10"/><circle cx="350" cy="170" r="10"/><circle cx="440" cy="196" r="10"/></g></svg>'
6
8
 
7
9
  // `picture` is where the slides find the picture: a file beside the deck, or
8
10
  // a data URL where there is no such file.
@@ -6,6 +6,7 @@
6
6
  // one is available to inverted slides as --inverse-bg, --inverse-ink, …
7
7
  // Theme tokens (type, spacing, fonts) and theme parameters come on top.
8
8
  import { tokensToCss, COLOR_ROLES, APPEARANCES, palettesFor } from './tokens.js'
9
+ import { movedHint } from './moved.js'
9
10
 
10
11
  // Code highlighting and logos on dark slides, unless the palette says otherwise.
11
12
  export const DARK_TOKENS = {
@@ -34,7 +35,7 @@ export function resolvePalette({ theme, palettes = {}, palette: requested, appea
34
35
  const warnings = []
35
36
  const offered = palettesFor(theme, palettes)
36
37
  let palette = requested ? palettes[requested] : null
37
- if (requested && !palette) warnings.push(`Unknown palette "${requested}". Available: ${offered.map(p => p.id).join(', ')}`)
38
+ if (requested && !palette) warnings.push(movedHint('palette', requested) ?? `Unknown palette "${requested}". Available: ${offered.map(p => p.id).join(', ')}`)
38
39
  else if (palette && !offered.includes(palette)) { warnings.push(`The theme "${theme.id}" does not offer the palette "${requested}". Available: ${offered.map(p => p.id).join(', ')}`); palette = null }
39
40
  palette ??= palettes[theme.palette] ?? offered[0] ?? null
40
41
  let appearance = requestedAppearance || theme.appearance || 'light'
@@ -1,7 +1,7 @@
1
1
  // One discovery layer for layouts, themes and palettes. The same registry
2
2
  // backs `mdeck check`, CLI listings, the dev server and builds.
3
3
  import { palettesFor } from './tokens.js'
4
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
4
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
5
5
  import { resolve, dirname, relative } from 'node:path'
6
6
  import { homedir } from 'node:os'
7
7
  import { MANIFEST_FILENAME, ManifestError, KINDS, parseManifestText, validateManifest } from './manifest.js'
@@ -9,18 +9,42 @@ import { builtinExtensionsRoot } from '../paths.js'
9
9
 
10
10
  const SKIP = name => name.startsWith('.') || name === 'node_modules'
11
11
 
12
+ // mdeck's own folder for this user: ~/.mdeck, or $MDECK_HOME.
13
+ export function mdeckHome() {
14
+ return process.env.MDECK_HOME || resolve(homedir(), '.mdeck')
15
+ }
16
+
12
17
  // Extensions installed for one user (`mdeck themes install --global`), for
13
- // every deck: ~/.mdeck/extensions, or $MDECK_HOME/extensions.
18
+ // every deck: ~/.mdeck/extensions.
14
19
  export function userExtensionsDir() {
15
- return resolve(process.env.MDECK_HOME || resolve(homedir(), '.mdeck'), 'extensions')
20
+ return resolve(mdeckHome(), 'extensions')
21
+ }
22
+
23
+ // The built-in themes and palettes this user removed (`mdeck themes remove
24
+ // aurora`): they stay in mdeck's folder but are not offered. Kept as
25
+ // "theme:aurora" and "palette:neon".
26
+ const removedFile = () => resolve(mdeckHome(), 'removed.json')
27
+ export function removedBuiltIns() {
28
+ let saved = {}
29
+ try { saved = JSON.parse(readFileSync(removedFile(), 'utf8')) } catch {}
30
+ return (Array.isArray(saved.hidden) ? saved.hidden : []).filter(key => /^(theme|palette):[a-z][a-z0-9-]*$/.test(key))
31
+ }
32
+ export function setRemovedBuiltIns(keys) {
33
+ mkdirSync(mdeckHome(), { recursive: true })
34
+ writeFileSync(removedFile(), JSON.stringify({ hidden: [...new Set(keys)].sort() }, null, 2) + '\n')
16
35
  }
17
36
 
18
- // Built-in, then the user's, then the deck's own: a deck's extension replaces
19
- // a user one with the same id.
37
+ // mdeck's own layouts, themes and palettes, then the user's, then the deck's
38
+ // own. For themes and palettes a later place replaces an earlier one with the
39
+ // same id: an installed newer version replaces the built-in one, and a
40
+ // deck's copy replaces both. Built-in themes and palettes this user removed
41
+ // are left out. `userRoot: null` leaves out what this user installed and
42
+ // removed, for results that must not depend on the computer (checks, the
43
+ // repository).
20
44
  export function extensionRoots(slidesPath, { builtinRoot = builtinExtensionsRoot, userRoot = userExtensionsDir() } = {}) {
21
45
  const deckDir = dirname(resolve(slidesPath))
22
46
  return [
23
- { dir: builtinRoot, source: 'built-in' },
47
+ { dir: builtinRoot, source: 'built-in', hidden: userRoot ? removedBuiltIns() : [] },
24
48
  ...(userRoot ? [{ dir: userRoot, source: 'user' }] : []),
25
49
  { dir: resolve(deckDir, 'extensions'), source: 'local' },
26
50
  ]
@@ -57,12 +81,16 @@ function loadManifest({ dir, file, folderName }) {
57
81
  export function discoverExtensions(roots) {
58
82
  const registry = { layouts: {}, themes: {}, palettes: {}, records: [], warnings: [] }
59
83
  const byKey = new Map()
84
+ const RANK = { 'built-in': 0, user: 1, local: 2 }
60
85
  const add = (record, source) => {
61
86
  const key = `${record.kind}:${record.id}`
62
87
  const existing = byKey.get(key)
63
88
  const entry = { ...record, source }
64
- if (existing?.source === 'user' && source === 'local') {
65
- registry.warnings.push(`The ${record.kind} "${record.id}" in this deck's extensions replaces the one installed in ${dirname(existing.dir)}`)
89
+ // Themes and palettes may be replaced from a later place; layouts and two
90
+ // with one id in the same place may not.
91
+ const replaces = existing && record.kind !== 'layout' && RANK[source] > RANK[existing.source]
92
+ if (replaces) {
93
+ if (existing.source === 'user') registry.warnings.push(`The ${record.kind} "${record.id}" in this deck's extensions replaces the one installed in ${dirname(existing.dir)}`)
66
94
  registry.records.splice(registry.records.indexOf(existing), 1)
67
95
  } else if (existing) throw new ManifestError(`Duplicate ${record.kind} "${record.id}" is also defined in ${existing.file}. Extension IDs must be unique within their kind.`, { file: record.file })
68
96
  byKey.set(key, entry)
@@ -71,8 +99,13 @@ export function discoverExtensions(roots) {
71
99
  }
72
100
  for (const root of roots) {
73
101
  if (!existsSync(root.dir) || !statSync(root.dir).isDirectory()) continue
74
- for (const found of walk(root.dir)) add(loadManifest(found), root.source)
102
+ for (const found of walk(root.dir)) {
103
+ const record = loadManifest(found)
104
+ if (!root.hidden?.includes(`${record.kind}:${record.id}`)) add(record, root.source)
105
+ }
75
106
  }
107
+ // Themes and palettes in name order, wherever they come from.
108
+ for (const kind of ['themes', 'palettes']) registry[kind] = Object.fromEntries(Object.entries(registry[kind]).sort(([a], [b]) => a.localeCompare(b, 'en')))
76
109
  checkPaletteReferences(registry)
77
110
  return registry
78
111
  }
@@ -96,7 +129,7 @@ function checkPaletteReferences(registry) {
96
129
  }
97
130
  }
98
131
 
99
- // The extensions that come with mdeck, alone.
132
+ // The extensions that come with mdeck, alone, removed or not.
100
133
  export function builtInRegistry() {
101
134
  return discoverExtensions([{ dir: builtinExtensionsRoot, source: 'built-in' }])
102
135
  }
@@ -118,7 +151,7 @@ export function serializeRegistry(registry, { relativeTo } = {}) {
118
151
  const out = { schema: 2, warnings: registry.warnings }
119
152
  for (const kind of KINDS) {
120
153
  out[`${kind}s`] = Object.values(registry[`${kind}s`]).map(record => ({
121
- ...record.manifest, kind, source: record.source,
154
+ ...record.manifest, kind, source: record.source, ...(record.package?.version ? { version: record.package.version } : {}),
122
155
  file: relativeTo ? relative(relativeTo, record.file) : record.file,
123
156
  }))
124
157
  }
@@ -0,0 +1,88 @@
1
+ // Where themes and palettes are installed: for every deck (the user's
2
+ // folder, where the built-in ones are offered too) or beside one deck.
3
+ // Shared by mdeck themes / mdeck palettes and the design page.
4
+ import { existsSync, rmSync, statSync } from 'node:fs'
5
+ import { dirname, resolve } from 'node:path'
6
+ import { loadRegistry, mdeckHome, removedBuiltIns, setRemovedBuiltIns, userExtensionsDir } from './discover.js'
7
+ import { installedPackages, installPackage, keyOf, obtainPackage, PackageError, removePackage, satisfies, withDependencies } from './packages.js'
8
+
9
+ const fail = message => { throw new PackageError(message) }
10
+
11
+ // Where a command works: for every deck (the user's folder), or beside a
12
+ // deck when one is named or --local is given.
13
+ export function target(where, local) {
14
+ if (!where && !local) return { user: true, root: userExtensionsDir(), deck: resolve(mdeckHome(), 'no-deck', 'slides.md'), label: 'for every deck' }
15
+ const given = resolve(where ?? '.')
16
+ if (!existsSync(given) && !/\.md$/i.test(given)) fail(`${where} does not exist; name a slides file or the folder it is in`)
17
+ const folder = existsSync(given) && statSync(given).isDirectory() ? given : dirname(given)
18
+ return { user: false, root: resolve(folder, 'extensions'), deck: existsSync(given) && !statSync(given).isDirectory() ? given : resolve(folder, 'slides.md'), label: `in ${resolve(folder, 'extensions')}` }
19
+ }
20
+
21
+ // The themes and palettes `where` sees, as a registry: for every deck what
22
+ // this user has, beside a deck also what that deck has.
23
+ const registryAt = where => loadRegistry(where.deck)
24
+
25
+ // Installs a theme or palette with what it needs. What `where` has already
26
+ // stays as it is. A built-in one is not copied: for every deck it is offered
27
+ // again if it was removed. A theme and its own palette may need each other,
28
+ // so the registry is checked before anything is written and once all is
29
+ // there; if anything fails, what this call installed is taken out again.
30
+ export async function installHere(catalogue, kind, id, where, { mdeckVersion, force = false }) {
31
+ const before = registryAt(where)
32
+ const plan = withDependencies(catalogue, kind, id)
33
+ const done = [], written = []
34
+ try {
35
+ for (const entry of plan) {
36
+ const key = keyOf(entry.kind, entry.id)
37
+ const requested = entry.kind === kind && entry.id === id
38
+ if (entry.builtIn) {
39
+ const hidden = removedBuiltIns()
40
+ const wasRemoved = hidden.includes(key)
41
+ if (wasRemoved) setRemovedBuiltIns(hidden.filter(other => other !== key))
42
+ done.push({ kind: entry.kind, id: entry.id, version: entry.version, builtIn: true, wasRemoved, requested })
43
+ continue
44
+ }
45
+ const have = installedPackages(where.root)[key]
46
+ // Beside a deck, what is installed only for every deck is copied too,
47
+ // so the slide folder carries it.
48
+ const there = before[`${entry.kind}s`][entry.id]
49
+ if (!requested && (have || (there && (where.user || there.source !== 'user')))) { done.push({ kind: entry.kind, id: entry.id, version: have?.version ?? null, kept: true, requested }); continue }
50
+ if (!satisfies(entry.mdeck, mdeckVersion)) fail(`The ${entry.kind} ${entry.id} ${entry.version} needs mdeck ${entry.mdeck}; this is ${mdeckVersion}. Update mdeck first: npm install -g mdeck`)
51
+ const { bundle, url, digest } = await obtainPackage(catalogue, entry)
52
+ const result = installPackage(bundle, { root: where.root, url, digest, force })
53
+ if (!result.replaced) written.push(result.dir)
54
+ done.push({ kind: entry.kind, id: entry.id, version: entry.version, replaced: result.replaced, requested })
55
+ }
56
+ registryAt(where)
57
+ } catch (error) {
58
+ for (const dir of written) rmSync(dir, { recursive: true, force: true })
59
+ throw error
60
+ }
61
+ return done
62
+ }
63
+
64
+ // Removes a theme or palette. A built-in one is hidden instead, for every
65
+ // deck; installing it brings it back. A palette a theme here uses by default
66
+ // stays; a theme takes the palettes that belong only to it along; the last
67
+ // theme stays.
68
+ export function removeHere(kind, id, where, { force = false } = {}) {
69
+ const registry = registryAt(where)
70
+ const record = registry[`${kind}s`][id]
71
+ const installed = installedPackages(where.root)
72
+ const key = keyOf(kind, id)
73
+ if (!installed[key] && !(where.user && record?.source === 'built-in')) fail(`The ${kind} ${id} is not installed ${where.label}`)
74
+ if (kind === 'palette') {
75
+ const users = Object.values(registry.themes).filter(theme => theme.manifest.palette === id).map(theme => theme.id)
76
+ if (users.length) fail(`${users.join(', ')} ${users.length > 1 ? 'use' : 'uses'} the palette ${id} by default; remove ${users.length > 1 ? 'those themes' : 'that theme'} first`)
77
+ }
78
+ if (kind === 'theme' && Object.keys(registry.themes).length === 1) fail(`${id} is the last theme ${where.label}; install another theme first (mdeck themes search lists them)`)
79
+ const removed = []
80
+ const take = (k, i) => {
81
+ const r = registry[`${k}s`][i]
82
+ if (installedPackages(where.root)[keyOf(k, i)]) { removePackage(k, i, { root: where.root, force }); removed.push({ kind: k, id: i }) }
83
+ else if (where.user && r?.source === 'built-in') { setRemovedBuiltIns([...removedBuiltIns(), keyOf(k, i)]); removed.push({ kind: k, id: i, hidden: true }) }
84
+ }
85
+ take(kind, id)
86
+ if (kind === 'theme') for (const palette of Object.values(registry.palettes)) if (palette.manifest.theme === id) take('palette', palette.id)
87
+ return removed
88
+ }
@@ -15,11 +15,16 @@ export const MANIFEST_FILENAME = 'extension.toml'
15
15
  const FRAMES = ['standard', 'title', 'chapter', 'none']
16
16
  const PROPERTY_TYPES = ['string', 'number', 'integer', 'boolean', 'array', 'object']
17
17
  const SHARED_KEYS = ['schema', 'kind', 'id', 'title', 'description']
18
+ // A theme or palette is also a package that can be installed: its version,
19
+ // who made it, its licence, a web page and the oldest mdeck it works with.
20
+ const PACKAGE_KEYS = ['version', 'author', 'license', 'homepage', 'mdeck']
18
21
  const KEYS = {
19
22
  layout: [...SHARED_KEYS, 'frame', 'files', 'regions', 'properties'],
20
- theme: [...SHARED_KEYS, 'palette', 'palettes', 'appearance', 'fonts', 'files', 'tokens', 'params'],
21
- palette: [...SHARED_KEYS, 'theme', 'light', 'dark'],
23
+ theme: [...SHARED_KEYS, ...PACKAGE_KEYS, 'palette', 'palettes', 'appearance', 'fonts', 'files', 'tokens', 'params'],
24
+ palette: [...SHARED_KEYS, ...PACKAGE_KEYS, 'theme', 'light', 'dark'],
22
25
  }
26
+ export const VERSION_RE = /^\d+\.\d+\.\d+$/
27
+ export const RANGE_RE = /^>=\s*(\d+\.\d+\.\d+)$/
23
28
  const SCHEMA_KEYS = ['type', 'title', 'description', 'enum', 'required', 'default', 'minimum', 'maximum', 'minItems', 'maxItems', 'items']
24
29
  const RESERVED_NAMES = ['constructor', 'prototype', '__proto__']
25
30
 
@@ -72,7 +77,19 @@ export function validateManifest(raw, { file, dir, folderName, fileExists = exis
72
77
  const manifest = kind === 'layout' ? validateLayout(raw, shared, fail)
73
78
  : kind === 'theme' ? validateTheme(raw, shared, fail)
74
79
  : validatePalette(raw, shared, fail)
75
- return { kind, id: raw.id, title: shared.title, description: shared.description, dir, file, manifest, files }
80
+ const pkg = kind === 'layout' ? null : validatePackage(raw, fail)
81
+ return { kind, id: raw.id, title: shared.title, description: shared.description, dir, file, manifest, files, ...(pkg ? { package: pkg } : {}) }
82
+ }
83
+
84
+ // The package settings, all optional for a theme or palette of one's own;
85
+ // the theme repository requires version, author and license.
86
+ function validatePackage(raw, fail) {
87
+ const pkg = {}
88
+ if (raw.version != null) { if (typeof raw.version !== 'string' || !VERSION_RE.test(raw.version)) fail('version must look like 1.0.0', 'version'); pkg.version = raw.version }
89
+ for (const key of ['author', 'license']) if (raw[key] != null) { if (typeof raw[key] !== 'string' || !raw[key].trim()) fail(`${key} must be text`, key); pkg[key] = raw[key].trim() }
90
+ if (raw.homepage != null) { if (typeof raw.homepage !== 'string' || !/^https:\/\//.test(raw.homepage)) fail('homepage must be an https:// address', 'homepage'); pkg.homepage = raw.homepage }
91
+ if (raw.mdeck != null) { if (typeof raw.mdeck !== 'string' || !RANGE_RE.test(raw.mdeck)) fail('mdeck must look like ">=3.1.0"', 'mdeck'); pkg.mdeck = raw.mdeck }
92
+ return pkg
76
93
  }
77
94
 
78
95
  const FILE_KEYS = { layout: ['layout', 'styles', 'starter'], theme: ['styles'], palette: [] }
@@ -0,0 +1,20 @@
1
+ // Themes and palettes that came with mdeck before 3.0 and now live in the
2
+ // theme repository. A deck that names one is told how to install it instead
3
+ // of being told the name is unknown.
4
+ export const MOVED = {
5
+ themes: ['duet', 'editorial', 'fhnw', 'terminal', 'sketch'],
6
+ palettes: ['cobalt', 'terra', 'brand', 'phosphor', 'pastel', 'forest'],
7
+ }
8
+
9
+ // Palettes that were removed because another one looks almost the same, by
10
+ // the one to use instead.
11
+ export const REPLACED = { palettes: { graphite: 'nordic', ember: 'paper' } }
12
+
13
+ // "The theme "duet" is no longer built into mdeck; install it with: mdeck
14
+ // themes install duet", what to use instead of a removed one, or null.
15
+ // Installing goes to every deck by default.
16
+ export function movedHint(kind, id) {
17
+ const instead = REPLACED[`${kind}s`]?.[id]
18
+ if (instead) return `The ${kind} "${id}" was removed from mdeck; use ${kind}: ${instead}, which looks almost the same`
19
+ return MOVED[`${kind}s`]?.includes(id) ? `The ${kind} "${id}" is no longer built into mdeck; install it with: mdeck ${kind}s install ${id}` : null
20
+ }