mdeck 3.0.0 → 3.2.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 (47) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/assets/base.css +24 -0
  3. package/assets/{packs/lagoon → extensions/palettes}/lagoon/extension.toml +3 -0
  4. package/assets/{packs/aurora → extensions/palettes}/neon/extension.toml +3 -0
  5. package/assets/{packs/academic → extensions/palettes}/nordic/extension.toml +3 -0
  6. package/assets/{packs/minimal → extensions/palettes}/paper/extension.toml +3 -0
  7. package/assets/{packs/neue → extensions/palettes}/swiss/extension.toml +3 -0
  8. package/assets/{packs/academic → extensions/themes}/academic/extension.toml +3 -0
  9. package/assets/{packs/aurora → extensions/themes}/aurora/extension.toml +3 -0
  10. package/assets/{packs/minimal → extensions/themes}/minimal/extension.toml +3 -0
  11. package/assets/{packs/neue → extensions/themes}/neue/extension.toml +3 -0
  12. package/docs/reference/authoring.md +16 -3
  13. package/docs/reference/extensions.md +42 -35
  14. package/docs/reference/palettes.md +1 -1
  15. package/docs/reference/themes.md +3 -3
  16. package/docs/site/content/appearance.md +11 -9
  17. package/docs/site/content/commands.md +2 -1
  18. package/docs/site/content/theme-authoring.md +1 -1
  19. package/docs/site/content/theme-repository.md +36 -28
  20. package/package.json +2 -2
  21. package/skills/write-slides/SKILL.md +1 -1
  22. package/src/build/editorPlugin.js +22 -17
  23. package/src/build/themeCheck.js +14 -3
  24. package/src/cli/args.js +2 -1
  25. package/src/cli/index.js +7 -6
  26. package/src/cli/themes.js +99 -92
  27. package/src/editor/api.js +11 -10
  28. package/src/editor/components/DesignApp.jsx +9 -11
  29. package/src/editor/components/ExtensionEditor.jsx +10 -11
  30. package/src/editor/components/ExtensionsPanel.jsx +24 -28
  31. package/src/extensions/discover.js +28 -35
  32. package/src/extensions/installed.js +73 -48
  33. package/src/extensions/manifest.js +20 -3
  34. package/src/extensions/moved.js +14 -7
  35. package/src/extensions/packages.js +318 -0
  36. package/src/paths.js +0 -2
  37. package/src/runtime/markedSetup.js +7 -0
  38. package/assets/packs/academic/pack.toml +0 -8
  39. package/assets/packs/aurora/pack.toml +0 -8
  40. package/assets/packs/lagoon/pack.toml +0 -8
  41. package/assets/packs/minimal/pack.toml +0 -8
  42. package/assets/packs/neue/pack.toml +0 -8
  43. package/src/extensions/packs.js +0 -357
  44. /package/assets/{packs/academic → extensions/themes}/academic/styles.css +0 -0
  45. /package/assets/{packs/aurora → extensions/themes}/aurora/styles.css +0 -0
  46. /package/assets/{packs/minimal → extensions/themes}/minimal/styles.css +0 -0
  47. /package/assets/{packs/neue → extensions/themes}/neue/styles.css +0 -0
@@ -89,7 +89,8 @@ export function buildSampleDeck({ extensions = {}, outDir }) {
89
89
  try {
90
90
  writeFileSync(resolve(temp, 'slides.md'), sampleDeck())
91
91
  writeFileSync(resolve(temp, 'picture.svg'), SAMPLE_PICTURE)
92
- for (const [id, dir] of Object.entries(extensions)) cpSync(dir, resolve(temp, 'extensions', id), { recursive: true })
92
+ // Keyed by id, or by "kind:id" to keep a theme and a palette of one name apart.
93
+ for (const [key, dir] of Object.entries(extensions)) cpSync(dir, resolve(temp, 'extensions', ...key.split(':').map((part, i, all) => all.length > 1 && i === 0 ? `${part}s` : part)), { recursive: true })
93
94
  mkdirSync(resolve(temp, 'home'))
94
95
  execFileSync(process.execPath, [resolve(frameworkRoot, 'bin/mdeck.js'), 'build', resolve(temp, 'slides.md'), '-o', resolve(outDir, 'index.html')],
95
96
  { cwd: temp, stdio: 'pipe', env: { ...process.env, MDECK_HOME: resolve(temp, 'home') } })
@@ -146,15 +147,25 @@ export function checkLooks(looks, { extensions = {} } = {}) {
146
147
  })
147
148
  }
