mdeck 3.0.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 (45) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/assets/{packs/lagoon → extensions/palettes}/lagoon/extension.toml +3 -0
  3. package/assets/{packs/aurora → extensions/palettes}/neon/extension.toml +3 -0
  4. package/assets/{packs/academic → extensions/palettes}/nordic/extension.toml +3 -0
  5. package/assets/{packs/minimal → extensions/palettes}/paper/extension.toml +3 -0
  6. package/assets/{packs/neue → extensions/palettes}/swiss/extension.toml +3 -0
  7. package/assets/{packs/academic → extensions/themes}/academic/extension.toml +3 -0
  8. package/assets/{packs/aurora → extensions/themes}/aurora/extension.toml +3 -0
  9. package/assets/{packs/minimal → extensions/themes}/minimal/extension.toml +3 -0
  10. package/assets/{packs/neue → extensions/themes}/neue/extension.toml +3 -0
  11. package/docs/reference/authoring.md +3 -3
  12. package/docs/reference/extensions.md +40 -35
  13. package/docs/reference/palettes.md +1 -1
  14. package/docs/reference/themes.md +3 -3
  15. package/docs/site/content/appearance.md +11 -9
  16. package/docs/site/content/commands.md +2 -1
  17. package/docs/site/content/theme-authoring.md +1 -1
  18. package/docs/site/content/theme-repository.md +36 -28
  19. package/package.json +2 -2
  20. package/skills/write-slides/SKILL.md +1 -1
  21. package/src/build/editorPlugin.js +22 -17
  22. package/src/build/themeCheck.js +2 -1
  23. package/src/cli/args.js +2 -1
  24. package/src/cli/index.js +7 -6
  25. package/src/cli/themes.js +92 -91
  26. package/src/editor/api.js +11 -10
  27. package/src/editor/components/DesignApp.jsx +9 -11
  28. package/src/editor/components/ExtensionEditor.jsx +10 -11
  29. package/src/editor/components/ExtensionsPanel.jsx +24 -28
  30. package/src/extensions/discover.js +28 -35
  31. package/src/extensions/installed.js +73 -48
  32. package/src/extensions/manifest.js +20 -3
  33. package/src/extensions/moved.js +14 -7
  34. package/src/extensions/packages.js +318 -0
  35. package/src/paths.js +0 -2
  36. package/assets/packs/academic/pack.toml +0 -8
  37. package/assets/packs/aurora/pack.toml +0 -8
  38. package/assets/packs/lagoon/pack.toml +0 -8
  39. package/assets/packs/minimal/pack.toml +0 -8
  40. package/assets/packs/neue/pack.toml +0 -8
  41. package/src/extensions/packs.js +0 -357
  42. /package/assets/{packs/academic → extensions/themes}/academic/styles.css +0 -0
  43. /package/assets/{packs/aurora → extensions/themes}/aurora/styles.css +0 -0
  44. /package/assets/{packs/minimal → extensions/themes}/minimal/styles.css +0 -0
  45. /package/assets/{packs/neue → extensions/themes}/neue/styles.css +0 -0
package/src/cli/themes.js CHANGED
@@ -1,93 +1,93 @@
1
- // `mdeck themes`: every theme and palette comes in a pack. Some packs come
2
- // with mdeck (the starter set), the others from the theme repository.
3
- // Packs are installed for every deck (~/.mdeck/extensions) unless a deck or
4
- // folder is named, or --local is given: then into the extensions folder
5
- // beside it, so the slide folder carries them. A pack that comes with mdeck
6
- // is removed by hiding it, and installed again from mdeck's own copy, so
7
- // that works offline. `build` is for the repository itself.
8
- 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'
9
11
  import { createRequire } from 'node:module'
10
- import { dirname, resolve } from 'node:path'
11
- import { bundledPacks, loadRegistry, manifestsOf, removedPacks, userExtensionsDir } from '../extensions/discover.js'
12
- import { target, packsAt, installHere, removeHere } from '../extensions/installed.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'
13
15
  import { palettesFor } from '../extensions/tokens.js'
14
16
  import { ManifestError } from '../extensions/manifest.js'
15
- import { buildRepository, compareVersions, findPack, installedPacks, installWithRequirements, loadCatalogue, PackError } from '../extensions/packs.js'
17
+ import { buildRepository, compareVersions, findEntry, installedPackages, keyOf, loadCatalogue, PackageError } from '../extensions/packages.js'
16
18
 
