mdeck 2.3.0 → 3.0.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 (108) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/README.md +9 -9
  3. package/assets/base.css +4 -1
  4. package/assets/packs/academic/pack.toml +8 -0
  5. package/assets/packs/aurora/pack.toml +8 -0
  6. package/assets/packs/lagoon/pack.toml +8 -0
  7. package/assets/packs/minimal/minimal/extension.toml +45 -0
  8. package/assets/packs/minimal/minimal/styles.css +342 -0
  9. package/assets/packs/minimal/pack.toml +8 -0
  10. package/assets/packs/minimal/paper/extension.toml +27 -0
  11. package/assets/packs/neue/pack.toml +8 -0
  12. package/docs/reference/authoring.md +13 -18
  13. package/docs/reference/claude-skill.md +1 -1
  14. package/docs/reference/extensions.md +29 -20
  15. package/docs/reference/palettes.md +8 -12
  16. package/docs/reference/themes.md +8 -7
  17. package/docs/site/content/appearance.md +33 -49
  18. package/docs/site/content/commands.md +2 -2
  19. package/docs/site/content/theme-authoring.md +1 -1
  20. package/docs/site/content/theme-repository.md +19 -15
  21. package/docs/site/examples/first-talk.md +1 -1
  22. package/docs/site/images/themes/academic-dark.webp +0 -0
  23. package/docs/site/images/themes/academic-light.webp +0 -0
  24. package/docs/site/images/themes/academic.webp +0 -0
  25. package/docs/site/images/themes/aurora-dark.webp +0 -0
  26. package/docs/site/images/themes/aurora-light.webp +0 -0
  27. package/docs/site/images/themes/aurora.webp +0 -0
  28. package/docs/site/images/themes/minimal-dark.webp +0 -0
  29. package/docs/site/images/themes/minimal-light.webp +0 -0
  30. package/docs/site/images/themes/minimal.webp +0 -0
  31. package/docs/site/images/themes/neue-dark.webp +0 -0
  32. package/docs/site/images/themes/neue-light.webp +0 -0
  33. package/docs/site/images/themes/neue.webp +0 -0
  34. package/docs/site/pages.js +1 -1
  35. package/examples/custom-layouts/slides.md +1 -1
  36. package/examples/poll/slides.md +1 -1
  37. package/examples/python/slides.md +2 -2
  38. package/examples/showcase/slides.md +3 -3
  39. package/package.json +1 -1
  40. package/skills/write-slides/SKILL.md +2 -2
  41. package/src/build/check.js +0 -7
  42. package/src/build/editorPlugin.js +19 -11
  43. package/src/build/themeCheck.js +15 -5
  44. package/src/cli/args.js +1 -1
  45. package/src/cli/index.js +3 -3
  46. package/src/cli/themes.js +93 -64
  47. package/src/core/validateDeck.js +4 -2
  48. package/src/editor/api.js +9 -1
  49. package/src/editor/components/App.jsx +4 -2
  50. package/src/editor/components/DesignApp.jsx +12 -3
  51. package/src/editor/components/ExtensionEditor.jsx +15 -4
  52. package/src/editor/components/ExtensionsPanel.jsx +19 -21
  53. package/src/editor/designLink.js +18 -1
  54. package/src/editor/sampleDeck.js +3 -1
  55. package/src/extensions/appearance.js +2 -1
  56. package/src/extensions/discover.js +56 -16
  57. package/src/extensions/installed.js +63 -0
  58. package/src/extensions/moved.js +13 -0
  59. package/src/extensions/packs.js +135 -46
  60. package/src/home/Home.jsx +12 -6
  61. package/src/layouts/layoutApi.jsx +10 -2
  62. package/src/paths.js +2 -0
  63. package/src/runtime/themeLoader.js +5 -2
  64. package/assets/extensions/palettes/brand/extension.toml +0 -29
  65. package/assets/extensions/palettes/cobalt/extension.toml +0 -27
  66. package/assets/extensions/palettes/ember/extension.toml +0 -27
  67. package/assets/extensions/palettes/forest/extension.toml +0 -27
  68. package/assets/extensions/palettes/graphite/extension.toml +0 -27
  69. package/assets/extensions/palettes/pastel/extension.toml +0 -30
  70. package/assets/extensions/palettes/phosphor/extension.toml +0 -27
  71. package/assets/extensions/palettes/terra/extension.toml +0 -27
  72. package/assets/extensions/themes/duet/extension.toml +0 -46
  73. package/assets/extensions/themes/duet/styles.css +0 -573
  74. package/assets/extensions/themes/editorial/extension.toml +0 -46
  75. package/assets/extensions/themes/editorial/styles.css +0 -574
  76. package/assets/extensions/themes/fhnw/extension.toml +0 -61
  77. package/assets/extensions/themes/fhnw/styles.css +0 -604
  78. package/assets/extensions/themes/sketch/extension.toml +0 -48
  79. package/assets/extensions/themes/sketch/styles.css +0 -633
  80. package/assets/extensions/themes/terminal/extension.toml +0 -38
  81. package/assets/extensions/themes/terminal/styles.css +0 -550
  82. package/docs/site/images/themes/duet-dark.webp +0 -0
  83. package/docs/site/images/themes/duet-light.webp +0 -0
  84. package/docs/site/images/themes/duet.webp +0 -0
  85. package/docs/site/images/themes/editorial-dark.webp +0 -0
  86. package/docs/site/images/themes/editorial-light.webp +0 -0
  87. package/docs/site/images/themes/editorial.webp +0 -0
  88. package/docs/site/images/themes/fhnw.webp +0 -0
  89. package/docs/site/images/themes/sketch-dark.webp +0 -0
  90. package/docs/site/images/themes/sketch-light.webp +0 -0
  91. package/docs/site/images/themes/sketch.webp +0 -0
  92. package/docs/site/images/themes/terminal-dark.webp +0 -0
  93. package/docs/site/images/themes/terminal-light.webp +0 -0
  94. package/docs/site/images/themes/terminal.webp +0 -0
  95. package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T08-36-47Z.json +0 -19
  96. package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T11-27-30Z.json +0 -6
  97. package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T08-08-30Z.json +0 -10
  98. package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T11-41-35Z.json +0 -13
  99. /package/assets/{extensions/themes → packs/academic}/academic/extension.toml +0 -0
  100. /package/assets/{extensions/themes → packs/academic}/academic/styles.css +0 -0
  101. /package/assets/{extensions/palettes → packs/academic}/nordic/extension.toml +0 -0
  102. /package/assets/{extensions/themes → packs/aurora}/aurora/extension.toml +0 -0
  103. /package/assets/{extensions/themes → packs/aurora}/aurora/styles.css +0 -0
  104. /package/assets/{extensions/palettes → packs/aurora}/neon/extension.toml +0 -0
  105. /package/assets/{extensions/palettes → packs/lagoon}/lagoon/extension.toml +0 -0
  106. /package/assets/{extensions/themes → packs/neue}/neue/extension.toml +0 -0
  107. /package/assets/{extensions/themes → packs/neue}/neue/styles.css +0 -0
  108. /package/assets/{extensions/palettes → packs/neue}/swiss/extension.toml +0 -0