148
149
 
150
+ // The size of a theme's icon in lists, in CSS pixels; drawn at twice that
151
+ // for sharp screens.
152
+ export const ICON = { width: 96, height: 54, scale: 2 }
153
+
149
154
  // Pictures of looks on the sample deck, as WebP: { file, theme, palette,
150
- // slide } each, slide counting from 1.
155
+ // slide, icon } each, slide counting from 1. `icon`, if given, is a second
156
+ // file with the same slide at icon size: small enough for a list, and still
157
+ // showing the theme's type, colours and shapes.
151
158
  export function renderPreviews(shots, { extensions = {}, width = 960, height = 540 } = {}) {
152
159
  return withSampleDeck({ extensions }, async ({ open }) => {
153
- for (const { file, theme, palette = '', slide = 1 } of shots) {
160
+ for (const { file, theme, palette = '', slide = 1, icon = null } of shots) {
154
161
  const page = await open({ theme, palette })
155
162
  if (slide > 1) { await page.evaluate(`document.querySelector('deck-stage').goTo(${slide - 1})`); await settle(page) }
156
163
  mkdirSync(resolve(file, '..'), { recursive: true })
157
164
  writeFileSync(file, await page.screenshot({ width, height, format: 'webp' }))
165
+ if (icon) {
166
+ mkdirSync(resolve(icon, '..'), { recursive: true })
167
+ writeFileSync(icon, await page.screenshot({ ...ICON, format: 'webp' }))
168
+ }
158
169
  await page.close()
159
170
  }
160
171
  })
package/src/cli/args.js CHANGED
@@ -7,7 +7,8 @@ const commands = {
7
7
  run: { ...common, '--network': flag, '--server': optional, max: 1 },
8
8
  edit: { ...common, max: 1 },
9
9
  design: { ...common, max: 1 },
10
- themes: { '--local': flag, '--global': flag, '--force': flag, '--check': flag, '--output': value, '-o': '--output', max: 4 },
10
+ themes: { '--local': flag, '--force': flag, '--check': flag, '--output': value, '-o': '--output', max: 4 },
11
+ palettes: { '--local': flag, '--force': flag, max: 4 },
11
12
  build: { ...output, '--single-file': flag, '--launchers': flag, '--reader': flag, '--notes': flag, max: 1 },
12
13
  send: { ...output, '--notes': flag, '--no-pdf': flag, max: 1 },
13
14
  pdf: { ...output, max: 1 },
package/src/cli/index.js CHANGED
@@ -19,7 +19,7 @@ import { createPairing, pairingPlugin } from '../build/pairing.js'
19
19
  import { isAllowedRequest } from '../build/editorPlugin.js'
20
20
  import { renderPdf, attachPdf, findChrome } from '../build/pdf.js'
21
21
  import { installSkill, readSkill, TARGETS } from './skill.js'
22
- import { runThemes } from './themes.js'
22
+ import { runPackages } from './themes.js'
23
23
  import { ManifestError, KINDS } from '../extensions/manifest.js'
24
24
 
25
25
  import { frameworkRoot } from '../paths.js'
@@ -313,9 +313,10 @@ const HELP = `
313
313
  ${c.green}mdeck edit${c.reset} [slides.md] Edit slides in the browser; saves to the file
314
314
  ${c.green}mdeck design${c.reset} [folder | slides.md] Look at and fine-tune themes and palettes on a sample deck;
315
315
  saves to the folder's extensions/ (default: this folder)
316
- ${c.green}mdeck themes${c.reset} search [words] Themes and palettes: those that come with mdeck and the online repository
317
- install <pack>, remove <pack> for every deck; add slides.md or --local for one deck only
318
- update [pack], list --force replaces files changed by hand
316
+ ${c.green}mdeck themes${c.reset} search [words] Themes from mdeck and the theme repository; install <theme>
317
+ install <theme>, remove <theme> brings its default palette along; for every deck, or add
318
+ update [theme], list slides.md or --local for one deck; --force replaces edited files
319
+ ${c.green}mdeck palettes${c.reset} search | install | remove | update | list The same for palettes
319
320
 
320
321
  ${c.dim}Make something to hand out${c.reset}
321
322
  ${c.green}mdeck build${c.reset} [slides.md] [-o dir/index.html] A folder to host
@@ -613,8 +614,8 @@ if (command === 'new') {
613
614
  tip(`Changes are saved to the file as you type. Before the first change, a copy goes to ${BACKUP_DIR}/ beside it.\n`)
614
615
 
615
616
  // ── themes ────────────────────────────────────────────────────────────────────
616
- } else if (command === 'themes') {
617
- await runThemes({ positionals: positionals(), flag: name => hasFlag(name), output: outputOption, ui: { ok, err, tip, c } })
617
+ } else if (command === 'themes' || command === 'palettes') {
618
+ await runPackages({ kind: command.slice(0, -1), positionals: positionals(), flag: name => hasFlag(name), output: outputOption, ui: { ok, err, tip, c } })
618
619
 
619
620
  // ── design ────────────────────────────────────────────────────────────────────
620
621
  // The design page: with a deck, beside its editor; with a folder (or nothing),
package/src/cli/themes.js CHANGED
@@ -1,93 +1,96 @@
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')
28
+ // Each theme's icon for the gallery's list (see renderPreviews).
29
+ const icons = new Map(catalogue.themes.map(entry => [entry.id, entry.icon]))
26
30
  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
31
  return {
29
32
  mdeck: VERSION,
30
- themes: Object.values(manifestsOf(registry, 'theme')).map(theme => ({ id: theme.id, title: theme.title, description: theme.description ?? '', ...origin(theme.id),
31
- 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
+ themes: Object.values(manifestsOf(registry, 'theme')).map(theme => ({ id: theme.id, title: theme.title, description: theme.description ?? '', builtIn: builtIn.has(theme.id + ':t'),
34
+ palette: theme.palette, appearance: theme.appearance ?? 'light', palettes: palettesFor(theme, palettes).map(p => p.id),
35
+ ...(icons.get(theme.id) ? { icon: icons.get(theme.id) } : {}) })),
36
+ palettes: Object.values(palettes).map(palette => ({ id: palette.id, title: palette.title, description: palette.description ?? '', builtIn: builtIn.has(palette.id + ':p'),
37
+ ...(palette.theme ? { theme: palette.theme } : {}), light: colours(palette.light), dark: colours(palette.dark) })),
34
38
  }
35
39
  }
36
40
 
37
- export async function runThemes({ positionals, flag, output, ui: { ok, err, tip, c } }) {
41
+ export async function runPackages({ kind, positionals, flag, output, ui: { ok, err, tip, c } }) {
42
+ const kinds = `${kind}s`
38
43
  const [sub = 'search', ...rest] = positionals
39
44
  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('; ')
45
+ const warnOffline = catalogue => { if (catalogue.offline) tip(`The theme repository could not be reached (${catalogue.offline}); showing the ${kinds} that come with mdeck.`) }
46
+ const label = step => `the ${step.kind} ${step.id}`
42
47
  try {
43
48
  if (sub === 'search') {
44
- const catalogue = await loadCatalogue({ bundled: bundledPacks({ all: true }) })
49
+ const catalogue = await loadCatalogue()
45
50
  warnOffline(catalogue)
46
51
  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}`)
52
+ const forEvery = installedPackages(userExtensionsDir()), here = installedPackages(resolve('extensions')), hidden = removedBuiltIns()
53
+ 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)))
54
+ if (!found.length) return tip(`No ${kind} matches${words.length ? ` "${words.join(' ')}"` : ''}`)
55
+ for (const entry of found) {
56
+ const key = keyOf(kind, entry.id)
57
+ const have = here[key] ?? forEvery[key]
58
+ 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}`)
59
+ : entry.builtIn ? (hidden.includes(key) ? ` ${c.dim}(comes with mdeck, removed)${c.reset}` : ` ${c.green}(comes with mdeck)${c.reset}`)
60
+ : entry.builtInVersion ? ` ${c.yellow}(comes with mdeck as ${entry.builtInVersion}, update available)${c.reset}` : ''
61
+ console.log(`\n ${c.cyan}${entry.id}${c.reset} ${entry.version} — ${entry.title}${state}`)
62
+ if (entry.description) console.log(` ${entry.description}`)
63
+ const facts = [kind === 'theme' ? `palette: ${entry.palette}` : entry.theme ? `only for the theme ${entry.theme}` : '', entry.author ? `by ${entry.author}` : '', entry.license].filter(Boolean)
64
+ if (facts.length) console.log(` ${c.dim}${facts.join(' · ')}${c.reset}`)
59
65
  }
60
66
  console.log()
61
- return tip('Install one for every deck with: mdeck themes install <pack> (beside a deck: add slides.md or --local)')
67
+ return tip(`Install one for every deck with: mdeck ${kinds} install <${kind}> (beside a deck: add slides.md or --local)`)
62
68
  }
63
69
 
64
70
  if (sub === 'install') {
65
71
  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.') }
72
+ if (!id) { err(`Name a ${kind}.`); return tip(`Usage: mdeck ${kinds} install <${kind}> [slides.md or folder] [--local]; mdeck ${kinds} search lists them.`) }
67
73
  const at = target(where, local)
68
- const catalogue = await loadCatalogue({ bundled: bundledPacks({ all: true }) })
74
+ const catalogue = await loadCatalogue()
69
75
  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)}` : ''}`)
76
+ findEntry(catalogue, kind, id)
77
+ for (const step of await installHere(catalogue, kind, id, at, { mdeckVersion: VERSION, force })) {
78
+ const what = step.requested ? label(step) : `${label(step)}, which it needs,`
79
+ if (step.kept) tip(`${what[0].toUpperCase() + what.slice(1)} is there already`)
80
+ 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
81
+ else ok(`${step.replaced ? `Updated ${label(step)} from ${step.replaced} to ${step.version}` : `Installed ${label(step)} ${step.version}`} ${at.label}`)
75
82
  }
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')
83
+ tip(`Use it with "${kind}: ${id}" at the top of a slide file. Look at it on a sample deck: mdeck design`)
84
+ return
81
85
  }
82
86
 
83
87
  if (sub === 'remove') {
84
88
  const [id, where] = rest
85
- if (!id) { err('Name a pack.'); return tip('Usage: mdeck themes remove <pack> [slides.md or folder] [--local]') }
89
+ if (!id) { err(`Name a ${kind}.`); return tip(`Usage: mdeck ${kinds} remove <${kind}> [slides.md or folder] [--local]`) }
86
90
  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`)