17
19
  const VERSION = createRequire(import.meta.url)('../../package.json').version
18
20
 
19
- const USAGE = 'search [words] | install <pack> | remove <pack> | update [pack] | list — for every deck, or beside a deck with [slides.md or folder] or --local; 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]' : ''}`
20
22
 
21
23
  // Every theme with the palettes it offers, and every palette's colours, each
22
- // marked with its pack and whether it comes with mdeck.
23
- function looksOf(registry, index) {
24
- const packOf = Object.fromEntries(index.packs.flatMap(p => [...p.themes, ...p.palettes].map(id => [id, p])))
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')))
25
27
  const palettes = manifestsOf(registry, 'palette')
26
28
  const colours = tokens => Object.fromEntries(['--bg', '--surface', '--ink', '--accent', '--accent-2'].map(key => [key, tokens?.[key] ?? null]))
27
- const origin = id => ({ pack: packOf[id]?.id ?? null, bundled: Boolean(packOf[id]?.bundled) })
28
29
  return {
29
30
  mdeck: VERSION,
30
- themes: Object.values(manifestsOf(registry, 'theme')).map(theme => ({ id: theme.id, title: theme.title, description: theme.description ?? '', ...origin(theme.id),
31
+ themes: Object.values(manifestsOf(registry, 'theme')).map(theme => ({ id: theme.id, title: theme.title, description: theme.description ?? '', builtIn: builtIn.has(theme.id + ':t'),
31
32
  palette: theme.palette, appearance: theme.appearance ?? 'light', palettes: palettesFor(theme, palettes).map(p => p.id) })),
32
- palettes: Object.values(palettes).map(palette => ({ id: palette.id, title: palette.title, description: palette.description ?? '', ...origin(palette.id),
33
- light: colours(palette.light), dark: colours(palette.dark) })),
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) })),
34
35
  }
35
36
  }
36
37
 
37
- 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`
38
40
  const [sub = 'search', ...rest] = positionals
39
41
  const local = flag('--local'), force = flag('--force')
40
- const warnOffline = catalogue => { if (catalogue.offline) tip(`The theme repository could not be reached (${catalogue.offline}); showing the packs that come with mdeck.`) }
41
- const what = step => [step.themes?.length ? `themes ${step.themes.join(', ')}` : '', step.palettes?.length ? `palettes ${step.palettes.join(', ')}` : ''].filter(Boolean).join('; ')
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}`
42
44
  try {
43
45
  if (sub === 'search') {
44
- const catalogue = await loadCatalogue({ bundled: bundledPacks({ all: true }) })
46
+ const catalogue = await loadCatalogue()
45
47
  warnOffline(catalogue)
46
48
  const words = rest.map(word => word.toLowerCase())
47
- const mine = packsAt(target(null, false)), here = installedPacks(target('.', true).root), hidden = removedPacks()
48
- const found = catalogue.packs.filter(pack => words.every(word => [pack.id, pack.title, pack.description, ...pack.themes, ...pack.palettes].join(' ').toLowerCase().includes(word)))
49
- if (!found.length) return tip(`No pack matches${words.length ? ` "${words.join(' ')}"` : ''}`)
50
- for (const pack of found) {
51
- const have = here[pack.id] ?? mine[pack.id]
52
- const state = hidden.includes(pack.id) && !have ? ` ${c.dim}(comes with mdeck, removed)${c.reset}`
53
- : !have ? (pack.bundled || pack.bundledVersion ? ` ${c.dim}(comes with mdeck)${c.reset}` : '')
54
- : have.bundled ? (pack.bundled ? ` ${c.green}(comes with mdeck)${c.reset}` : ` ${c.yellow}(comes with mdeck as ${pack.bundledVersion}, update available)${c.reset}`)
55
- : compareVersions(pack.version, have.version) > 0 ? ` ${c.yellow}(${have.version} installed, update available)${c.reset}` : ` ${c.green}(installed)${c.reset}`
56
- console.log(`\n ${c.cyan}${pack.id}${c.reset} ${pack.version} — ${pack.title}${state}`)
57
- if (pack.description) console.log(` ${pack.description}`)
58
- console.log(` ${c.dim}${[pack.themes.length ? `themes: ${pack.themes.join(', ')}` : '', pack.palettes.length ? `palettes: ${pack.palettes.join(', ')}` : '', pack.requires?.length ? `requires: ${pack.requires.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}`)
59
62
  }