@@ -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,26 +1,55 @@
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'
8
- import { builtinExtensionsRoot } from '../paths.js'
8
+ import { builtinExtensionsRoot, bundledPacksRoot } 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 bundled packs this user removed (`mdeck themes remove <pack> --global`):
24
+ // they stay in mdeck's folder but are not offered.
25
+ const removedFile = () => resolve(mdeckHome(), 'removed.json')
26
+ export function removedPacks() {
27
+ try { const packs = JSON.parse(readFileSync(removedFile(), 'utf8')).packs; return Array.isArray(packs) ? packs : [] } catch { return [] }
28
+ }
29
+ export function setRemovedPacks(packs) {
30
+ mkdirSync(mdeckHome(), { recursive: true })
31
+ writeFileSync(removedFile(), JSON.stringify({ packs: [...new Set(packs)].sort() }, null, 2) + '\n')
32
+ }
33
+
34
+ // The packs that come with mdeck: { id: folder }, all or only those offered
35
+ // (not `hidden`, by default those this user removed).
36
+ export function bundledPacks({ root = bundledPacksRoot, all = false, hidden = all ? [] : removedPacks() } = {}) {
37
+ if (!existsSync(root)) return {}
38
+ const removed = hidden
39
+ return Object.fromEntries(sortedEntries(root).map(entry => entry.name).filter(id => !removed.includes(id)).map(id => [id, resolve(root, id)]))
16
40
  }