91
+ for (const step of removeHere(kind, id, at, { force })) {
92
+ 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}`)
93
+ }
91
94
  return
92
95
  }
93
96
 
@@ -95,19 +98,18 @@ export async function runThemes({ positionals, flag, output, ui: { ok, err, tip,
95
98
  const [first, second] = rest
96
99
  const named = first && !/\.md$/i.test(first) && !(existsSync(first) && statSync(first).isDirectory()) ? first : null
97
100
  const at = target(named ? second : first, local)
98
- const catalogue = await loadCatalogue({ bundled: bundledPacks({ all: true }) })
101
+ const catalogue = await loadCatalogue()
99
102
  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}`)
103
+ const installed = Object.values(installedPackages(at.root)).filter(item => item.kind === kind)
104
+ const ids = named ? [named] : installed.map(item => item.id)
105
+ if (!ids.length) return tip(`No ${kinds} installed ${at.label}`)
103
106
  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}`)
107
+ const have = installed.find(item => item.id === id)
108
+ if (!have) throw new PackageError(`The ${kind} ${id} is not installed ${at.label}`)
109
+ const entry = findEntry(catalogue, kind, id)
110
+ if (entry.builtIn || compareVersions(entry.version, have.version ?? '0.0.0') <= 0) { tip(`The ${kind} ${id} ${have.version} is up to date`); continue }
111
+ await installHere(catalogue, kind, id, at, { mdeckVersion: VERSION, force })
112
+ ok(`Updated the ${kind} ${id} ${have.version} → ${entry.version}`)
111
113
  }
112
114
  loadRegistry(at.deck)
113
115
  return
@@ -115,54 +117,59 @@ export async function runThemes({ positionals, flag, output, ui: { ok, err, tip,
115
117
 
116
118
  if (sub === 'list') {
117
119
  const [where] = rest
118
- const hidden = removedPacks()
120
+ const hidden = removedBuiltIns()
121
+ const catalogue = await loadCatalogue({ online: false })
119
122
  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}` : ''}`)
123
+ 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
124
  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}` : ''}`)
125
+ for (const [title, root] of sections) {
126
+ const items = Object.values(installedPackages(root)).filter(item => item.kind === kind)
127
+ console.log(`\n ${c.bold}${title}${c.reset} ${c.dim}${root}${c.reset}`)
128
+ if (!items.length) console.log(` ${c.dim}none${c.reset}`)
129
+ for (const item of items) console.log(` ${c.cyan}${item.id}${c.reset} ${item.version}`)
127
130
  }
128
131
  console.log()
129
132
  return
130
133
  }
131
134
 
132
- if (sub === 'build') {
133
- const [packsDir] = rest
135
+ if (sub === 'build' && kind === 'theme') {
136
+ const [repoDir] = rest
134
137
  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 })
138
+ if (!repoDir || !out) { err('Name the repository folder and the output folder.'); return tip('Usage: mdeck themes build <repository folder> -o <output folder> [--check]') }
139
+ const { catalogue, dirs } = buildRepository(resolve(repoDir), resolve(out))
138
140
  // 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'
141
+ // the built-in ones are in it already.
142
+ const repo = [...catalogue.themes.map(e => ({ ...e, kind: 'theme' })), ...catalogue.palettes.map(e => ({ ...e, kind: 'palette' }))].filter(entry => !entry.builtIn)
143
+ const extensions = Object.fromEntries(repo.map(entry => [keyOf(entry.kind, entry.id), dirs[keyOf(entry.kind, entry.id)]]))
143
144
  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 }))])
145
+ const looks = [...catalogue.themes, ...catalogue.palettes.map(p => ({ ...p, isPalette: true }))].map(entry => entry.isPalette
146
+ ? { key: `palettes/${entry.id}`, theme: entry.theme ?? 'neue', palette: entry.id }
147
+ : { key: `themes/${entry.id}`, theme: entry.id })
145
148
  if (flag('--check')) {
146
149
  const failures = await checkLooks(looks, { extensions })
147
150
  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`)