60
63
  console.log()
61
- return tip('Install one for every deck with: mdeck themes install <pack> (beside a deck: add slides.md or --local)')
64
+ return tip(`Install one for every deck with: mdeck ${kinds} install <${kind}> (beside a deck: add slides.md or --local)`)
62
65
  }
63
66
 
64
67
  if (sub === 'install') {
65
68
  const [id, where] = rest
66
- if (!id) { err('Name a pack.'); return tip('Usage: mdeck themes install <pack> [slides.md or folder] [--local]; mdeck themes search lists them.') }
69
+ if (!id) { err(`Name a ${kind}.`); return tip(`Usage: mdeck ${kinds} install <${kind}> [slides.md or folder] [--local]; mdeck ${kinds} search lists them.`) }
67
70
  const at = target(where, local)
68
- const catalogue = await loadCatalogue({ bundled: bundledPacks({ all: true }) })
71
+ const catalogue = await loadCatalogue()
69
72
  warnOffline(catalogue)
70
- findPack(catalogue, id)
71
- for (const step of await installHere(catalogue, id, at, { mdeckVersion: VERSION, force })) {
72
- if (step.kept) tip(`${step.id} ${step.version} is there already`)
73
- else if (step.bundled) (step.wasRemoved ? ok(`${step.id} comes with mdeck and is offered again ${at.label}`) : tip(`${step.id} comes with mdeck and is installed ${at.label} already`))
74
- else ok(`${step.replaced ? `Updated ${step.id} from ${step.replaced} to ${step.version}` : `Installed ${step.id} ${step.version}`} ${at.label}${what(step) ? `: ${what(step)}` : ''}`)
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}`)
75
79
  }
76
- loadRegistry(at.deck)
77
- const pack = findPack(catalogue, id)
78
- if (pack.themes.length) tip(`Use it with "theme: ${pack.themes[0]}" at the top of a slide file.`)
79
- else if (pack.palettes.length) tip(`Use it with "palette: ${pack.palettes[0]}" at the top of a slide file.`)
80
- return tip('Look at it on a sample deck: mdeck design')
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
81
82
  }
82
83
 
83
84
  if (sub === 'remove') {
84
85
  const [id, where] = rest
85
- if (!id) { err('Name a pack.'); return tip('Usage: mdeck themes remove <pack> [slides.md or folder] [--local]') }
86
+ if (!id) { err(`Name a ${kind}.`); return tip(`Usage: mdeck ${kinds} remove <${kind}> [slides.md or folder] [--local]`) }
86
87
  const at = target(where, local)
87
- const catalogue = await loadCatalogue({ bundled: bundledPacks({ all: true }), online: false })
88
- const removed = removeHere(catalogue, id, at, { force })
89
- if (removed.folders.length) ok(`Removed ${id} ${at.label} (${removed.folders.join(', ')})`)
90
- if (removed.hidden) ok(`${id} comes with mdeck and is no longer offered; mdeck themes install ${id} brings it back`)
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
91
  return
92
92
  }
93
93
 
@@ -95,19 +95,18 @@ export async function runThemes({ positionals, flag, output, ui: { ok, err, tip,
95
95
  const [first, second] = rest
96
96
  const named = first && !/\.md$/i.test(first) && !(existsSync(first) && statSync(first).isDirectory()) ? first : null
97
97
  const at = target(named ? second : first, local)
98
- const catalogue = await loadCatalogue({ bundled: bundledPacks({ all: true }) })
98
+ const catalogue = await loadCatalogue()
99
99
  warnOffline(catalogue)
100
- const installed = packsAt(at)
101
- const ids = named ? [named] : Object.keys(installed)
102
- if (!ids.length) return tip(`No packs installed ${at.label}`)
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}`)
103
103
  for (const id of ids) {
104
- const have = installed[id]
105
- if (!have) throw new PackError(`The pack ${id} is not installed ${at.label}`)
106
- const entry = findPack(catalogue, id)
107
- // A bundled pack is up to date until the repository has a newer one.
108
- if (have.bundled ? entry.bundled : compareVersions(entry.version, have.version) <= 0) { tip(`${id} ${have.version ?? entry.version} is up to date`); continue }
109
- const steps = await installWithRequirements(catalogue, id, { root: at.root, mdeckVersion: VERSION, force, has: pack => pack !== id && Object.hasOwn(packsAt(at), pack) })
110
- ok(`Updated ${id} ${have.version ?? entry.bundledVersion} → ${steps.at(-1).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}`)
111
110
  }