17
41
 
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.
20
- export function extensionRoots(slidesPath, { builtinRoot = builtinExtensionsRoot, userRoot = userExtensionsDir() } = {}) {
42
+ // Built-in layouts and the bundled packs, then the user's, then the deck's
43
+ // own. For themes and palettes a later place replaces an earlier one with the
44
+ // same id: an installed newer version replaces the bundled one, and a deck's
45
+ // copy replaces both.
46
+ // `userRoot: null` leaves out what this user installed and removed, for
47
+ // results that must not depend on the computer (checks, the repository).
48
+ export function extensionRoots(slidesPath, { builtinRoot = builtinExtensionsRoot, packsRoot = bundledPacksRoot, userRoot = userExtensionsDir() } = {}) {
21
49
  const deckDir = dirname(resolve(slidesPath))
22
50
  return [
23
51
  { dir: builtinRoot, source: 'built-in' },
52
+ ...Object.entries(bundledPacks({ root: packsRoot, hidden: userRoot ? removedPacks() : [] })).map(([pack, dir]) => ({ dir, source: 'built-in', pack })),
24
53
  ...(userRoot ? [{ dir: userRoot, source: 'user' }] : []),
25
54
  { dir: resolve(deckDir, 'extensions'), source: 'local' },
26
55
  ]
@@ -51,18 +80,26 @@ function readLayout(record) {
51
80
  function loadManifest({ dir, file, folderName }) {
52
81
  const raw = parseManifestText(readFileSync(file, 'utf8'), file)
53
82
  const record = validateManifest(raw, { file, dir, folderName })
54
- return record.kind === 'layout' ? readLayout(record) : record
83
+ // An installed pack leaves a note in each folder saying which pack it is.
84
+ let pack = null
85
+ try { pack = JSON.parse(readFileSync(resolve(dir, '.mdeck-pack.json'), 'utf8')).pack ?? null } catch {}
86
+ const withPack = pack ? { ...record, pack } : record
87
+ return record.kind === 'layout' ? readLayout(withPack) : withPack
55
88
  }
56
89
 
57
90
  export function discoverExtensions(roots) {
58
91
  const registry = { layouts: {}, themes: {}, palettes: {}, records: [], warnings: [] }
59
92
  const byKey = new Map()
60
- const add = (record, source) => {
93
+ const RANK = { 'built-in': 0, user: 1, local: 2 }
94
+ const add = (record, source, pack) => {
61
95
  const key = `${record.kind}:${record.id}`
62
96
  const existing = byKey.get(key)
63
- 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)}`)
97
+ const entry = { ...record, source, ...(pack ? { pack } : {}) }
98
+ // Themes and palettes may be replaced from a later place; layouts and two
99
+ // with one id in the same place may not.
100
+ const replaces = existing && record.kind !== 'layout' && RANK[source] > RANK[existing.source]
101
+ if (replaces) {
102
+ 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
103
  registry.records.splice(registry.records.indexOf(existing), 1)
67
104
  } 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
105
  byKey.set(key, entry)
@@ -71,8 +108,10 @@ export function discoverExtensions(roots) {
71
108
  }
72
109
  for (const root of roots) {
73
110
  if (!existsSync(root.dir) || !statSync(root.dir).isDirectory()) continue
74
- for (const found of walk(root.dir)) add(loadManifest(found), root.source)
111
+ for (const found of walk(root.dir)) { const record = loadManifest(found); add(record, root.source, root.pack ?? record.pack) }
75
112
  }
113
+ // Themes and palettes in name order, wherever they come from.
114
+ for (const kind of ['themes', 'palettes']) registry[kind] = Object.fromEntries(Object.entries(registry[kind]).sort(([a], [b]) => a.localeCompare(b, 'en')))
76
115
  checkPaletteReferences(registry)
77
116
  return registry
78
117
  }
@@ -96,9 +135,10 @@ function checkPaletteReferences(registry) {
96
135
  }
97
136
  }
98
137
 
99
- // The extensions that come with mdeck, alone.
138
+ // The extensions that come with mdeck, alone: layouts and every bundled pack,
139
+ // removed or not.
100
140
  export function builtInRegistry() {
101
- return discoverExtensions([{ dir: builtinExtensionsRoot, source: 'built-in' }])
141
+ return discoverExtensions([{ dir: builtinExtensionsRoot, source: 'built-in' }, ...Object.entries(bundledPacks({ all: true })).map(([pack, dir]) => ({ dir, source: 'built-in', pack }))])
102
142
  }
103
143
 
104
144
  export function loadRegistry(slidesPath, options) {
@@ -118,7 +158,7 @@ export function serializeRegistry(registry, { relativeTo } = {}) {
118
158
  const out = { schema: 2, warnings: registry.warnings }
119
159
  for (const kind of KINDS) {
120
160
  out[`${kind}s`] = Object.values(registry[`${kind}s`]).map(record => ({
121
- ...record.manifest, kind, source: record.source,
161
+ ...record.manifest, kind, source: record.source, ...(record.pack ? { pack: record.pack } : {}),
122
162
  file: relativeTo ? relative(relativeTo, record.file) : record.file,
123
163
  }))
124
164
  }
@@ -0,0 +1,63 @@
1
+ // Where packs are installed: for every deck (the user's folder, where the
2
+ // bundled packs are offered too) or beside one deck. Shared by mdeck themes
3
+ // and the design page.
4
+ import { existsSync, statSync } from 'node:fs'
5
+ import { dirname, resolve } from 'node:path'
6
+ import { bundledPacks, loadRegistry, mdeckHome, removedPacks, setRemovedPacks, userExtensionsDir } from './discover.js'
7
+ import { installedPacks, installWithRequirements, PackError, removePack } from './packs.js'
8
+
9
+ // Where a command works: for every deck (the user's folder), or beside a
10
+ // deck when one is named or --local is given.
11
+ export function target(where, local) {
12
+ if (!where && !local) return { user: true, root: userExtensionsDir(), deck: resolve('slides.md'), label: 'for every deck' }
13
+ const given = resolve(where ?? '.')
14
+ if (!existsSync(given) && !/\.md$/i.test(given)) throw new PackError(`${where} does not exist; name a slides file or the folder it is in`)
15
+ const folder = existsSync(given) && statSync(given).isDirectory() ? given : dirname(given)
16
+ return { user: false, root: resolve(folder, 'extensions'), deck: existsSync(given) && !statSync(given).isDirectory() ? given : resolve(folder, 'slides.md'), label: `in ${resolve(folder, 'extensions')}` }
17
+ }
18
+
19
+ // The packs that `where` has: installed there, and for the user's folder
20
+ // also the bundled packs that are offered.
21
+ export function packsAt(where) {
22
+ const installed = installedPacks(where.root)
23
+ if (!where.user) return installed
24
+ const offered = Object.fromEntries(Object.keys(bundledPacks()).map(id => [id, { bundled: true, requires: [] }]))
25
+ return { ...offered, ...installed }
26
+ }
27
+
28
+ // The packs at `where` that require `id`.
29
+ export function requiredBy(id, where, catalogue) {
30
+ return Object.entries(packsAt(where)).filter(([other, pack]) => {
31
+ if (other === id) return false
32
+ const requires = pack.bundled ? catalogue.packs.find(p => p.id === other)?.requires ?? [] : pack.requires
33
+ return requires.includes(id)
34
+ }).map(([other]) => other)
35
+ }
36
+
37
+ // Installs a pack with what it requires. For every deck, a bundled pack is
38
+ // offered again instead of copied.
39
+ export async function installHere(catalogue, id, where, { mdeckVersion, force = false }) {
40
+ const removed = removedPacks()
41
+ const has = pack => Object.hasOwn(packsAt(where), pack)
42
+ const bundledHere = where.user ? entry => { setRemovedPacks(removedPacks().filter(other => other !== entry.id)); return true } : null
43
+ const done = await installWithRequirements(catalogue, id, { root: where.root, mdeckVersion, force, has, bundledHere })
44
+ return done.map(step => ({ ...step, wasRemoved: step.bundled && removed.includes(step.id) }))
45
+ }
46
+
47
+ // Removes a pack. For every deck, a bundled pack is hidden (and a newer
48
+ // version installed over it removed too); install brings it back.
49
+ export function removeHere(catalogue, id, where, { force = false } = {}) {
50
+ const needed = requiredBy(id, where, catalogue)
51
+ if (needed.length && !force) throw new PackError(`${needed.join(', ')} ${needed.length > 1 ? 'require' : 'requires'} ${id}; remove ${needed.length > 1 ? 'them' : 'it'} first, or add --force`)
52
+ const installed = installedPacks(where.root)[id]
53
+ const isBundled = Object.hasOwn(bundledPacks({ all: true }), id)
54
+ if (!installed && !(where.user && isBundled)) throw new PackError(`The pack ${id} is not installed ${where.label}`)
55
+ // Every deck needs a theme: the last one stays, even with --force.
56
+ if (where.user) {
57
+ const themes = Object.values(loadRegistry(resolve(mdeckHome(), 'no-deck', 'slides.md')).themes)
58
+ if (themes.length && themes.every(theme => theme.pack === id)) throw new PackError(`${id} has the last theme installed for every deck (${themes.map(theme => theme.id).join(', ')}); install another theme first, for example mdeck themes install neue`)
59
+ }
60
+ const pack = installed ? removePack(id, { root: where.root, force }) : null
61
+ if (where.user && isBundled) setRemovedPacks([...removedPacks(), id])
62
+ return { folders: pack?.folders ?? [], hidden: where.user && isBundled }
63
+ }
@@ -0,0 +1,13 @@
1
+ // Themes and palettes that came with mdeck before 3.0 and now live in the
2
+ // theme repository, by the pack that has them. A deck that names one is told
3
+ // how to install it instead of being told the name is unknown.
4
+ export const MOVED = {
5
+ themes: { duet: 'duet', editorial: 'editorial', fhnw: 'fhnw', terminal: 'terminal', sketch: 'sketch' },
6
+ palettes: { cobalt: 'duet', terra: 'editorial', brand: 'fhnw', phosphor: 'terminal', pastel: 'sketch', ember: 'earth', forest: 'earth', graphite: 'graphite' },
7
+ }
8
+
9
+ // "The theme "duet" is no longer built in: mdeck themes install duet", or null.
10
+ export function movedHint(kind, id) {
11
+ const pack = MOVED[`${kind}s`]?.[id]
12
+ return pack ? `The ${kind} "${id}" is no longer built into mdeck; install it beside the deck with: mdeck themes install ${pack}` : null
13
+ }
@@ -1,8 +1,11 @@
1
- // Packs: themes and palettes shared through the theme repository
2
- // (https://gh.tschieber.de/mdeck-themes/). A pack is a folder with pack.toml
3
- // and one folder per theme or palette; it travels as one JSON file of text
4
- // files, so installing it is checking it and writing those files. Only
5
- // extension.toml and styles.css are allowed: a pack never carries code.
1
+ // Packs: every theme and palette comes in one. A pack is a folder with
2
+ // pack.toml and one folder per theme or palette; it travels as one JSON file
3
+ // of text files, so installing it is checking it and writing those files.
4
+ // Only extension.toml and styles.css are allowed: a pack never carries code.
5
+ // A pack may require others (`requires = ["lagoon"]`) for palettes its
6
+ // themes name. Packs come from two places: the starter set bundled with
7
+ // mdeck (assets/packs), and the theme repository
8
+ // (https://gh.tschieber.de/mdeck-themes/).
6
9
  import { createHash } from 'node:crypto'
7
10
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
8
11
  import { resolve, relative } from 'node:path'
@@ -47,6 +50,7 @@ function checkMeta(meta, where) {
47
50
  if (typeof meta.id !== 'string' || !ID_RE.test(meta.id)) fail(`${where}: id must use lowercase letters, digits and hyphens, starting with a letter`)
48
51
  if (typeof meta.version !== 'string' || !VERSION_RE.test(meta.version)) fail(`${where}: version must look like 1.0.0`)
49
52
  if (meta.mdeck != null && (typeof meta.mdeck !== 'string' || !RANGE_RE.test(meta.mdeck))) fail(`${where}: mdeck must look like ">=2.3.0"`)
53
+ if (meta.requires != null && (!Array.isArray(meta.requires) || meta.requires.some(id => typeof id !== 'string' || !ID_RE.test(id) || id === meta.id))) fail(`${where}: requires must be a list of other packs' ids`)
50
54
  }
51
55
 
52
56
  // pack.toml and the files of each theme or palette in a pack folder.
@@ -75,7 +79,7 @@ export function readPackDir(dir) {
75
79
  // The published form of a pack folder: one JSON text and its checksum.
76
80
  export function bundlePack(dir) {
77
81
  const { meta, files } = readPackDir(dir)
78
- const bundle = { schema: 1, id: meta.id, version: meta.version, ...(meta.mdeck ? { mdeck: meta.mdeck } : {}), files }
82
+ const bundle = { schema: 1, id: meta.id, version: meta.version, ...(meta.mdeck ? { mdeck: meta.mdeck } : {}), ...(meta.requires?.length ? { requires: meta.requires } : {}), files }
79
83
  const text = JSON.stringify(bundle, null, 1) + '\n'
80
84
  return { meta, bundle, text, sha256: sha256(text) }
81
85
  }
@@ -87,10 +91,11 @@ function checkCss(text, where) {
87
91
  }
88
92
  }
89
93
 
90
- // Checks a pack before it is published or installed. `known` is the registry
91
- // it will join (built-in extensions at least), for palettes a theme names.
92
- // Returns the ids it holds, as { themes, palettes }.
93
- export function checkPack(bundle, { known = { themes: {}, palettes: {} } } = {}) {
94
+ // Checks a pack before it is published or installed. `provided` holds the
95
+ // themes and palettes of the packs it requires ({ themes, palettes } by id),
96
+ // for those its themes name; `owners` maps ids to the packs that have them,
97
+ // so no pack takes another's id. Returns the ids and manifests it holds.
98
+ export function checkPack(bundle, { provided = { themes: {}, palettes: {} }, owners = {} } = {}) {
94
99
  if (!bundle || typeof bundle !== 'object' || typeof bundle.files !== 'object' || !bundle.files) fail('Not a pack')
95
100
  checkMeta(bundle, `Pack ${bundle.id ?? '?'}`)
96
101
  const byId = {}
@@ -119,18 +124,19 @@ export function checkPack(bundle, { known = { themes: {}, palettes: {} } } = {})
119
124
  for (const url of record.manifest.fonts) if (!FONT_HOSTS.some(host => url.startsWith(host))) fail(`${bundle.id}: ${id} loads fonts from ${url}; allowed are ${FONT_HOSTS.join(', ')}`)
120
125
  checkCss(files['styles.css'] ?? '', `${bundle.id}: ${id}/styles.css`)
121
126
  }
122
- if (known.themes[id] || known.palettes[id]) fail(`${bundle.id}: ${id} is the id of a built-in ${known.themes[id] ? 'theme' : 'palette'}`)
127
+ if (owners[id] && owners[id] !== bundle.id) fail(`${bundle.id}: ${id} is already the id of a theme or palette in the pack ${owners[id]}`)
123
128
  records.push(record)
124
129
  }
125
- const themes = records.filter(r => r.kind === 'theme').map(r => r.id)
126
- const palettes = records.filter(r => r.kind === 'palette').map(r => r.id)
127
- const hasPalette = id => palettes.includes(id) || Boolean(known.palettes[id])
128
- const hasTheme = id => themes.includes(id) || Boolean(known.themes[id])
130
+ const mine = kind => Object.fromEntries(records.filter(r => r.kind === kind).map(r => [r.id, r.manifest]))
131
+ const themes = mine('theme'), palettes = mine('palette')
132
+ const requires = bundle.requires?.length ? ` or in ${bundle.requires.join(', ')}` : ''
129
133
  for (const record of records) {
130
- if (record.kind === 'theme') for (const id of [record.manifest.palette, ...(record.manifest.palettes ?? [])]) if (!hasPalette(id)) fail(`${bundle.id}: the theme ${record.id} names the palette "${id}", which is neither in the pack nor built in`)
131
- if (record.kind === 'palette' && record.manifest.theme && !hasTheme(record.manifest.theme)) fail(`${bundle.id}: the palette ${record.id} belongs to the theme "${record.manifest.theme}", which is neither in the pack nor built in`)
134
+ if (record.kind === 'theme') for (const id of [record.manifest.palette, ...(record.manifest.palettes ?? [])]) {
135
+ if (!palettes[id] && !provided.palettes[id]) fail(`${bundle.id}: the theme ${record.id} names the palette "${id}", which is not in the pack${requires}; add the pack that has it to requires`)
136
+ }
137
+ if (record.kind === 'palette' && record.manifest.theme && !themes[record.manifest.theme] && !provided.themes[record.manifest.theme]) fail(`${bundle.id}: the palette ${record.id} belongs to the theme "${record.manifest.theme}", which is not in the pack${requires}`)
132
138
  }
133
- return { themes, palettes }
139
+ return { themes: Object.keys(themes), palettes: Object.keys(palettes), manifests: { themes, palettes } }
134
140
  }
135
141
 
136
142
  function readMarker(dir) {
@@ -152,7 +158,7 @@ export function installedPacks(root) {
152
158
  for (const entry of readdirSync(root).sort()) {
153
159
  const marker = readMarker(resolve(root, entry))
154
160
  if (!marker?.pack) continue
155
- const pack = packs[marker.pack] ??= { version: marker.version, url: marker.url, folders: [] }
161
+ const pack = packs[marker.pack] ??= { version: marker.version, url: marker.url, requires: marker.requires ?? [], folders: [] }
156
162
  pack.folders.push(entry)
157
163
  }
158
164
  return packs
@@ -166,8 +172,8 @@ const refuseChanged = (root, folder, marker) => {
166
172
  // Writes a checked pack into an extensions folder (a deck's, or the user's).
167
173
  // A folder of another pack or of the deck itself is never touched; a folder
168
174
  // of this pack is replaced unless it was changed by hand.
169
- export function installPack(bundle, { root, known, url = null, digest = null, force = false } = {}) {
170
- const { themes, palettes } = checkPack(bundle, { known })
175
+ export function installPack(bundle, { root, provided, owners, url = null, digest = null, force = false } = {}) {
176
+ const { themes, palettes } = checkPack(bundle, { provided, owners })
171
177
  const ids = [...themes, ...palettes]
172
178
  const before = installedPacks(root)[bundle.id]
173
179
  for (const id of ids) {
@@ -193,7 +199,7 @@ export function installPack(bundle, { root, known, url = null, digest = null, fo
193
199
  hashes[name] = sha256(content)
194
200
  }
195
201
  for (const name of readdirSync(dir)) if (name !== MARKER && !Object.hasOwn(hashes, name)) rmSync(resolve(dir, name), { recursive: true, force: true })
196
- writeFileSync(resolve(dir, MARKER), JSON.stringify({ pack: bundle.id, version: bundle.version, url, sha256: digest, files: hashes }, null, 2) + '\n')
202
+ writeFileSync(resolve(dir, MARKER), JSON.stringify({ pack: bundle.id, version: bundle.version, ...(bundle.requires?.length ? { requires: bundle.requires } : {}), url, sha256: digest, files: hashes }, null, 2) + '\n')
197
203
  }
198
204
  return { themes, palettes, replaced: before?.version ?? null }
199
205
  }
@@ -230,38 +236,121 @@ export async function fetchPack(index, entry) {
230
236
  return { bundle, url, digest: entry.sha256 }
231
237
  }
232
238
 
233
- // Installs a pack listed in the index: refused when this mdeck is too old.
234
- export async function installFromIndex(index, entry, { root, known, mdeckVersion, force = false }) {
235
- if (!satisfies(entry.mdeck, mdeckVersion)) fail(`${entry.id} ${entry.version} needs mdeck ${entry.mdeck}; this is ${mdeckVersion}. Update mdeck first: npm install -g mdeck`)
236
- const { bundle, url, digest } = await fetchPack(index, entry)
237
- return installPack(bundle, { root, known, url, digest, force })
239
+ // ─── The catalogue: bundled packs and the repository ──────────────────────
240
+
241
+ // A bundled pack as the catalogue lists it.
242
+ function bundledEntry(id, dir) {
243
+ const { meta, bundle, sha256: digest } = bundlePack(dir)
244
+ const { themes, palettes } = checkPack(bundle)
245
+ return { id, title: meta.title, description: meta.description ?? '', version: meta.version, author: meta.author, license: meta.license,
246
+ ...(meta.mdeck ? { mdeck: meta.mdeck } : {}), requires: meta.requires ?? [], themes, palettes, sha256: digest, bundled: true, dir }
247
+ }
248
+
249
+ // Every pack mdeck can install: the bundled ones, and the repository's when
250
+ // it answers. Of two versions of one pack the newer is offered; the bundled
251
+ // one on a tie, since it needs no download. `offline` says why the
252
+ // repository is missing.
253
+ export async function loadCatalogue({ bundled = {}, url = indexUrl(), online = true } = {}) {
254
+ const packs = new Map(Object.entries(bundled).map(([id, dir]) => [id, bundledEntry(id, dir)]))
255
+ let offline = null, index = { url, packs: [] }
256
+ if (online) { try { index = await fetchIndex(url) } catch (error) { offline = error.message } }
257
+ for (const entry of index.packs) {
258
+ const have = packs.get(entry.id)
259
+ if (!have || compareVersions(entry.version, have.version) > 0) packs.set(entry.id, { ...entry, requires: entry.requires ?? [], bundled: false, bundledVersion: have?.version })
260
+ else if (have.bundled) have.repository = entry
261
+ }
262
+ return { url, offline, packs: [...packs.values()].sort((a, b) => a.id.localeCompare(b.id, 'en')) }
238
263
  }
239
264
 
240
- export function findPack(index, id) {
241
- return index.packs.find(pack => pack.id === id) ?? fail(`There is no pack "${id}" in ${index.url}; mdeck themes search lists them`)
265
+ export function findPack(catalogue, id) {
266
+ return catalogue.packs.find(pack => pack.id === id) ?? fail(`There is no pack "${id}"${catalogue.offline ? ` among those that come with mdeck, and the theme repository could not be reached (${catalogue.offline})` : `; mdeck themes search lists them`}`)
242
267
  }
243
268
 
269
+ // The ids every pack in the catalogue has, so packs do not take each other's.
270
+ export const ownersIn = catalogue => Object.fromEntries(catalogue.packs.flatMap(pack => [...pack.themes, ...pack.palettes].map(id => [id, pack.id])))
271
+
272
+ // A pack's files: read from mdeck's folder, or downloaded and checked
273
+ // against the repository's checksum.
274
+ export async function obtainPack(catalogue, entry) {
275
+ if (entry.bundled) { const { bundle, sha256: digest } = bundlePack(entry.dir); return { bundle, url: null, digest } }
276
+ return fetchPack({ url: catalogue.url }, entry)
277
+ }
278
+
279
+ // A pack and the packs it requires, in the order to install them; the pack
280
+ // itself last.
281
+ export function withRequirements(catalogue, id, seen = []) {
282
+ if (seen.includes(id)) fail(`The packs ${[...seen, id].join(' → ')} require each other in a circle`)
283
+ const entry = findPack(catalogue, id)
284
+ const order = []
285
+ for (const required of entry.requires ?? []) for (const pack of withRequirements(catalogue, required, [...seen, id])) if (!order.includes(pack)) order.push(pack)
286
+ return [...order, entry]
287
+ }
288
+
289
+ // Installs a pack and what it requires into an extensions folder. A required
290
+ // pack that `has(id)` says is there already is left as it is. `bundledHere`
291
+ // handles a bundled pack without writing it (for the user's folder, where the
292
+ // bundled one is offered anyway); it returns true when it did.
293
+ export async function installWithRequirements(catalogue, id, { root, mdeckVersion, force = false, has = () => false, bundledHere = null }) {
294
+ const owners = ownersIn(catalogue)
295
+ const provided = { themes: {}, palettes: {} }
296
+ const done = []
297
+ for (const entry of withRequirements(catalogue, id)) {
298
+ if (!satisfies(entry.mdeck, mdeckVersion)) fail(`${entry.id} ${entry.version} needs mdeck ${entry.mdeck}; this is ${mdeckVersion}. Update mdeck first: npm install -g mdeck`)
299
+ const { bundle, url, digest } = await obtainPack(catalogue, entry)
300
+ const { manifests } = checkPack(bundle, { provided, owners })
301
+ const target = entry.id === id
302
+ if (!target && has(entry.id)) done.push({ id: entry.id, version: entry.version, kept: true })
303
+ else if (entry.bundled && bundledHere?.(entry)) done.push({ id: entry.id, version: entry.version, bundled: true, themes: Object.keys(manifests.themes), palettes: Object.keys(manifests.palettes) })
304
+ else done.push({ id: entry.id, version: entry.version, ...installPack(bundle, { root, provided, owners, url, digest, force }) })
305
+ Object.assign(provided.themes, manifests.themes)
306
+ Object.assign(provided.palettes, manifests.palettes)
307
+ }
308
+ return done
309
+ }
310
+
311
+ // ─── The repository itself ────────────────────────────────────────────────
312
+
244
313
  // The theme repository as served: index.json and packs/<id>-<version>.json,
245
- // from a folder of pack folders. Each pack is checked on its own against the
246
- // built-in extensions, since it is installed alone, and no two packs may
247
- // share an id. Previews are added by the caller.
248
- export function buildRepository(packsDir, outDir, { known }) {
249
- const entries = []
250
- const owner = {}
251
- const dirs = readdirSync(packsDir).filter(name => !name.startsWith('.') && statSync(resolve(packsDir, name)).isDirectory()).sort()
252
- mkdirSync(resolve(outDir, 'packs'), { recursive: true })
253
- for (const name of dirs) {
254
- const { meta, bundle, text, sha256: digest } = bundlePack(resolve(packsDir, name))
255
- const { themes, palettes } = checkPack(bundle, { known })
256
- for (const id of [...themes, ...palettes]) {
257
- if (owner[id]) fail(`${id} is in both ${owner[id]} and ${meta.id}`)
258
- owner[id] = meta.id
314
+ // from a folder of pack folders and mdeck's bundled packs (marked bundled).
315
+ // Each pack is checked with only the packs it requires, as it is installed;
316
+ // no two packs may share an id. Previews are added by the caller.
317
+ export function buildRepository(packsDir, outDir, { bundled = {} } = {}) {
318
+ const dirs = { ...Object.fromEntries(readdirSync(packsDir).filter(name => !name.startsWith('.') && statSync(resolve(packsDir, name)).isDirectory()).map(name => [name, resolve(packsDir, name)])) }
319
+ for (const [id, dir] of Object.entries(bundled)) {
320
+ if (dirs[id]) fail(`${id} comes with mdeck; change it there instead of in ${packsDir}`)
321
+ dirs[id] = dir
322
+ }
323
+ const read = Object.fromEntries(Object.entries(dirs).map(([id, dir]) => [id, bundlePack(dir)]))
324
+ const owners = {}
325
+ for (const [id, { bundle }] of Object.entries(read)) {
326
+ for (const ext of Object.keys(bundle.files).map(path => path.split('/')[0])) {
327
+ if (owners[ext] && owners[ext] !== id) fail(`${ext} is in both ${owners[ext]} and ${id}`)
328
+ owners[ext] = id
259
329
  }
330
+ }
331
+ const checked = {}
332
+ const check = (id, seen = []) => {
333
+ if (checked[id]) return checked[id]
334
+ if (!read[id]) fail(`${seen.at(-1)} requires ${id}, which is not in the repository`)
335
+ if (seen.includes(id)) fail(`The packs ${[...seen, id].join(' → ')} require each other in a circle`)
336
+ const provided = { themes: {}, palettes: {} }
337
+ for (const required of read[id].bundle.requires ?? []) {
338
+ const result = check(required, [...seen, id])
339
+ Object.assign(provided.themes, result.provides.themes); Object.assign(provided.palettes, result.provides.palettes)
340
+ }
341
+ const result = checkPack(read[id].bundle, { provided, owners })
342
+ return checked[id] = { ...result, provides: { themes: { ...provided.themes, ...result.manifests.themes }, palettes: { ...provided.palettes, ...result.manifests.palettes } } }
343
+ }
344
+ mkdirSync(resolve(outDir, 'packs'), { recursive: true })
345
+ const entries = Object.keys(read).sort((a, b) => a.localeCompare(b, 'en')).map(id => {
346
+ const { meta, text, sha256: digest } = read[id]
347
+ const { themes, palettes } = check(id)
260
348
  const file = `packs/${meta.id}-${meta.version}.json`
261
349
  writeFileSync(resolve(outDir, file), text)
262
- entries.push({ id: meta.id, title: meta.title, description: meta.description ?? '', version: meta.version, author: meta.author, license: meta.license,
263
- ...(meta.homepage ? { homepage: meta.homepage } : {}), ...(meta.mdeck ? { mdeck: meta.mdeck } : {}), themes, palettes, url: file, sha256: digest })
264
- }
350
+ return { id: meta.id, title: meta.title, description: meta.description ?? '', version: meta.version, author: meta.author, license: meta.license,
351
+ ...(meta.homepage ? { homepage: meta.homepage } : {}), ...(meta.mdeck ? { mdeck: meta.mdeck } : {}), ...(meta.requires?.length ? { requires: meta.requires } : {}),
352
+ ...(bundled[id] ? { bundled: true } : {}), themes, palettes, url: file, sha256: digest }
353
+ })
265
354
  const index = { schema: 1, packs: entries }
266
355
  writeFileSync(resolve(outDir, 'index.json'), JSON.stringify(index, null, 2) + '\n')
267
356
  return index
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
 
package/src/paths.js CHANGED
@@ -7,3 +7,5 @@ export const runtimeRoot = resolve(frameworkRoot, 'src/runtime')
7
7
  export const assetsRoot = resolve(frameworkRoot, 'assets')
8
8
  export const docsRoot = resolve(frameworkRoot, 'docs/site')
9
9
  export const builtinExtensionsRoot = resolve(assetsRoot, 'extensions')
10
+ // The packs of themes and palettes that come with mdeck (the starter set).
11
+ export const bundledPacksRoot = resolve(assetsRoot, 'packs')
@@ -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"