mdeck 2.3.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 +259 -0
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/assets/base.css +196 -0
- package/assets/extensions/layouts/chapter/extension.toml +26 -0
- package/assets/extensions/layouts/chapter/layout.jsx +21 -0
- package/assets/extensions/layouts/chapter/starter.md +8 -0
- package/assets/extensions/layouts/focus/extension.toml +8 -0
- package/assets/extensions/layouts/focus/layout.jsx +10 -0
- package/assets/extensions/layouts/focus/starter.md +4 -0
- package/assets/extensions/layouts/full-bleed-image/extension.toml +31 -0
- package/assets/extensions/layouts/full-bleed-image/layout.jsx +19 -0
- package/assets/extensions/layouts/full-bleed-image/starter.md +6 -0
- package/assets/extensions/layouts/generic/extension.toml +8 -0
- package/assets/extensions/layouts/generic/layout.jsx +8 -0
- package/assets/extensions/layouts/generic/starter.md +4 -0
- package/assets/extensions/layouts/image-text/extension.toml +25 -0
- package/assets/extensions/layouts/image-text/layout.jsx +17 -0
- package/assets/extensions/layouts/image-text/starter.md +6 -0
- package/assets/extensions/layouts/split/extension.toml +25 -0
- package/assets/extensions/layouts/split/layout.jsx +19 -0
- package/assets/extensions/layouts/split/starter.md +14 -0
- package/assets/extensions/layouts/title/extension.toml +26 -0
- package/assets/extensions/layouts/title/layout.jsx +14 -0
- package/assets/extensions/layouts/title/starter.md +5 -0
- package/assets/extensions/palettes/brand/extension.toml +29 -0
- package/assets/extensions/palettes/cobalt/extension.toml +27 -0
- package/assets/extensions/palettes/ember/extension.toml +27 -0
- package/assets/extensions/palettes/forest/extension.toml +27 -0
- package/assets/extensions/palettes/graphite/extension.toml +27 -0
- package/assets/extensions/palettes/lagoon/extension.toml +27 -0
- package/assets/extensions/palettes/neon/extension.toml +27 -0
- package/assets/extensions/palettes/nordic/extension.toml +27 -0
- package/assets/extensions/palettes/pastel/extension.toml +30 -0
- package/assets/extensions/palettes/phosphor/extension.toml +27 -0
- package/assets/extensions/palettes/swiss/extension.toml +27 -0
- package/assets/extensions/palettes/terra/extension.toml +27 -0
- package/assets/extensions/themes/academic/extension.toml +53 -0
- package/assets/extensions/themes/academic/styles.css +631 -0
- package/assets/extensions/themes/aurora/extension.toml +37 -0
- package/assets/extensions/themes/aurora/styles.css +644 -0
- package/assets/extensions/themes/duet/extension.toml +46 -0
- package/assets/extensions/themes/duet/styles.css +573 -0
- package/assets/extensions/themes/editorial/extension.toml +46 -0
- package/assets/extensions/themes/editorial/styles.css +574 -0
- package/assets/extensions/themes/fhnw/extension.toml +61 -0
- package/assets/extensions/themes/fhnw/styles.css +604 -0
- package/assets/extensions/themes/neue/extension.toml +46 -0
- package/assets/extensions/themes/neue/styles.css +489 -0
- package/assets/extensions/themes/sketch/extension.toml +48 -0
- package/assets/extensions/themes/sketch/styles.css +633 -0
- package/assets/extensions/themes/terminal/extension.toml +38 -0
- package/assets/extensions/themes/terminal/styles.css +550 -0
- package/assets/logo/avatar.png +0 -0
- package/assets/logo/logo-dark.svg +10 -0
- package/assets/logo/logo-light.svg +10 -0
- package/assets/logo/mark.svg +15 -0
- package/bin/mdeck.js +2 -0
- package/docs/README.md +8 -0
- package/docs/reference/authoring.md +795 -0
- package/docs/reference/claude-skill.md +96 -0
- package/docs/reference/extensions.md +250 -0
- package/docs/reference/layouts.md +153 -0
- package/docs/reference/palettes.md +106 -0
- package/docs/reference/structured-slides.md +120 -0
- package/docs/reference/themes.md +133 -0
- package/docs/site/assets/favicon.svg +15 -0
- package/docs/site/assets/github-logo.svg +1 -0
- package/docs/site/assets/site.css +162 -0
- package/docs/site/assets/site.js +123 -0
- package/docs/site/build.js +148 -0
- package/docs/site/content/appearance.md +203 -0
- package/docs/site/content/audience.md +178 -0
- package/docs/site/content/commands.md +153 -0
- package/docs/site/content/components.md +134 -0
- package/docs/site/content/drawing.md +72 -0
- package/docs/site/content/editing.md +59 -0
- package/docs/site/content/getting-started.md +103 -0
- package/docs/site/content/layouts.md +126 -0
- package/docs/site/content/media.md +107 -0
- package/docs/site/content/more-content.md +130 -0
- package/docs/site/content/presenting.md +93 -0
- package/docs/site/content/reusable-layouts.md +74 -0
- package/docs/site/content/sharing.md +88 -0
- package/docs/site/content/theme-authoring.md +144 -0
- package/docs/site/content/theme-repository.md +76 -0
- package/docs/site/content/troubleshooting.md +92 -0
- package/docs/site/content/views.md +56 -0
- package/docs/site/content/welcome.md +71 -0
- package/docs/site/content/writing.md +121 -0
- package/docs/site/examples/first-talk.md +88 -0
- package/docs/site/examples/notebook.svg +58 -0
- package/docs/site/images/themes/academic-dark.webp +0 -0
- package/docs/site/images/themes/academic-light.webp +0 -0
- package/docs/site/images/themes/academic.webp +0 -0
- package/docs/site/images/themes/aurora-dark.webp +0 -0
- package/docs/site/images/themes/aurora-light.webp +0 -0
- package/docs/site/images/themes/aurora.webp +0 -0
- package/docs/site/images/themes/duet-dark.webp +0 -0
- package/docs/site/images/themes/duet-light.webp +0 -0
- package/docs/site/images/themes/duet.webp +0 -0
- package/docs/site/images/themes/editorial-dark.webp +0 -0
- package/docs/site/images/themes/editorial-light.webp +0 -0
- package/docs/site/images/themes/editorial.webp +0 -0
- package/docs/site/images/themes/fhnw.webp +0 -0
- package/docs/site/images/themes/neue-dark.webp +0 -0
- package/docs/site/images/themes/neue-light.webp +0 -0
- package/docs/site/images/themes/neue.webp +0 -0
- package/docs/site/images/themes/sketch-dark.webp +0 -0
- package/docs/site/images/themes/sketch-light.webp +0 -0
- package/docs/site/images/themes/sketch.webp +0 -0
- package/docs/site/images/themes/terminal-dark.webp +0 -0
- package/docs/site/images/themes/terminal-light.webp +0 -0
- package/docs/site/images/themes/terminal.webp +0 -0
- package/docs/site/package.json +9 -0
- package/docs/site/pages.js +27 -0
- package/docs/site/server.js +58 -0
- package/examples/custom-layouts/extensions/comparison/extension.toml +35 -0
- package/examples/custom-layouts/extensions/comparison/layout.jsx +15 -0
- package/examples/custom-layouts/extensions/comparison/starter.md +17 -0
- package/examples/custom-layouts/extensions/comparison/styles.css +4 -0
- package/examples/custom-layouts/slides.md +60 -0
- package/examples/poll/slides.md +127 -0
- package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T08-36-47Z.json +19 -0
- package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T11-27-30Z.json +6 -0
- package/examples/python/slides.drawings.json +6 -0
- package/examples/python/slides.md +493 -0
- package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T08-08-30Z.json +10 -0
- package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T11-41-35Z.json +13 -0
- package/examples/showcase/img/image.jpg +0 -0
- package/examples/showcase/img/mdeck.svg +66 -0
- package/examples/showcase/slides.drawings.json +11 -0
- package/examples/showcase/slides.md +427 -0
- package/package.json +75 -0
- package/skills/write-slides/SKILL.md +144 -0
- package/src/build/check.js +49 -0
- package/src/build/chrome.js +130 -0
- package/src/build/components.js +44 -0
- package/src/build/config.js +61 -0
- package/src/build/drawings.js +14 -0
- package/src/build/editorPlugin.js +272 -0
- package/src/build/editorServer.js +36 -0
- package/src/build/fonts.js +84 -0
- package/src/build/homePlugin.js +208 -0
- package/src/build/inkPlugin.js +192 -0
- package/src/build/ownWrites.js +18 -0
- package/src/build/pairing.js +81 -0
- package/src/build/pdf.js +34 -0
- package/src/build/relayAccess.js +85 -0
- package/src/build/slidesPlugin.js +371 -0
- package/src/build/themeCheck.js +151 -0
- package/src/build/tunnelClient.js +184 -0
- package/src/cli/args.js +76 -0
- package/src/cli/index.js +758 -0
- package/src/cli/migrate.js +123 -0
- package/src/cli/skill.js +40 -0
- package/src/cli/themes.js +140 -0
- package/src/components/CodeBlock.jsx +168 -0
- package/src/components/ConnectedViews.jsx +25 -0
- package/src/components/Icon.jsx +8 -0
- package/src/components/Poll.jsx +67 -0
- package/src/components/QrCode.jsx +29 -0
- package/src/components/QrCodeTag.jsx +27 -0
- package/src/components/Question.jsx +28 -0
- package/src/components/Scale.jsx +47 -0
- package/src/components/VideoPlayer.jsx +108 -0
- package/src/components/WordCloud.jsx +106 -0
- package/src/components/activity.jsx +35 -0
- package/src/components/callout.css +78 -0
- package/src/components/code-block.css +123 -0
- package/src/components/columns.css +11 -0
- package/src/components/poll.css +81 -0
- package/src/components/video-player.css +14 -0
- package/src/core/devPath.js +4 -0
- package/src/core/editDeck.js +230 -0
- package/src/core/icons.js +69 -0
- package/src/core/ink.js +221 -0
- package/src/core/labels.js +144 -0
- package/src/core/outline.js +33 -0
- package/src/core/parseSlides.js +152 -0
- package/src/core/source.js +137 -0
- package/src/core/urls.js +34 -0
- package/src/core/validateDeck.js +139 -0
- package/src/editor/api.js +67 -0
- package/src/editor/components/App.jsx +75 -0
- package/src/editor/components/ConflictBanner.jsx +10 -0
- package/src/editor/components/DeckSettings.jsx +108 -0
- package/src/editor/components/DesignApp.jsx +252 -0
- package/src/editor/components/Diagnostics.jsx +10 -0
- package/src/editor/components/ExtensionEditor.jsx +66 -0
- package/src/editor/components/ExtensionForms.jsx +134 -0
- package/src/editor/components/ExtensionsPanel.jsx +114 -0
- package/src/editor/components/Field.jsx +24 -0
- package/src/editor/components/Inspector.jsx +36 -0
- package/src/editor/components/LayoutPicker.jsx +23 -0
- package/src/editor/components/LayoutSelect.jsx +25 -0
- package/src/editor/components/LookThumbnail.jsx +8 -0
- package/src/editor/components/MetaFields.jsx +24 -0
- package/src/editor/components/Outline.jsx +28 -0
- package/src/editor/components/PalettePicker.jsx +32 -0
- package/src/editor/components/Preview.jsx +63 -0
- package/src/editor/components/PropsForm.jsx +46 -0
- package/src/editor/components/RawSlideEditor.jsx +9 -0
- package/src/editor/components/RegionEditor.jsx +15 -0
- package/src/editor/components/Resizer.jsx +18 -0
- package/src/editor/designLink.js +19 -0
- package/src/editor/editor.css +139 -0
- package/src/editor/extensions.js +86 -0
- package/src/editor/keyboard.js +21 -0
- package/src/editor/model.js +38 -0
- package/src/editor/sampleDeck.js +165 -0
- package/src/editor/saveQueue.js +47 -0
- package/src/editor/schema.js +36 -0
- package/src/editor/state.js +92 -0
- package/src/editor/useDeckDocument.js +67 -0
- package/src/extensions/appearance.js +79 -0
- package/src/extensions/contrast.js +57 -0
- package/src/extensions/discover.js +126 -0
- package/src/extensions/manifest.js +209 -0
- package/src/extensions/packs.js +268 -0
- package/src/extensions/tokens.js +25 -0
- package/src/home/Home.jsx +362 -0
- package/src/home/home.css +148 -0
- package/src/layouts/layoutApi.jsx +205 -0
- package/src/layouts/layoutProps.js +30 -0
- package/src/layouts/renderSlide.jsx +17 -0
- package/src/live/answer/answer.css +34 -0
- package/src/live/answer/answer.html +14 -0
- package/src/live/answer/answer.js +130 -0
- package/src/live/client.js +290 -0
- package/src/live/code.js +19 -0
- package/src/live/follow.js +17 -0
- package/src/live/pairing.js +34 -0
- package/src/live/presence.js +33 -0
- package/src/live/roomTag.js +38 -0
- package/src/live/rooms.js +249 -0
- package/src/live/server.js +82 -0
- package/src/live/tunnel.js +180 -0
- package/src/paths.js +9 -0
- package/src/runtime/ReaderView.jsx +193 -0
- package/src/runtime/SlideErrorBoundary.jsx +28 -0
- package/src/runtime/chrome.jsx +55 -0
- package/src/runtime/deck-stage.js +1533 -0
- package/src/runtime/design.html +12 -0
- package/src/runtime/design.jsx +5 -0
- package/src/runtime/editor.html +12 -0
- package/src/runtime/editor.jsx +5 -0
- package/src/runtime/editorBridge.js +58 -0
- package/src/runtime/fit.js +149 -0
- package/src/runtime/home.html +13 -0
- package/src/runtime/home.jsx +5 -0
- package/src/runtime/index.html +15 -0
- package/src/runtime/ink/InkLayer.jsx +17 -0
- package/src/runtime/ink/Toolbar.jsx +145 -0
- package/src/runtime/ink/attach.js +69 -0
- package/src/runtime/ink/bus.js +126 -0
- package/src/runtime/ink/controller.js +132 -0
- package/src/runtime/ink/room.js +101 -0
- package/src/runtime/ink/select.js +124 -0
- package/src/runtime/ink/server.js +75 -0
- package/src/runtime/ink/store.js +55 -0
- package/src/runtime/ink/toolbar.css +79 -0
- package/src/runtime/main.jsx +863 -0
- package/src/runtime/markedSetup.js +147 -0
- package/src/runtime/reader.css +59 -0
- package/src/runtime/registry.jsx +26 -0
- package/src/runtime/syncOrder.js +32 -0
- package/src/runtime/themeLoader.js +68 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The deck file as both editor pages see it: loaded once, edited through
|
|
2
|
+
// `edit`, saved in the background, and kept in step with changes on disk,
|
|
3
|
+
// including those the other page makes.
|
|
4
|
+
import { useEffect, useMemo, useReducer, useRef, useState } from 'preact/hooks'
|
|
5
|
+
import { reduce, initialState } from './state.js'
|
|
6
|
+
import { loadDeck, saveSource, onServerEvent } from './api.js'
|
|
7
|
+
import { createSaveQueue } from './saveQueue.js'
|
|
8
|
+
|
|
9
|
+
export function useDeckDocument() {
|
|
10
|
+
const [state, dispatch] = useReducer(reduce, initialState)
|
|
11
|
+
const stateRef = useRef(state)
|
|
12
|
+
stateRef.current = state
|
|
13
|
+
const [loadError, setLoadError] = useState(null)
|
|
14
|
+
const [previewKey, setPreviewKey] = useState(0)
|
|
15
|
+
|
|
16
|
+
const queue = useMemo(() => createSaveQueue({
|
|
17
|
+
save: saveSource,
|
|
18
|
+
onSaving: () => dispatch({ type: 'saving' }),
|
|
19
|
+
onSaved: info => dispatch({ type: 'saved', ...info }),
|
|
20
|
+
onConflict: error => dispatch({ type: 'conflict', source: error.source, hash: error.hash }),
|
|
21
|
+
onError: error => dispatch({ type: 'saveError', message: error.message }),
|
|
22
|
+
}), [])
|
|
23
|
+
|
|
24
|
+
const load = async () => {
|
|
25
|
+
try {
|
|
26
|
+
const payload = await loadDeck()
|
|
27
|
+
dispatch({ type: 'load', ...payload })
|
|
28
|
+
queue.setBase(payload.hash)
|
|
29
|
+
setLoadError(null)
|
|
30
|
+
} catch (error) { setLoadError(error.message) }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
load()
|
|
35
|
+
const offChanged = onServerEvent('mdeck:deck-changed', async ({ hash }) => {
|
|
36
|
+
if (hash === stateRef.current.hash || hash === queue.base()) return
|
|
37
|
+
try {
|
|
38
|
+
const payload = await loadDeck()
|
|
39
|
+
if (stateRef.current.status === 'saved') queue.setBase(payload.hash)
|
|
40
|
+
dispatch({ type: 'externalChange', source: payload.source, hash: payload.hash })
|
|
41
|
+
} catch {}
|
|
42
|
+
})
|
|
43
|
+
// Extension files changed on disk (by an editor page or another program):
|
|
44
|
+
// refresh the registry and reload the preview frame, never the page.
|
|
45
|
+
const offExtensions = onServerEvent('mdeck:extensions-changed', async () => {
|
|
46
|
+
try { const payload = await loadDeck(); dispatch({ type: 'setRegistry', registry: payload.registry }) } catch {}
|
|
47
|
+
setPreviewKey(key => key + 1)
|
|
48
|
+
})
|
|
49
|
+
const flush = () => { if (queue.pending()) queue.flush() }
|
|
50
|
+
const hidden = () => { if (document.visibilityState === 'hidden') flush() }
|
|
51
|
+
window.addEventListener('beforeunload', flush)
|
|
52
|
+
document.addEventListener('visibilitychange', hidden)
|
|
53
|
+
return () => { offChanged(); offExtensions(); window.removeEventListener('beforeunload', flush); document.removeEventListener('visibilitychange', hidden) }
|
|
54
|
+
}, [])
|
|
55
|
+
|
|
56
|
+
useEffect(() => { if (state.loaded && state.status === 'unsaved') queue.push(state.source) }, [state.source, state.status, state.loaded])
|
|
57
|
+
|
|
58
|
+
const edit = (apply, options = {}) => dispatch({ type: 'edit', apply, ...options })
|
|
59
|
+
const resolve = choice => {
|
|
60
|
+
const conflict = stateRef.current.conflict
|
|
61
|
+
dispatch({ type: 'resolveConflict', choice })
|
|
62
|
+
if (choice === 'overwrite') queue.overwrite(conflict.hash)
|
|
63
|
+
else queue.reload(conflict.hash)
|
|
64
|
+
}
|
|
65
|
+
const reloadPreview = () => setPreviewKey(key => key + 1)
|
|
66
|
+
return { state, stateRef, dispatch, edit, resolve, loadError, previewKey, reloadPreview }
|
|
67
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Pure appearance resolution shared by the runtime and tests.
|
|
2
|
+
//
|
|
3
|
+
// Colours come from exactly one place, the palette: the deck's `palette`, or
|
|
4
|
+
// the theme's default. A palette has a light and a dark variant; the deck's
|
|
5
|
+
// `appearance` (else the theme's) picks the one for the slides, and the other
|
|
6
|
+
// one is available to inverted slides as --inverse-bg, --inverse-ink, …
|
|
7
|
+
// Theme tokens (type, spacing, fonts) and theme parameters come on top.
|
|
8
|
+
import { tokensToCss, COLOR_ROLES, APPEARANCES, palettesFor } from './tokens.js'
|
|
9
|
+
|
|
10
|
+
// Code highlighting and logos on dark slides, unless the palette says otherwise.
|
|
11
|
+
export const DARK_TOKENS = {
|
|
12
|
+
'--logo-filter': 'invert(1)',
|
|
13
|
+
'--token-default': '#e2e8f0',
|
|
14
|
+
'--token-comment': '#718096',
|
|
15
|
+
'--token-string': '#68d391',
|
|
16
|
+
'--token-number': '#fc8181',
|
|
17
|
+
'--token-keyword': '#90cdf4',
|
|
18
|
+
'--token-function': '#d6bcfa',
|
|
19
|
+
'--token-operator': '#fbd38d',
|
|
20
|
+
// The drawing pen's red, blue and green, lighter for dark slides.
|
|
21
|
+
'--pen-red': '#fb7185',
|
|
22
|
+
'--pen-blue': '#60a5fa',
|
|
23
|
+
'--pen-green': '#4ade80',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const other = appearance => appearance === 'dark' ? 'light' : 'dark'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Which palette and variant a deck gets: `{ palette, appearance, warnings }`.
|
|
30
|
+
* An unknown palette, or one the theme does not offer, falls back to the
|
|
31
|
+
* theme's default with a warning (mdeck check reports these as errors).
|
|
32
|
+
*/
|
|
33
|
+
export function resolvePalette({ theme, palettes = {}, palette: requested, appearance: requestedAppearance } = {}) {
|
|
34
|
+
const warnings = []
|
|
35
|
+
const offered = palettesFor(theme, palettes)
|
|
36
|
+
let palette = requested ? palettes[requested] : null
|
|
37
|
+
if (requested && !palette) warnings.push(`Unknown palette "${requested}". Available: ${offered.map(p => p.id).join(', ')}`)
|
|
38
|
+
else if (palette && !offered.includes(palette)) { warnings.push(`The theme "${theme.id}" does not offer the palette "${requested}". Available: ${offered.map(p => p.id).join(', ')}`); palette = null }
|
|
39
|
+
palette ??= palettes[theme.palette] ?? offered[0] ?? null
|
|
40
|
+
let appearance = requestedAppearance || theme.appearance || 'light'
|
|
41
|
+
if (!APPEARANCES.includes(appearance)) { warnings.push(`appearance must be one of: ${APPEARANCES.join(', ')}`); appearance = theme.appearance ?? 'light' }
|
|
42
|
+
return { palette, appearance, warnings }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The CSS custom properties of a palette in one appearance, with the other one as --inverse-*. */
|
|
46
|
+
export function paletteTokens(palette, appearance = 'light') {
|
|
47
|
+
if (!palette) return {}
|
|
48
|
+
const main = palette[appearance], inverse = palette[other(appearance)]
|
|
49
|
+
const tokens = { ...(appearance === 'dark' ? DARK_TOKENS : {}), ...main }
|
|
50
|
+
for (const role of COLOR_ROLES) tokens[`--inverse-${role.slice(2)}`] = inverse[role]
|
|
51
|
+
return tokens
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildAppearance({ theme, palettes = {}, palette, appearance, params = {}, meta = {}, offline = false } = {}) {
|
|
55
|
+
if (!theme) throw new Error('A theme manifest is required')
|
|
56
|
+
const resolved = resolvePalette({ theme, palettes, palette, appearance })
|
|
57
|
+
const warnings = [...resolved.warnings]
|
|
58
|
+
const themeCss = tokensToCss(theme.tokens)
|
|
59
|
+
const paletteCss = tokensToCss({ 'color-scheme': resolved.appearance, ...paletteTokens(resolved.palette, resolved.appearance) })
|
|
60
|
+
|
|
61
|
+
const overrides = {}
|
|
62
|
+
for (const [name, value] of Object.entries(params ?? {})) {
|
|
63
|
+
const param = theme.params?.[name]
|
|
64
|
+
if (param) overrides[param.token] = value
|
|
65
|
+
else warnings.push(`Theme "${theme.id}" has no parameter "${name}"`)
|
|
66
|
+
}
|
|
67
|
+
for (const [key, value] of Object.entries(meta ?? {})) overrides[`--meta-${key}`] = JSON.stringify(String(value))
|
|
68
|
+
const overridesCss = tokensToCss(overrides)
|
|
69
|
+
|
|
70
|
+
// Offline-ready builds deliberately use the declared system-font fallbacks.
|
|
71
|
+
const fonts = offline ? [] : theme.fonts ?? []
|
|
72
|
+
return { themeCss, paletteCss, overridesCss, fonts, warnings, palette: resolved.palette?.id ?? null, appearance: resolved.appearance }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A colour of the palette a deck gets, for previews and swatches. */
|
|
76
|
+
export function paletteColor(token, { theme, palettes = {}, palette, appearance } = {}) {
|
|
77
|
+
const resolved = resolvePalette({ theme, palettes, palette, appearance })
|
|
78
|
+
return resolved.palette?.[resolved.appearance]?.[token] ?? null
|
|
79
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Contrast between palette colours (WCAG 2 relative luminance), so a palette
|
|
2
|
+
// that cannot be read is caught: in the tests for the built-in palettes, and
|
|
3
|
+
// as warnings from `mdeck check` for a deck's own.
|
|
4
|
+
|
|
5
|
+
function parseColor(text) {
|
|
6
|
+
const value = String(text).trim().toLowerCase()
|
|
7
|
+
let match = value.match(/^#([0-9a-f]{3,8})$/)
|
|
8
|
+
if (match) {
|
|
9
|
+
let hex = match[1]
|
|
10
|
+
if (hex.length === 3 || hex.length === 4) hex = [...hex].map(c => c + c).join('')
|
|
11
|
+
return [0, 2, 4].map(i => parseInt(hex.slice(i, i + 2), 16))
|
|
12
|
+
}
|
|
13
|
+
match = value.match(/^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/)
|
|
14
|
+
if (match) return match.slice(1, 4).map(Number)
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function luminance([r, g, b]) {
|
|
19
|
+
const channel = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 }
|
|
20
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The contrast ratio of two colours (1 to 21), or null when one is not a plain colour. */
|
|
24
|
+
export function contrast(a, b) {
|
|
25
|
+
const x = parseColor(a), y = parseColor(b)
|
|
26
|
+
if (!x || !y) return null
|
|
27
|
+
const [light, dark] = [luminance(x), luminance(y)].sort((p, q) => q - p)
|
|
28
|
+
return (light + 0.05) / (dark + 0.05)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// What a slide needs to be read from the back of a room.
|
|
32
|
+
const RULES = [
|
|
33
|
+
['--ink', '--bg', 7, 'text'],
|
|
34
|
+
['--ink-soft', '--bg', 4.5, 'secondary text'],
|
|
35
|
+
['--muted', '--bg', 4.5, 'muted text'],
|
|
36
|
+
['--accent', '--bg', 3, 'the accent'],
|
|
37
|
+
['--accent-2', '--bg', 3, 'the second accent'],
|
|
38
|
+
['--on-accent', '--accent', 3, 'text on the accent'],
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Pairs of a palette's colours that are too close to read, as messages.
|
|
43
|
+
* A private palette (one theme's corporate colours) may use its accent as a
|
|
44
|
+
* surface only, so its accents are not checked as text.
|
|
45
|
+
*/
|
|
46
|
+
export function paletteProblems(palette) {
|
|
47
|
+
const problems = []
|
|
48
|
+
for (const appearance of ['light', 'dark']) {
|
|
49
|
+
const tokens = palette[appearance] ?? {}
|
|
50
|
+
for (const [fore, back, minimum, what] of RULES) {
|
|
51
|
+
if (palette.theme && (fore === '--accent' || fore === '--accent-2')) continue
|
|
52
|
+
const ratio = contrast(tokens[fore], tokens[back])
|
|
53
|
+
if (ratio != null && ratio < minimum) problems.push(`${appearance}: ${what} (${fore} on ${back}) has a contrast of ${ratio.toFixed(1)}:1; at least ${minimum}:1 is needed`)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return problems
|
|
57
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// One discovery layer for layouts, themes and palettes. The same registry
|
|
2
|
+
// backs `mdeck check`, CLI listings, the dev server and builds.
|
|
3
|
+
import { palettesFor } from './tokens.js'
|
|
4
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
5
|
+
import { resolve, dirname, relative } from 'node:path'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
import { MANIFEST_FILENAME, ManifestError, KINDS, parseManifestText, validateManifest } from './manifest.js'
|
|
8
|
+
import { builtinExtensionsRoot } from '../paths.js'
|
|
9
|
+
|
|
10
|
+
const SKIP = name => name.startsWith('.') || name === 'node_modules'
|
|
11
|
+
|
|
12
|
+
// Extensions installed for one user (`mdeck themes install --global`), for
|
|
13
|
+
// every deck: ~/.mdeck/extensions, or $MDECK_HOME/extensions.
|
|
14
|
+
export function userExtensionsDir() {
|
|
15
|
+
return resolve(process.env.MDECK_HOME || resolve(homedir(), '.mdeck'), 'extensions')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Built-in, then the user's, then the deck's own: a deck's extension replaces
|
|
19
|
+
// a user one with the same id.
|
|
20
|
+
export function extensionRoots(slidesPath, { builtinRoot = builtinExtensionsRoot, userRoot = userExtensionsDir() } = {}) {
|
|
21
|
+
const deckDir = dirname(resolve(slidesPath))
|
|
22
|
+
return [
|
|
23
|
+
{ dir: builtinRoot, source: 'built-in' },
|
|
24
|
+
...(userRoot ? [{ dir: userRoot, source: 'user' }] : []),
|
|
25
|
+
{ dir: resolve(deckDir, 'extensions'), source: 'local' },
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sortedEntries(dir) {
|
|
30
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
31
|
+
.filter(entry => entry.isDirectory() && !SKIP(entry.name))
|
|
32
|
+
.sort((a, b) => a.name.localeCompare(b.name, 'en'))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A folder holding extension.toml is an extension; any other folder is a
|
|
36
|
+
// grouping folder and is searched further. Folder names must equal the id.
|
|
37
|
+
function* walk(dir) {
|
|
38
|
+
for (const entry of sortedEntries(dir)) {
|
|
39
|
+
const sub = resolve(dir, entry.name)
|
|
40
|
+
const file = resolve(sub, MANIFEST_FILENAME)
|
|
41
|
+
if (existsSync(file)) yield { dir: sub, file, folderName: entry.name }
|
|
42
|
+
else yield* walk(sub)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readLayout(record) {
|
|
47
|
+
const starter = record.files.starter ? readFileSync(record.files.starter, 'utf8') : `:::meta\nlayout: ${record.id}\n:::\n`
|
|
48
|
+
return { ...record, manifest: { ...record.manifest, starter } }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function loadManifest({ dir, file, folderName }) {
|
|
52
|
+
const raw = parseManifestText(readFileSync(file, 'utf8'), file)
|
|
53
|
+
const record = validateManifest(raw, { file, dir, folderName })
|
|
54
|
+
return record.kind === 'layout' ? readLayout(record) : record
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function discoverExtensions(roots) {
|
|
58
|
+
const registry = { layouts: {}, themes: {}, palettes: {}, records: [], warnings: [] }
|
|
59
|
+
const byKey = new Map()
|
|
60
|
+
const add = (record, source) => {
|
|
61
|
+
const key = `${record.kind}:${record.id}`
|
|
62
|
+
const existing = byKey.get(key)
|
|
63
|
+
const entry = { ...record, source }
|
|
64
|
+
if (existing?.source === 'user' && source === 'local') {
|
|
65
|
+
registry.warnings.push(`The ${record.kind} "${record.id}" in this deck's extensions replaces the one installed in ${dirname(existing.dir)}`)
|
|
66
|
+
registry.records.splice(registry.records.indexOf(existing), 1)
|
|
67
|
+
} else if (existing) throw new ManifestError(`Duplicate ${record.kind} "${record.id}" is also defined in ${existing.file}. Extension IDs must be unique within their kind.`, { file: record.file })
|
|
68
|
+
byKey.set(key, entry)
|
|
69
|
+
registry.records.push(entry)
|
|
70
|
+
registry[`${record.kind}s`][record.id] = entry
|
|
71
|
+
}
|
|
72
|
+
for (const root of roots) {
|
|
73
|
+
if (!existsSync(root.dir) || !statSync(root.dir).isDirectory()) continue
|
|
74
|
+
for (const found of walk(root.dir)) add(loadManifest(found), root.source)
|
|
75
|
+
}
|
|
76
|
+
checkPaletteReferences(registry)
|
|
77
|
+
return registry
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Themes and palettes name each other across files: a theme's default palette
|
|
81
|
+
// and its list must exist and fit it, a private palette's theme must exist.
|
|
82
|
+
function checkPaletteReferences(registry) {
|
|
83
|
+
for (const theme of Object.values(registry.themes)) {
|
|
84
|
+
const offered = palettesFor(theme.manifest, manifestsOf(registry, 'palette'))
|
|
85
|
+
for (const id of theme.manifest.palettes ?? []) {
|
|
86
|
+
if (!registry.palettes[id]) throw new ManifestError(`palettes names "${id}", which is not a palette. Available: ${Object.keys(registry.palettes).join(', ')}`, { file: theme.file, path: 'palettes' })
|
|
87
|
+
const owner = registry.palettes[id].manifest.theme
|
|
88
|
+
if (owner && owner !== theme.id) throw new ManifestError(`palette "${id}" belongs to the theme "${owner}"`, { file: theme.file, path: 'palettes' })
|
|
89
|
+
}
|
|
90
|
+
if (!registry.palettes[theme.manifest.palette]) throw new ManifestError(`palette "${theme.manifest.palette}" does not exist. Available: ${Object.keys(registry.palettes).join(', ')}`, { file: theme.file, path: 'palette' })
|
|
91
|
+
if (!offered.some(palette => palette.id === theme.manifest.palette)) throw new ManifestError(`palette "${theme.manifest.palette}" belongs to another theme`, { file: theme.file, path: 'palette' })
|
|
92
|
+
}
|
|
93
|
+
for (const palette of Object.values(registry.palettes)) {
|
|
94
|
+
const owner = palette.manifest.theme
|
|
95
|
+
if (owner && !registry.themes[owner]) throw new ManifestError(`theme "${owner}" does not exist, so nothing could use this palette`, { file: palette.file, path: 'theme' })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The extensions that come with mdeck, alone.
|
|
100
|
+
export function builtInRegistry() {
|
|
101
|
+
return discoverExtensions([{ dir: builtinExtensionsRoot, source: 'built-in' }])
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function loadRegistry(slidesPath, options) {
|
|
105
|
+
return discoverExtensions(extensionRoots(slidesPath, options))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function manifestsOf(registry, kind) {
|
|
109
|
+
return Object.fromEntries(Object.entries(registry[`${kind}s`]).map(([id, record]) => [id, record.manifest]))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function layoutManifests(slidesPath, options) {
|
|
113
|
+
return manifestsOf(loadRegistry(slidesPath, options), 'layout')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Serializable listing for `mdeck list --json` and future editors.
|
|
117
|
+
export function serializeRegistry(registry, { relativeTo } = {}) {
|
|
118
|
+
const out = { schema: 2, warnings: registry.warnings }
|
|
119
|
+
for (const kind of KINDS) {
|
|
120
|
+
out[`${kind}s`] = Object.values(registry[`${kind}s`]).map(record => ({
|
|
121
|
+
...record.manifest, kind, source: record.source,
|
|
122
|
+
file: relativeTo ? relative(relativeTo, record.file) : record.file,
|
|
123
|
+
}))
|
|
124
|
+
}
|
|
125
|
+
return out
|
|
126
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// The extension manifest contract: one TOML format for layouts, themes and
|
|
2
|
+
// palettes. Parsing and validation happen in Node; the normalized result is
|
|
3
|
+
// plain data that the runtime, CLI and documentation can all share.
|
|
4
|
+
import { existsSync } from 'node:fs'
|
|
5
|
+
import { resolve, relative, isAbsolute, sep } from 'node:path'
|
|
6
|
+
import { parse as parseToml, TomlError } from 'smol-toml'
|
|
7
|
+
import { TOKEN_NAME_RE, COLOR_ROLES, APPEARANCES } from './tokens.js'
|
|
8
|
+
import { propertyErrors } from '../layouts/layoutProps.js'
|
|
9
|
+
|
|
10
|
+
export const SCHEMA_VERSION = 1
|
|
11
|
+
export const KINDS = ['layout', 'theme', 'palette']
|
|
12
|
+
export const ID_RE = /^[a-z][a-z0-9-]*$/
|
|
13
|
+
export const MANIFEST_FILENAME = 'extension.toml'
|
|
14
|
+
|
|
15
|
+
const FRAMES = ['standard', 'title', 'chapter', 'none']
|
|
16
|
+
const PROPERTY_TYPES = ['string', 'number', 'integer', 'boolean', 'array', 'object']
|
|
17
|
+
const SHARED_KEYS = ['schema', 'kind', 'id', 'title', 'description']
|
|
18
|
+
const KEYS = {
|
|
19
|
+
layout: [...SHARED_KEYS, 'frame', 'files', 'regions', 'properties'],
|
|
20
|
+
theme: [...SHARED_KEYS, 'palette', 'palettes', 'appearance', 'fonts', 'files', 'tokens', 'params'],
|
|
21
|
+
palette: [...SHARED_KEYS, 'theme', 'light', 'dark'],
|
|
22
|
+
}
|
|
23
|
+
const SCHEMA_KEYS = ['type', 'title', 'description', 'enum', 'required', 'default', 'minimum', 'maximum', 'minItems', 'maxItems', 'items']
|
|
24
|
+
const RESERVED_NAMES = ['constructor', 'prototype', '__proto__']
|
|
25
|
+
|
|
26
|
+
export class ManifestError extends Error {
|
|
27
|
+
constructor(message, { file, path, line, column } = {}) {
|
|
28
|
+
const where = [file, line, line && column].filter(Boolean).join(':')
|
|
29
|
+
super(`${where ? where + ': ' : ''}${path ? path + ': ' : ''}${message}`)
|
|
30
|
+
this.name = 'ManifestError'
|
|
31
|
+
Object.assign(this, { file, path, line, column, reason: message })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
36
|
+
|
|
37
|
+
export function parseManifestText(text, file) {
|
|
38
|
+
try {
|
|
39
|
+
return parseToml(text)
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error instanceof TomlError) throw new ManifestError(error.message.split('\n')[0].replace(/^Invalid TOML document: /, ''), { file, line: error.line, column: error.column })
|
|
42
|
+
throw error
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Validates a raw manifest object and returns a normalized record:
|
|
47
|
+
// { kind, id, title, description, dir, file, manifest, files }
|
|
48
|
+
// `manifest` is serializable public data; `files` holds absolute paths.
|
|
49
|
+
export function validateManifest(raw, { file, dir, folderName, fileExists = existsSync } = {}) {
|
|
50
|
+
const fail = (message, path) => { throw new ManifestError(message, { file, path }) }
|
|
51
|
+
if (!isPlainObject(raw)) fail('The manifest must be a table of settings')
|
|
52
|
+
|
|
53
|
+
if (raw.schema !== SCHEMA_VERSION) fail(`schema must be ${SCHEMA_VERSION}${raw.schema == null ? ' (add "schema = 1" at the top)' : ''}`, 'schema')
|
|
54
|
+
if (raw.kind === 'template') fail('kind = \"template\" is now kind = \"layout\". Run mdeck migrate to update local manifests.', 'kind')
|
|
55
|
+
if (!KINDS.includes(raw.kind)) fail(`kind must be one of: ${KINDS.join(', ')}`, 'kind')
|
|
56
|
+
const kind = raw.kind
|
|
57
|
+
if (raw.name !== undefined) fail('name is not a setting; use id for the identifier and title for the display name', 'name')
|
|
58
|
+
// Colours used to live in themes and in a palette's single [tokens] table.
|
|
59
|
+
if (kind === 'theme' && raw.dark !== undefined) fail('dark is now appearance = "dark"; the colours come from the palette', 'dark')
|
|
60
|
+
if (kind === 'theme' && (raw.accent2 !== undefined || raw.accent2Preview !== undefined)) fail('a theme no longer sets colours; the second accent comes from the palette (--accent-2)', raw.accent2 !== undefined ? 'accent2' : 'accent2Preview')
|
|
61
|
+
if (kind === 'palette' && raw.tokens !== undefined) fail('a palette has a [light] and a [dark] table of colours instead of [tokens]', 'tokens')
|
|
62
|
+
for (const key of Object.keys(raw)) {
|
|
63
|
+
if (!KEYS[kind].includes(key)) fail(`Unknown setting for a ${kind}. Allowed: ${KEYS[kind].join(', ')}`, key)
|
|
64
|
+
}
|
|
65
|
+
if (typeof raw.id !== 'string' || !ID_RE.test(raw.id)) fail('id must use lowercase letters, digits and hyphens and start with a letter', 'id')
|
|
66
|
+
if (folderName != null && raw.id !== folderName) fail(`id "${raw.id}" must match the folder name "${folderName}"`, 'id')
|
|
67
|
+
if (typeof raw.title !== 'string' || !raw.title.trim()) fail('title is required', 'title')
|
|
68
|
+
if (raw.description != null && typeof raw.description !== 'string') fail('description must be text', 'description')
|
|
69
|
+
|
|
70
|
+
const files = resolveFiles(raw.files, kind, { dir, fail, fileExists })
|
|
71
|
+
const shared = { id: raw.id, title: raw.title.trim(), description: raw.description?.trim() || '' }
|
|
72
|
+
const manifest = kind === 'layout' ? validateLayout(raw, shared, fail)
|
|
73
|
+
: kind === 'theme' ? validateTheme(raw, shared, fail)
|
|
74
|
+
: validatePalette(raw, shared, fail)
|
|
75
|
+
return { kind, id: raw.id, title: shared.title, description: shared.description, dir, file, manifest, files }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const FILE_KEYS = { layout: ['layout', 'styles', 'starter'], theme: ['styles'], palette: [] }
|
|
79
|
+
const FILE_DEFAULTS = { layout: { layout: 'layout.jsx', styles: 'styles.css', starter: 'starter.md' }, theme: { styles: 'styles.css' } }
|
|
80
|
+
|
|
81
|
+
function resolveFiles(section, kind, { dir, fail, fileExists }) {
|
|
82
|
+
if (section != null && !isPlainObject(section)) fail('files must be a table', 'files')
|
|
83
|
+
const declared = section ?? {}
|
|
84
|
+
for (const key of Object.keys(declared)) {
|
|
85
|
+
if (!FILE_KEYS[kind].includes(key)) fail(FILE_KEYS[kind].length ? `Unknown file role for a ${kind}. Allowed: ${FILE_KEYS[kind].join(', ')}` : `A ${kind} has no supporting files`, `files.${key}`)
|
|
86
|
+
}
|
|
87
|
+
const resolveOne = (value, path) => {
|
|
88
|
+
if (typeof value !== 'string' || !value.trim()) fail('must be a file path inside the extension folder', path)
|
|
89
|
+
if (isAbsolute(value)) fail('must be relative to the extension folder', path)
|
|
90
|
+
const abs = resolve(dir ?? '.', value)
|
|
91
|
+
const rel = relative(dir ?? '.', abs)
|
|
92
|
+
if (rel.startsWith('..') || isAbsolute(rel) || rel.split(sep).includes('..')) fail(`"${value}" leaves the extension folder`, path)
|
|
93
|
+
if (!fileExists(abs)) fail(`"${value}" does not exist in ${dir ?? 'the extension folder'}`, path)
|
|
94
|
+
return abs
|
|
95
|
+
}
|
|
96
|
+
const files = {}
|
|
97
|
+
for (const key of FILE_KEYS[kind]) {
|
|
98
|
+
const path = `files.${key}`
|
|
99
|
+
const value = declared[key]
|
|
100
|
+
if (value === undefined) {
|
|
101
|
+
const fallback = FILE_DEFAULTS[kind]?.[key]
|
|
102
|
+
const abs = fallback ? resolve(dir ?? '.', fallback) : null
|
|
103
|
+
if (abs && fileExists(abs)) files[key] = key === 'styles' ? [abs] : abs
|
|
104
|
+
else if (kind === 'layout' && key === 'layout') fail(`layout.jsx is missing; add it or point files.layout at the layout file`, path)
|
|
105
|
+
else files[key] = key === 'styles' ? [] : null
|
|
106
|
+
} else if (key === 'styles') {
|
|
107
|
+
const list = Array.isArray(value) ? value : [value]
|
|
108
|
+
if (!list.length) fail('must name at least one stylesheet or be omitted', path)
|
|
109
|
+
files[key] = list.map((item, index) => resolveOne(item, Array.isArray(value) ? `${path}[${index}]` : path))
|
|
110
|
+
} else files[key] = resolveOne(value, path)
|
|
111
|
+
}
|
|
112
|
+
return files
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function validateLayout(raw, shared, fail) {
|
|
116
|
+
if (raw.frame != null && !FRAMES.includes(raw.frame)) fail(`frame must be one of: ${FRAMES.join(', ')}`, 'frame')
|
|
117
|
+
if (!isPlainObject(raw.regions) || !Object.hasOwn(raw.regions, 'body')) fail('regions must include a [regions.body] table', 'regions')
|
|
118
|
+
const regions = {}
|
|
119
|
+
for (const [name, region] of Object.entries(raw.regions)) {
|
|
120
|
+
const path = `regions.${name}`
|
|
121
|
+
if (!ID_RE.test(name)) fail('region names use lowercase letters, digits and hyphens', path)
|
|
122
|
+
if (!isPlainObject(region)) fail('must be a table', path)
|
|
123
|
+
for (const key of Object.keys(region)) if (!['description', 'required'].includes(key)) fail('Unknown region setting. Allowed: description, required', `${path}.${key}`)
|
|
124
|
+
if (region.required != null && typeof region.required !== 'boolean') fail('must be true or false', `${path}.required`)
|
|
125
|
+
if (region.description != null && typeof region.description !== 'string') fail('must be text', `${path}.description`)
|
|
126
|
+
regions[name] = { ...(region.description ? { description: region.description } : {}), ...(region.required ? { required: true } : {}) }
|
|
127
|
+
}
|
|
128
|
+
if (raw.properties != null && !isPlainObject(raw.properties)) fail('properties must be a table', 'properties')
|
|
129
|
+
const properties = {}
|
|
130
|
+
for (const [name, schema] of Object.entries(raw.properties ?? {})) {
|
|
131
|
+
const path = `properties.${name}`
|
|
132
|
+
if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(name) || RESERVED_NAMES.includes(name)) fail('property names use letters and digits and start with a letter', path)
|
|
133
|
+
properties[name] = validatePropertySchema(schema, path, fail)
|
|
134
|
+
}
|
|
135
|
+
return { ...shared, frame: raw.frame ?? 'standard', regions, properties }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function validatePropertySchema(schema, path, fail) {
|
|
139
|
+
if (!isPlainObject(schema)) fail('must be a table with a type', path)
|
|
140
|
+
for (const key of Object.keys(schema)) if (!SCHEMA_KEYS.includes(key)) fail(`Unknown property setting. Allowed: ${SCHEMA_KEYS.join(', ')}`, `${path}.${key}`)
|
|
141
|
+
if (!PROPERTY_TYPES.includes(schema.type)) fail(`type must be one of: ${PROPERTY_TYPES.join(', ')}`, `${path}.type`)
|
|
142
|
+
if (schema.enum != null && (!Array.isArray(schema.enum) || !schema.enum.length)) fail('must be a nonempty list', `${path}.enum`)
|
|
143
|
+
if (schema.required != null && typeof schema.required !== 'boolean') fail('must be true or false', `${path}.required`)
|
|
144
|
+
for (const key of ['title', 'description']) if (schema[key] != null && typeof schema[key] !== 'string') fail('must be text', `${path}.${key}`)
|
|
145
|
+
for (const key of ['minimum', 'maximum', 'minItems', 'maxItems']) {
|
|
146
|
+
if (schema[key] != null && (typeof schema[key] !== 'number' || !Number.isFinite(schema[key]))) fail('must be a number', `${path}.${key}`)
|
|
147
|
+
}
|
|
148
|
+
const normalized = { ...schema }
|
|
149
|
+
if (schema.items !== undefined) normalized.items = validatePropertySchema(schema.items, `${path}.items`, fail)
|
|
150
|
+
if (schema.default !== undefined) {
|
|
151
|
+
const errors = propertyErrors(schema.default, normalized, `${path}.default`)
|
|
152
|
+
if (errors.length) fail(errors.join('; ').replace(new RegExp(`${path.replace(/[.[\]]/g, '\\$&')}\\.default`, 'g'), 'default'), path)
|
|
153
|
+
}
|
|
154
|
+
return normalized
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function validateTokens(table, path, fail, { required = false } = {}) {
|
|
158
|
+
if (!isPlainObject(table) || (required && !Object.keys(table).length)) fail(`a [${path}] table with at least one CSS custom property is required`, path)
|
|
159
|
+
const tokens = {}
|
|
160
|
+
for (const [name, value] of Object.entries(table)) {
|
|
161
|
+
if (!TOKEN_NAME_RE.test(name)) fail(`token names look like "--accent" (quote them in TOML: "--accent" = "#ff0000")`, `${path}.${name}`)
|
|
162
|
+
if (typeof value !== 'string' || !value.trim()) fail('token values are CSS text', `${path}.${name}`)
|
|
163
|
+
tokens[name] = value.trim()
|
|
164
|
+
}
|
|
165
|
+
return tokens
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function validateTheme(raw, shared, fail) {
|
|
169
|
+
if (raw.fonts != null && (!Array.isArray(raw.fonts) || raw.fonts.some(url => typeof url !== 'string' || !url.trim()))) fail('must be a list of stylesheet URLs', 'fonts')
|
|
170
|
+
// Colours come from palettes only: a theme names its default palette, may
|
|
171
|
+
// limit which palettes fit it, and says whether it starts light or dark.
|
|
172
|
+
if (typeof raw.palette !== 'string' || !ID_RE.test(raw.palette)) fail('palette must name the palette this theme uses by default, such as palette = "lagoon"', 'palette')
|
|
173
|
+
if (raw.palettes != null && (!Array.isArray(raw.palettes) || !raw.palettes.length || raw.palettes.some(id => typeof id !== 'string' || !ID_RE.test(id)))) fail('palettes must be a nonempty list of palette ids, or be left out to offer every palette', 'palettes')
|
|
174
|
+
if (raw.palettes && !raw.palettes.includes(raw.palette)) fail(`palettes must include the default palette "${raw.palette}"`, 'palettes')
|
|
175
|
+
if (raw.appearance != null && !APPEARANCES.includes(raw.appearance)) fail(`appearance must be one of: ${APPEARANCES.join(', ')}`, 'appearance')
|
|
176
|
+
const tokens = validateTokens(raw.tokens ?? {}, 'tokens', fail)
|
|
177
|
+
for (const name of Object.keys(tokens)) if (COLOR_ROLES.includes(name)) fail(`${name} is a colour; colours come from the palette, not the theme`, `tokens.${name}`)
|
|
178
|
+
if (raw.params != null && !isPlainObject(raw.params)) fail('params must be a table', 'params')
|
|
179
|
+
const params = {}
|
|
180
|
+
for (const [name, param] of Object.entries(raw.params ?? {})) {
|
|
181
|
+
const path = `params.${name}`
|
|
182
|
+
if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(name) || RESERVED_NAMES.includes(name)) fail('parameter names use letters and digits and start with a letter', path)
|
|
183
|
+
if (!isPlainObject(param)) fail('must be a table with a token', path)
|
|
184
|
+
for (const key of Object.keys(param)) if (!['token', 'title', 'description'].includes(key)) fail('Unknown parameter setting. Allowed: token, title, description', `${path}.${key}`)
|
|
185
|
+
if (typeof param.token !== 'string' || !Object.hasOwn(tokens, param.token)) fail(`token must name an entry of [tokens] (got ${JSON.stringify(param.token)})`, `${path}.token`)
|
|
186
|
+
for (const key of ['title', 'description']) if (param[key] != null && typeof param[key] !== 'string') fail('must be text', `${path}.${key}`)
|
|
187
|
+
params[name] = { token: param.token, default: tokens[param.token], ...(param.title ? { title: param.title } : {}), ...(param.description ? { description: param.description } : {}) }
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
...shared, palette: raw.palette, ...(raw.palettes ? { palettes: [...raw.palettes] } : {}),
|
|
191
|
+
appearance: raw.appearance ?? 'light', fonts: raw.fonts ?? [], tokens, params,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// A palette is a family of colours in two variants: [light] and [dark], each
|
|
196
|
+
// with every colour role. Themes use one variant for the slides and the
|
|
197
|
+
// other for inverted slides. `theme` makes a palette private to that theme.
|
|
198
|
+
function validatePalette(raw, shared, fail) {
|
|
199
|
+
if (raw.theme != null && (typeof raw.theme !== 'string' || !ID_RE.test(raw.theme))) fail('theme must be the id of the only theme that may use this palette', 'theme')
|
|
200
|
+
const variants = {}
|
|
201
|
+
for (const appearance of APPEARANCES) {
|
|
202
|
+
if (!isPlainObject(raw[appearance])) fail(`a [${appearance}] table with the ${appearance} colours is required`, appearance)
|
|
203
|
+
const tokens = validateTokens(raw[appearance], appearance, fail, { required: true })
|
|
204
|
+
const missing = COLOR_ROLES.filter(role => !Object.hasOwn(tokens, role))
|
|
205
|
+
if (missing.length) fail(`missing colours: ${missing.join(', ')}`, appearance)
|
|
206
|
+
variants[appearance] = tokens
|
|
207
|
+
}
|
|
208
|
+
return { ...shared, ...(raw.theme ? { theme: raw.theme } : {}), light: variants.light, dark: variants.dark }
|
|
209
|
+
}
|