112
111
  loadRegistry(at.deck)
113
112
  return
@@ -115,54 +114,56 @@ export async function runThemes({ positionals, flag, output, ui: { ok, err, tip,
115
114
 
116
115
  if (sub === 'list') {
117
116
  const [where] = rest
118
- const hidden = removedPacks()
117
+ const hidden = removedBuiltIns()
118
+ const catalogue = await loadCatalogue({ online: false })
119
119
  console.log(`\n ${c.bold}Come with mdeck${c.reset}`)
120
- for (const id of Object.keys(bundledPacks({ all: true }))) console.log(` ${c.cyan}${id}${c.reset}${hidden.includes(id) ? ` ${c.dim}(removed; mdeck themes install ${id} brings it back)${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
121
  const sections = [['For every deck', userExtensionsDir()], ...(where || local ? [['Beside the deck', target(where, true).root]] : [])]
122
- for (const [label, root] of sections) {
123
- const packs = installedPacks(root)
124
- console.log(`\n ${c.bold}${label}${c.reset} ${c.dim}${root}${c.reset}`)
125
- if (!Object.keys(packs).length) console.log(` ${c.dim}none${c.reset}`)
126
- for (const [id, pack] of Object.entries(packs)) console.log(` ${c.cyan}${id}${c.reset} ${pack.version} — ${pack.folders.join(', ')}${pack.requires.length ? ` ${c.dim}(requires ${pack.requires.join(', ')})${c.reset}` : ''}`)
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}`)
127
127
  }
128
128
  console.log()
129
129
  return
130
130
  }
131
131
 
132
- if (sub === 'build') {
133
- const [packsDir] = rest
132
+ if (sub === 'build' && kind === 'theme') {
133
+ const [repoDir] = rest
134
134
  const out = output()
135
- if (!packsDir || !out) { err('Name the packs folder and the output folder.'); return tip('Usage: mdeck themes build <packs folder> -o <output folder> [--check]') }
136
- const bundled = bundledPacks({ all: true })
137
- const index = buildRepository(resolve(packsDir), resolve(out), { bundled })
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))
138
137
  // The repository's own themes and palettes go beside the sample deck;
139
- // the bundled ones are in it already.
140
- const extensions = Object.fromEntries(index.packs.filter(pack => !pack.bundled).flatMap(pack => [...pack.themes, ...pack.palettes].map(id => [id, resolve(packsDir, pack.id, id)])))
141
- const dirOf = id => extensions[id] ?? resolve(bundled[index.packs.find(pack => [...pack.themes, ...pack.palettes].includes(id)).id], id)
142
- const paletteTheme = id => /^theme\s*=\s*"([^"]+)"/m.exec(readFileSync(resolve(dirOf(id), 'extension.toml'), 'utf8'))?.[1] ?? 'neue'
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)]]))
143
141
  const { checkLooks, renderPreviews, buildSampleDeck } = await import('../build/themeCheck.js')
144
- const looks = index.packs.flatMap(pack => [...pack.themes.map(theme => ({ id: theme, theme })), ...pack.palettes.map(palette => ({ id: palette, theme: paletteTheme(palette), palette }))])
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 })
145
145
  if (flag('--check')) {
146
146
  const failures = await checkLooks(looks, { extensions })
147
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 }
148
- 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`)
149
149
  }
150
150
  // Themes on the title slide, palettes on the chapter slide (the third).
151
- await renderPreviews(looks.map(look => ({ file: resolve(out, 'previews', `${look.id}.webp`), theme: look.theme, palette: look.palette ?? '', slide: look.palette ? 3 : 1 })), { extensions })
152
- for (const pack of index.packs) pack.previews = Object.fromEntries([...pack.themes, ...pack.palettes].map(id => [id, `previews/${id}.webp`]))
153
- writeFileSync(resolve(out, 'index.json'), JSON.stringify(index, null, 2) + '\n')
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')
154
155
  // For the repository's theme browser: the sample deck with every theme
155
156
  // and palette, and what each theme offers.
156
157
  const registry = buildSampleDeck({ extensions, outDir: resolve(out, 'preview') })
157
- writeFileSync(resolve(out, 'looks.json'), JSON.stringify(looksOf(registry, index), null, 2) + '\n')
158
- return ok(`Built ${index.packs.length} pack(s) into ${resolve(out)}`)
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)}`)
159
160
  }
160
161
 
161
- err(`Unknown: mdeck themes ${sub}`)
162
- tip(`Use: mdeck themes ${USAGE}`)
162
+ err(`Unknown: mdeck ${kinds} ${sub}`)
163
+ tip(`Use: mdeck ${kinds} ${usage(kind)}`)
163
164
  process.exitCode = 1
164
165
  } catch (error) {
165
- if (!(error instanceof PackError || error instanceof ManifestError)) throw error
166
+ if (!(error instanceof PackageError || error instanceof ManifestError)) throw error
166
167
  err(error.message)
167
168
  process.exitCode = 1
168
169
  }
package/src/editor/api.js CHANGED
@@ -52,24 +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 for every deck, with the packs it requires. Returns the
63
- // new registry.
64
- export async function installThemePack(id) {
65
- 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 }) })
66
67
  if (!response.ok) throw await failure(response)
67
68
  return response.json()
68
69
  }
69
70
 
70
- // Removes a pack for every deck (one that comes with mdeck is hidden).
71
- export async function removeThemePack(id) {
72
- const response = await fetch('/__mdeck/themes/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) })
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 }) })
73
74
  if (!response.ok) throw await failure(response)
74
75
  return response.json()
75
76
  }
@@ -1,7 +1,7 @@
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, removeThemePack } 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
7
  import { parseTarget, launchPageUrl } from '../designLink.js'
@@ -118,18 +118,16 @@ export function DesignApp() {
118
118
  } catch (caught) { setError(caught.message) }
119
119
  }
120
120
 
121
- // A pack from the theme repository, installed beside the deck; its first
122
- // theme or palette of the kind shown is opened.
123
- const installOnline = async (pack, kind) => {
124
- 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)
125
124
  dispatch({ type: 'setRegistry', registry: result.registry })
126
- const id = pack[`${kind}s`][0]
127
- if (id) await select(kind, id)
125
+ await select(kind, id)
128
126
  }
129
127
 
130
- // Removes a pack for every deck; the deck's look is not changed.
131
- const removeOnline = async pack => {
132
- const result = await removeThemePack(pack)
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)
133
131
  dispatch({ type: 'setRegistry', registry: result.registry })
134
132
  setCurrent(null)
135
133
  }
@@ -253,7 +251,7 @@ export function DesignApp() {
253
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>
254
252
  : current.source === 'local'
255
253
  ? <>{usage}<ExtensionEditor extension={current} palettes={manifests.palettes} status={status} error={error} note={note} onFiles={onFiles} onDelete={remove} onCopy={() => copyDialog(current.kind, current.id)} /></>
256
- : <BuiltInCard extension={current} manifest={manifests[`${current.kind}s`][current.id]} onCopy={() => copyDialog(current.kind, current.id)} onRemovePack={removeOnline} />}
254
+ : <BuiltInCard extension={current} manifest={manifests[`${current.kind}s`][current.id]} onCopy={() => copyDialog(current.kind, current.id)} onRemove={removeOnline} />}
257
255
  </aside>
258
256
  </div>
259
257
  {dialog && <NewExtensionDialog kind={dialog.kind} initial={dialog.initial ?? null} registry={state.registry} existing={existing} onCreate={create} onClose={() => setDialog(null)} />}
@@ -45,30 +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, onRemovePack }) {
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 pack = manifest?.pack ?? null
54
53
  const [removing, setRemoving] = useState(null)
55
54
  return <div class="form">
56
- <p class="section-title">{user ? `Installed for every deck` : `Comes with mdeck`}{pack ? ` · pack ${pack}` : ''}</p>
55
+ <p class="section-title">{user ? `Installed for every deck` : `Comes with mdeck`}{manifest?.version ? ` · ${manifest.version}` : ''}</p>
57
56
  <h2 class="builtin-title">{manifest?.title ?? id} <span class="muted">{id}</span></h2>
58
57
  {manifest?.description && <p class="builtin-description">{manifest.description}</p>}
59
58
  {kind === 'palette' && manifest && ['light', 'dark'].map(mode => <Field key={mode} label={mode === 'light' ? 'Light' : 'Dark'}><Swatches tokens={manifest[mode] ?? {}} /></Field>)}
60
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>}
61
- <p class="note">{user ? `Installed ${kind}s change only when they are updated (mdeck themes 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>
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>
62
61
  <div class="row" style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
63
62
  <button class="btn is-primary" onClick={onCopy}>Copy to customise</button>
64
- {pack && onRemovePack && (removing === 'confirm'
65
- ? <><span class="muted" style={{ alignSelf: 'center' }}>Remove the pack {pack} for every deck?</span>
66
- <button class="btn" onClick={async () => { setRemoving('busy'); try { await onRemovePack(pack); setRemoving(null) } catch (error) { setRemoving({ error: error.message }) } }}>Remove</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>
67
66
  <button class="btn" onClick={() => setRemoving(null)}>Keep</button></>
68
- : <button class="btn" disabled={removing === 'busy'} onClick={() => setRemoving('confirm')}>Remove pack</button>)}
67
+ : <button class="btn" disabled={removing === 'busy'} onClick={() => setRemoving('confirm')}>Remove {kind}</button>)}
69
68
  </div>
70
69
  {removing?.error && <p class="online-error">{removing.error}</p>}
71
- {pack && !user && <p class="hint-text">Removing a pack that comes with mdeck hides it; install it again from “More to install”.</p>}
70
+ {!user && <p class="hint-text">Removing a {kind} that comes with mdeck hides it; install it again from “More to install”.</p>}
72
71
  <details class="builtin-source">
73
72
  <summary>{MANIFEST}</summary>
74
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.
@@ -44,24 +44,24 @@ export function ExtensionsPanel({ registry, view = {}, owner = "This deck's", se
44
44
  </>}
45
45
  <div class="panel-section-head"><p class="section-title">Come with mdeck</p></div>
46
46
  <ol class="outline-list">{builtIn.map(row)}</ol>
47
- <OnlinePacks key="online" kind={kind} onInstall={onInstall} />
47
+ <OnlinePackages key="online" kind={kind} onInstall={onInstall} />
48
48
  </aside>
49
49
  }
50
50
 
51
- // Packs not installed yet that have this kind of extension: from the theme
52
- // repository, and those that come with mdeck but were removed. Loaded on
53
- // request, since it needs the network. Installing makes a pack available to
54
- // every deck.
55
- function OnlinePacks({ kind, 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 }) {
56
56
  const [state, setState] = useState({ status: 'idle' })
57
57
  const [busy, setBusy] = useState(null)
58
58
  const load = () => {
59
- setState(prev => ({ ...prev, status: prev.index ? 'ready' : 'loading' }))
60
- 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 }))
61
61
  }
