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.
- package/CHANGELOG.md +11 -1
- package/assets/{packs/lagoon → extensions/palettes}/lagoon/extension.toml +3 -0
- package/assets/{packs/aurora → extensions/palettes}/neon/extension.toml +3 -0
- package/assets/{packs/academic → extensions/palettes}/nordic/extension.toml +3 -0
- package/assets/{packs/minimal → extensions/palettes}/paper/extension.toml +3 -0
- package/assets/{packs/neue → extensions/palettes}/swiss/extension.toml +3 -0
- package/assets/{packs/academic → extensions/themes}/academic/extension.toml +3 -0
- package/assets/{packs/aurora → extensions/themes}/aurora/extension.toml +3 -0
- package/assets/{packs/minimal → extensions/themes}/minimal/extension.toml +3 -0
- package/assets/{packs/neue → extensions/themes}/neue/extension.toml +3 -0
- package/docs/reference/authoring.md +3 -3
- package/docs/reference/extensions.md +40 -35
- package/docs/reference/palettes.md +1 -1
- package/docs/reference/themes.md +3 -3
- package/docs/site/content/appearance.md +11 -9
- package/docs/site/content/commands.md +2 -1
- package/docs/site/content/theme-authoring.md +1 -1
- package/docs/site/content/theme-repository.md +36 -28
- package/package.json +2 -2
- package/skills/write-slides/SKILL.md +1 -1
- package/src/build/editorPlugin.js +22 -17
- package/src/build/themeCheck.js +2 -1
- package/src/cli/args.js +2 -1
- package/src/cli/index.js +7 -6
- package/src/cli/themes.js +92 -91
- package/src/editor/api.js +11 -10
- package/src/editor/components/DesignApp.jsx +9 -11
- package/src/editor/components/ExtensionEditor.jsx +10 -11
- package/src/editor/components/ExtensionsPanel.jsx +24 -28
- package/src/extensions/discover.js +28 -35
- package/src/extensions/installed.js +73 -48
- package/src/extensions/manifest.js +20 -3
- package/src/extensions/moved.js +14 -7
- package/src/extensions/packages.js +318 -0
- package/src/paths.js +0 -2
- package/assets/packs/academic/pack.toml +0 -8
- package/assets/packs/aurora/pack.toml +0 -8
- package/assets/packs/lagoon/pack.toml +0 -8
- package/assets/packs/minimal/pack.toml +0 -8
- package/assets/packs/neue/pack.toml +0 -8
- package/src/extensions/packs.js +0 -357
- /package/assets/{packs/academic → extensions/themes}/academic/styles.css +0 -0
- /package/assets/{packs/aurora → extensions/themes}/aurora/styles.css +0 -0
- /package/assets/{packs/minimal → extensions/themes}/minimal/styles.css +0 -0
- /package/assets/{packs/neue → extensions/themes}/neue/styles.css +0 -0
package/src/extensions/packs.js
DELETED
|
@@ -1,357 +0,0 @@
|
|
|
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/).
|
|
9
|
-
import { createHash } from 'node:crypto'
|
|
10
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
11
|
-
import { resolve, relative } from 'node:path'
|
|
12
|
-
import { parse } from 'smol-toml'
|
|
13
|
-
import { ID_RE, ManifestError, parseManifestText, validateManifest } from './manifest.js'
|
|
14
|
-
|
|
15
|
-
export const INDEX_URL = 'https://gh.tschieber.de/mdeck-themes/index.json'
|
|
16
|
-
export const MARKER = '.mdeck-pack.json'
|
|
17
|
-
export const PACK_FILE = 'pack.toml'
|
|
18
|
-
const FILES = ['extension.toml', 'styles.css']
|
|
19
|
-
const FILE_RE = /^([a-z][a-z0-9-]*)\/(extension\.toml|styles\.css)$/
|
|
20
|
-
const VERSION_RE = /^\d+\.\d+\.\d+$/
|
|
21
|
-
const RANGE_RE = /^>=\s*(\d+\.\d+\.\d+)$/
|
|
22
|
-
// Where theme fonts may come from: stylesheet services that only serve fonts.
|
|
23
|
-
export const FONT_HOSTS = ['https://fonts.googleapis.com/', 'https://fonts.bunny.net/']
|
|
24
|
-
|
|
25
|
-
export class PackError extends Error {}
|
|
26
|
-
const fail = message => { throw new PackError(message) }
|
|
27
|
-
|
|
28
|
-
export const sha256 = text => createHash('sha256').update(text, 'utf8').digest('hex')
|
|
29
|
-
|
|
30
|
-
export function indexUrl() {
|
|
31
|
-
return process.env.MDECK_THEMES_URL || INDEX_URL
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const parts = version => version.split('.').map(Number)
|
|
35
|
-
export function compareVersions(a, b) {
|
|
36
|
-
const [x, y] = [parts(a), parts(b)]
|
|
37
|
-
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i]
|
|
38
|
-
return 0
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Ranges are only of the form ">=1.2.3"; no range means any version.
|
|
42
|
-
export function satisfies(range, version) {
|
|
43
|
-
if (!range) return true
|
|
44
|
-
const match = RANGE_RE.exec(range)
|
|
45
|
-
return Boolean(match) && compareVersions(version, match[1]) >= 0
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function checkMeta(meta, where) {
|
|
49
|
-
if (meta.schema !== 1) fail(`${where}: schema must be 1`)
|
|
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`)
|
|
51
|
-
if (typeof meta.version !== 'string' || !VERSION_RE.test(meta.version)) fail(`${where}: version must look like 1.0.0`)
|
|
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`)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// pack.toml and the files of each theme or palette in a pack folder.
|
|
57
|
-
export function readPackDir(dir) {
|
|
58
|
-
const file = resolve(dir, PACK_FILE)
|
|
59
|
-
if (!existsSync(file)) fail(`${dir} has no ${PACK_FILE}`)
|
|
60
|
-
let meta
|
|
61
|
-
try { meta = parse(readFileSync(file, 'utf8')) } catch (error) { fail(`${file}: ${error.message.split('\n')[0]}`) }
|
|
62
|
-
checkMeta(meta, file)
|
|
63
|
-
for (const key of ['title', 'author', 'license']) if (typeof meta[key] !== 'string' || !meta[key].trim()) fail(`${file}: ${key} is required`)
|
|
64
|
-
const folder = dir.split(/[\\/]/).filter(Boolean).at(-1)
|
|
65
|
-
if (meta.id !== folder) fail(`${file}: id "${meta.id}" must be the folder name "${folder}"`)
|
|
66
|
-
const files = {}
|
|
67
|
-
for (const entry of readdirSync(dir).sort()) {
|
|
68
|
-
if (entry === PACK_FILE || entry.startsWith('.') || entry === 'README.md') continue
|
|
69
|
-
const sub = resolve(dir, entry)
|
|
70
|
-
if (!statSync(sub).isDirectory()) fail(`${sub}: a pack holds only ${PACK_FILE}, README.md and one folder per theme or palette`)
|
|
71
|
-
for (const name of readdirSync(sub).sort()) {
|
|
72
|
-
if (!FILES.includes(name)) fail(`${resolve(sub, name)}: a theme or palette in a pack holds only ${FILES.join(' and ')}`)
|
|
73
|
-
files[`${entry}/${name}`] = readFileSync(resolve(sub, name), 'utf8')
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
return { meta, files }
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// The published form of a pack folder: one JSON text and its checksum.
|
|
80
|
-
export function bundlePack(dir) {
|
|
81
|
-
const { meta, files } = readPackDir(dir)
|
|
82
|
-
const bundle = { schema: 1, id: meta.id, version: meta.version, ...(meta.mdeck ? { mdeck: meta.mdeck } : {}), ...(meta.requires?.length ? { requires: meta.requires } : {}), files }
|
|
83
|
-
const text = JSON.stringify(bundle, null, 1) + '\n'
|
|
84
|
-
return { meta, bundle, text, sha256: sha256(text) }
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function checkCss(text, where) {
|
|
88
|
-
if (/@import/i.test(text)) fail(`${where}: @import is not allowed; list font stylesheets under fonts in extension.toml`)
|
|
89
|
-
for (const match of text.matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi)) {
|
|
90
|
-
if (!match[2].trim().startsWith('data:')) fail(`${where}: url(${match[2]}) is not allowed; only data: URLs`)
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
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 = {} } = {}) {
|
|
99
|
-
if (!bundle || typeof bundle !== 'object' || typeof bundle.files !== 'object' || !bundle.files) fail('Not a pack')
|
|
100
|
-
checkMeta(bundle, `Pack ${bundle.id ?? '?'}`)
|
|
101
|
-
const byId = {}
|
|
102
|
-
for (const [path, text] of Object.entries(bundle.files)) {
|
|
103
|
-
const match = FILE_RE.exec(path)
|
|
104
|
-
if (!match) fail(`${bundle.id}: "${path}" is not allowed; a pack holds only <id>/extension.toml and <id>/styles.css`)
|
|
105
|
-
if (typeof text !== 'string') fail(`${bundle.id}: ${path} is not text`)
|
|
106
|
-
;(byId[match[1]] ??= {})[match[2]] = text
|
|
107
|
-
}
|
|
108
|
-
if (!Object.keys(byId).length) fail(`${bundle.id}: the pack is empty`)
|
|
109
|
-
const records = []
|
|
110
|
-
for (const [id, files] of Object.entries(byId)) {
|
|
111
|
-
if (!files['extension.toml']) fail(`${bundle.id}: ${id}/extension.toml is missing`)
|
|
112
|
-
const dir = `/pack/${id}`
|
|
113
|
-
const file = `${dir}/extension.toml`
|
|
114
|
-
let record
|
|
115
|
-
try {
|
|
116
|
-
record = validateManifest(parseManifestText(files['extension.toml'], file), { file, dir, folderName: id, fileExists: path => Object.hasOwn(files, relative(dir, path)) })
|
|
117
|
-
} catch (error) {
|
|
118
|
-
if (error instanceof ManifestError) fail(`${bundle.id}: ${id}/extension.toml: ${error.message}`)
|
|
119
|
-
throw error
|
|
120
|
-
}
|
|
121
|
-
if (!['theme', 'palette'].includes(record.kind)) fail(`${bundle.id}: ${id} is a ${record.kind}; packs hold only themes and palettes`)
|
|
122
|
-
if (files['styles.css'] !== undefined && record.kind !== 'theme') fail(`${bundle.id}: ${id}/styles.css belongs only to a theme`)
|
|
123
|
-
if (record.kind === 'theme') {
|
|
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(', ')}`)
|
|
125
|
-
checkCss(files['styles.css'] ?? '', `${bundle.id}: ${id}/styles.css`)
|
|
126
|
-
}
|
|
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]}`)
|
|
128
|
-
records.push(record)
|
|
129
|
-
}
|
|
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(', ')}` : ''
|
|
133
|
-
for (const record of records) {
|
|
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}`)
|
|
138
|
-
}
|
|
139
|
-
return { themes: Object.keys(themes), palettes: Object.keys(palettes), manifests: { themes, palettes } }
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function readMarker(dir) {
|
|
143
|
-
try { return JSON.parse(readFileSync(resolve(dir, MARKER), 'utf8')) } catch { return null }
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Files changed since the pack was installed, by name.
|
|
147
|
-
function changedFiles(dir, marker) {
|
|
148
|
-
return Object.entries(marker.files ?? {}).filter(([name, hash]) => {
|
|
149
|
-
const file = resolve(dir, name)
|
|
150
|
-
return !existsSync(file) || sha256(readFileSync(file, 'utf8')) !== hash
|
|
151
|
-
}).map(([name]) => name)
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// The packs installed in an extensions folder: { id: { version, url, folders } }.
|
|
155
|
-
export function installedPacks(root) {
|
|
156
|
-
const packs = {}
|
|
157
|
-
if (!existsSync(root)) return packs
|
|
158
|
-
for (const entry of readdirSync(root).sort()) {
|
|
159
|
-
const marker = readMarker(resolve(root, entry))
|
|
160
|
-
if (!marker?.pack) continue
|
|
161
|
-
const pack = packs[marker.pack] ??= { version: marker.version, url: marker.url, requires: marker.requires ?? [], folders: [] }
|
|
162
|
-
pack.folders.push(entry)
|
|
163
|
-
}
|
|
164
|
-
return packs
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const refuseChanged = (root, folder, marker) => {
|
|
168
|
-
const changed = changedFiles(resolve(root, folder), marker)
|
|
169
|
-
if (changed.length) fail(`${resolve(root, folder)} was changed since it was installed (${changed.join(', ')}); add --force to replace or remove it anyway`)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Writes a checked pack into an extensions folder (a deck's, or the user's).
|
|
173
|
-
// A folder of another pack or of the deck itself is never touched; a folder
|
|
174
|
-
// of this pack is replaced unless it was changed by hand.
|
|
175
|
-
export function installPack(bundle, { root, provided, owners, url = null, digest = null, force = false } = {}) {
|
|
176
|
-
const { themes, palettes } = checkPack(bundle, { provided, owners })
|
|
177
|
-
const ids = [...themes, ...palettes]
|
|
178
|
-
const before = installedPacks(root)[bundle.id]
|
|
179
|
-
for (const id of ids) {
|
|
180
|
-
const dir = resolve(root, id)
|
|
181
|
-
if (!existsSync(dir)) continue
|
|
182
|
-
const marker = readMarker(dir)
|
|
183
|
-
if (marker?.pack !== bundle.id) fail(`${dir} already exists and is not from the pack ${bundle.id}; rename or remove it first`)
|
|
184
|
-
if (!force) refuseChanged(root, id, marker)
|
|
185
|
-
}
|
|
186
|
-
const gone = (before?.folders ?? []).filter(folder => !ids.includes(folder))
|
|
187
|
-
if (!force) for (const folder of gone) refuseChanged(root, folder, readMarker(resolve(root, folder)))
|
|
188
|
-
for (const folder of gone) rmSync(resolve(root, folder), { recursive: true, force: true })
|
|
189
|
-
for (const id of ids) {
|
|
190
|
-
// Files are overwritten in place, not deleted and written again, so a
|
|
191
|
-
// running editor sees a change instead of a file that disappears.
|
|
192
|
-
const dir = resolve(root, id)
|
|
193
|
-
mkdirSync(dir, { recursive: true })
|
|
194
|
-
const hashes = {}
|
|
195
|
-
for (const [path, content] of Object.entries(bundle.files)) {
|
|
196
|
-
if (!path.startsWith(`${id}/`)) continue
|
|
197
|
-
const name = path.slice(id.length + 1)
|
|
198
|
-
writeFileSync(resolve(dir, name), content, 'utf8')
|
|
199
|
-
hashes[name] = sha256(content)
|
|
200
|
-
}
|
|
201
|
-
for (const name of readdirSync(dir)) if (name !== MARKER && !Object.hasOwn(hashes, name)) rmSync(resolve(dir, name), { recursive: true, force: true })
|
|
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')
|
|
203
|
-
}
|
|
204
|
-
return { themes, palettes, replaced: before?.version ?? null }
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export function removePack(id, { root, force = false } = {}) {
|
|
208
|
-
const pack = installedPacks(root)[id]
|
|
209
|
-
if (!pack) fail(`The pack ${id} is not installed in ${root}`)
|
|
210
|
-
if (!force) for (const folder of pack.folders) refuseChanged(root, folder, readMarker(resolve(root, folder)))
|
|
211
|
-
for (const folder of pack.folders) rmSync(resolve(root, folder), { recursive: true, force: true })
|
|
212
|
-
return pack
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
async function get(url, as) {
|
|
216
|
-
let response
|
|
217
|
-
try { response = await fetch(url) } catch (error) { fail(`Could not reach ${url}: ${error.cause?.message ?? error.message}`) }
|
|
218
|
-
if (!response.ok) fail(`${url} answered ${response.status}`)
|
|
219
|
-
return as === 'json' ? response.json() : response.text()
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
export async function fetchIndex(url = indexUrl()) {
|
|
223
|
-
const index = await get(url, 'json')
|
|
224
|
-
if (index?.schema !== 1 || !Array.isArray(index.packs)) fail(`${url} is not a theme repository index`)
|
|
225
|
-
return { url, packs: index.packs }
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Downloads a pack listed in the index and checks that it is the one listed.
|
|
229
|
-
export async function fetchPack(index, entry) {
|
|
230
|
-
const url = new URL(entry.url, index.url).href
|
|
231
|
-
const text = await get(url, 'text')
|
|
232
|
-
if (sha256(text) !== entry.sha256) fail(`${url} does not match its checksum in the index; not installed`)
|
|
233
|
-
let bundle
|
|
234
|
-
try { bundle = JSON.parse(text) } catch { fail(`${url} is not a pack`) }
|
|
235
|
-
if (bundle.id !== entry.id || bundle.version !== entry.version) fail(`${url} holds ${bundle.id} ${bundle.version}, not ${entry.id} ${entry.version}`)
|
|
236
|
-
return { bundle, url, digest: entry.sha256 }
|
|
237
|
-
}
|
|
238
|
-
|
|
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')) }
|
|
263
|
-
}
|
|
264
|
-
|
|
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`}`)
|
|
267
|
-
}
|
|
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
|
-
|
|
313
|
-
// The theme repository as served: index.json and packs/<id>-<version>.json,
|
|
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
|
|
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)
|
|
348
|
-
const file = `packs/${meta.id}-${meta.version}.json`
|
|
349
|
-
writeFileSync(resolve(outDir, file), text)
|
|
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
|
-
})
|
|
354
|
-
const index = { schema: 1, packs: entries }
|
|
355
|
-
writeFileSync(resolve(outDir, 'index.json'), JSON.stringify(index, null, 2) + '\n')
|
|
356
|
-
return index
|
|
357
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|