151
+ ok(`Checked ${catalogue.themes.length} themes and ${catalogue.palettes.length} palettes, light and dark, on every kind of slide`)
149
152
  }
150
153
  // 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')
154
+ // Themes also get an icon of their title slide for lists; a palette's
155
+ // colours are its icon.
156
+ await renderPreviews(looks.map(look => ({ file: resolve(out, 'previews', `${look.key}.webp`), theme: look.theme, palette: look.palette ?? '', slide: look.palette ? 3 : 1,
157
+ ...(look.palette ? {} : { icon: resolve(out, 'previews', `${look.key}-icon.webp`) }) })), { extensions })
158
+ for (const entry of catalogue.themes) Object.assign(entry, { preview: `previews/themes/${entry.id}.webp`, icon: `previews/themes/${entry.id}-icon.webp` })
159
+ for (const entry of catalogue.palettes) entry.preview = `previews/palettes/${entry.id}.webp`
160
+ writeFileSync(resolve(out, 'catalogue.json'), JSON.stringify(catalogue, null, 2) + '\n')
154
161
  // For the repository's theme browser: the sample deck with every theme
155
162
  // and palette, and what each theme offers.
156
163
  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)}`)
164
+ writeFileSync(resolve(out, 'looks.json'), JSON.stringify(looksOf(registry, catalogue), null, 2) + '\n')
165
+ return ok(`Built ${catalogue.themes.length} themes and ${catalogue.palettes.length} palettes into ${resolve(out)}`)
159
166
  }
160
167
 
161
- err(`Unknown: mdeck themes ${sub}`)
162
- tip(`Use: mdeck themes ${USAGE}`)
168
+ err(`Unknown: mdeck ${kinds} ${sub}`)
169
+ tip(`Use: mdeck ${kinds} ${usage(kind)}`)
163
170
  process.exitCode = 1
164
171
  } catch (error) {
165
- if (!(error instanceof PackError || error instanceof ManifestError)) throw error
172
+ if (!(error instanceof PackageError || error instanceof ManifestError)) throw error
166
173
  err(error.message)
167
174
  process.exitCode = 1
168
175
  }
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