62
- const install = async pack => {
63
- setBusy(pack.id)
64
- try { await onInstall(pack, kind); load() } 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 })) }
65
65
  setBusy(null)
66
66
  }
67
67
  const head = <div class="panel-section-head"><p class="section-title">More to install</p></div>
@@ -69,22 +69,18 @@ function OnlinePacks({ kind, onInstall }) {
69
69
  {state.status === 'loading' ? 'Loading…' : <button class="btn is-small" onClick={load}>Show what you can install</button>}
70
70
  {state.error && <p class="online-error">{state.error}</p>}
71
71
  </div></>
72
- const { index } = state
73
- const packs = index.packs.filter(pack => pack[`${kind}s`].length && !index.installed?.[pack.id])
72
+ const { catalogue } = state
73
+ const entries = catalogue.entries.filter(entry => entry.kind === kind && !entry.available)
74
74
  return <>{head}
75
75
  {state.error && <p class="panel-hint online-error">{state.error}</p>}
76
- {index.offline && <p class="panel-hint">The theme repository could not be reached; showing what comes with mdeck.</p>}
77
- {!packs.length && <p class="panel-hint">Every {kind} there is, is installed.</p>}
78
- <ol class="outline-list">{packs.map(pack => {
79
- const id = pack[`${kind}s`][0]
80
- const previews = pack.previews ?? pack.repository?.previews
81
- return <li key={pack.id} class="outline-item has-thumb online-pack">
82
- {previews?.[id] && <div class="thumb"><img src={new URL(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.removed ? ' · comes with mdeck, removed' : ` · ${pack.author}`}{pack.requires?.length ? ` · needs ${pack.requires.join(', ')}` : ''}</span></span>
85
- <button class="btn is-small" disabled={busy === pack.id} onClick={() => install(pack)}>{busy === pack.id ? 'Installing…' : 'Install'}</button>
86
- </li>
87
- })}</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>
88
84
  </>
89
85
  }
90
86