mdeck 3.7.1 → 3.9.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 +13 -0
- package/README.md +3 -3
- package/assets/base.css +9 -5
- package/assets/extensions/themes/academic/extension.toml +4 -1
- package/assets/extensions/themes/academic/styles.css +22 -22
- package/assets/extensions/themes/glass/extension.toml +7 -1
- package/assets/extensions/themes/glass/styles.css +17 -17
- package/assets/extensions/themes/minimal/extension.toml +3 -1
- package/assets/extensions/themes/minimal/styles.css +14 -14
- package/assets/extensions/themes/neue/extension.toml +3 -1
- package/assets/extensions/themes/neue/styles.css +14 -14
- package/assets/extensions/themes/plain/extension.toml +7 -1
- package/assets/extensions/themes/plain/styles.css +17 -17
- package/assets/extensions/themes/work/extension.toml +8 -2
- package/assets/extensions/themes/work/styles.css +26 -26
- package/docs/reference/authoring.md +21 -4
- package/docs/reference/extensions.md +5 -1
- package/docs/reference/themes.md +6 -2
- package/docs/site/content/drawing.md +1 -1
- package/docs/site/content/presenting.md +45 -0
- package/docs/site/content/theme-authoring.md +28 -0
- package/examples/python/slides.de.md +493 -0
- package/examples/python/{slides.md → slides.en.md} +1 -1
- package/examples/python/slides.fa.md +492 -0
- package/examples/python/slides.hi.md +492 -0
- package/examples/showcase/slides.md +33 -0
- package/package.json +1 -1
- package/skills/write-slides/SKILL.md +1 -0
- package/src/build/config.js +1 -1
- package/src/build/slidesPlugin.js +34 -4
- package/src/cli/index.js +4 -0
- package/src/cli/themes.js +2 -0
- package/src/components/callout.css +10 -10
- package/src/components/citation.css +2 -2
- package/src/components/code-block.css +3 -3
- package/src/components/poll.css +6 -6
- package/src/components/video-player.css +1 -1
- package/src/core/editDeck.js +3 -2
- package/src/core/icons.js +1 -0
- package/src/core/languages.js +51 -0
- package/src/core/parseSlides.js +15 -3
- package/src/core/validateDeck.js +13 -1
- package/src/editor/components/DeckSettings.jsx +37 -3
- package/src/editor/components/ExtensionForms.jsx +12 -0
- package/src/editor/components/MetaFields.jsx +7 -0
- package/src/editor/extensions.js +6 -3
- package/src/extensions/appearance.js +6 -2
- package/src/extensions/manifest.js +3 -3
- package/src/extensions/params.js +113 -0
- package/src/layouts/layoutApi.jsx +7 -1
- package/src/live/answer/answer.css +2 -2
- package/src/runtime/chrome.jsx +6 -7
- package/src/runtime/deck-stage.js +249 -11
- package/src/runtime/hotspots.js +98 -0
- package/src/runtime/ink/bus.js +12 -1
- package/src/runtime/ink/room.js +20 -8
- package/src/runtime/main.jsx +152 -22
- package/src/runtime/zoom/camera.js +77 -0
- /package/examples/python/{slides.drawings.json → slides.en.drawings.json} +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
something important are emphasis, in the accent; a warning and a caution
|
|
3
3
|
call for attention, in the third accent. */
|
|
4
4
|
.callout {
|
|
5
|
-
border-
|
|
5
|
+
border-inline-start: 4px solid var(--rule);
|
|
6
6
|
padding: 20px 28px;
|
|
7
7
|
background: var(--surface, var(--bg));
|
|
8
8
|
margin: var(--gap-item) 0;
|
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
text-transform: uppercase;
|
|
15
15
|
margin: 0 0 8px 0;
|
|
16
16
|
}
|
|
17
|
-
.callout p { margin: 0; color: var(--ink-soft); padding-
|
|
17
|
+
.callout p { margin: 0; color: var(--ink-soft); padding-inline-start: 28px; }
|
|
18
18
|
|
|
19
|
-
.callout:is(.callout-note, .callout-tip, .callout-important) { border-
|
|
20
|
-
.callout:is(.callout-warning, .callout-caution) { border-
|
|
19
|
+
.callout:is(.callout-note, .callout-tip, .callout-important) { border-inline-start-color: var(--accent); }
|
|
20
|
+
.callout:is(.callout-warning, .callout-caution) { border-inline-start-color: var(--accent-3); }
|
|
21
21
|
|
|
22
22
|
/* Titles are text: the accent mixed with the text colour, darker on light
|
|
23
23
|
slides and lighter on dark ones, so they can be read on both. The third
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
into its text, and a square ends it. Themes with their own callout style
|
|
33
33
|
colour these blocks their way; the proof is shared, and themes that set
|
|
34
34
|
it differently (academic, sketch) use the same selectors. */
|
|
35
|
-
.callout:is(.callout-theorem, .callout-lemma, .callout-corollary) { border-
|
|
36
|
-
.callout.callout-definition { border-
|
|
35
|
+
.callout:is(.callout-theorem, .callout-lemma, .callout-corollary) { border-inline-start-color: var(--accent); }
|
|
36
|
+
.callout.callout-definition { border-inline-start-color: var(--accent-2); }
|
|
37
37
|
.callout:is(.callout-theorem, .callout-lemma, .callout-corollary) .callout-title { color: color-mix(in oklab, var(--accent) 75%, var(--ink)); }
|
|
38
38
|
.callout.callout-definition .callout-title { color: color-mix(in oklab, var(--accent-2) 75%, var(--ink)); }
|
|
39
39
|
.callout:is(.callout-theorem, .callout-lemma, .callout-corollary) > :not(.callout-title) { font-style: italic; }
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
.slide-body .callout.callout-proof::before { display: none; }
|
|
51
51
|
.slide-body .callout.callout-proof .callout-title {
|
|
52
52
|
display: inline;
|
|
53
|
-
float:
|
|
54
|
-
margin: 0
|
|
53
|
+
float: inline-start;
|
|
54
|
+
margin-block: 0; margin-inline: 0 0.4em;
|
|
55
55
|
padding: 0;
|
|
56
56
|
background: none;
|
|
57
57
|
font-size: inherit;
|
|
@@ -64,5 +64,5 @@
|
|
|
64
64
|
}
|
|
65
65
|
.slide-body .callout.callout-proof .callout-title::before { content: none; }
|
|
66
66
|
.slide-body .callout.callout-proof .callout-title::after { content: '.'; }
|
|
67
|
-
.slide-body .callout.callout-proof > :not(.callout-title) { padding-
|
|
68
|
-
.slide-body .callout.callout-proof > :last-child::after { content: '\220E'; margin-
|
|
67
|
+
.slide-body .callout.callout-proof > :not(.callout-title) { padding-inline-start: 0; font-style: normal; }
|
|
68
|
+
.slide-body .callout.callout-proof > :last-child::after { content: '\220E'; margin-inline-start: 0.6em; color: var(--ink); }
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
.slide-citations .csl-bib-body { display: flex; flex-direction: column; gap: 4px; }
|
|
19
19
|
|
|
20
20
|
/* Entries hang; numeric styles put the number in a margin column. */
|
|
21
|
-
.csl-entry { line-height: 1.4; padding-
|
|
22
|
-
.csl-entry:has(.csl-left-margin) { display: grid; grid-template-columns: auto 1fr; column-gap: 0.6em; padding-
|
|
21
|
+
.csl-entry { line-height: 1.4; padding-inline-start: 1.5em; text-indent: -1.5em; overflow-wrap: anywhere; }
|
|
22
|
+
.csl-entry:has(.csl-left-margin) { display: grid; grid-template-columns: auto 1fr; column-gap: 0.6em; padding-inline-start: 0; text-indent: 0; }
|
|
23
23
|
.csl-left-margin { color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
24
24
|
.csl-entry a { color: inherit; text-decoration: none; }
|
|
25
25
|
|
|
@@ -25,7 +25,7 @@ code[class*="language-"] { color: var(--token-default); }
|
|
|
25
25
|
margin: 40px 0;
|
|
26
26
|
width: 100%;
|
|
27
27
|
/* Centred layouts (focus, title) must not centre the lines of code. */
|
|
28
|
-
text-align:
|
|
28
|
+
text-align: start;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/* ── Action buttons ─────────────────────────────────────────────────────── */
|
|
@@ -103,11 +103,11 @@ code[class*="language-"] { color: var(--token-default); }
|
|
|
103
103
|
max-height: 10em;
|
|
104
104
|
overflow-y: auto;
|
|
105
105
|
border-radius: 0 8px 8px 8px;
|
|
106
|
-
border-
|
|
106
|
+
border-inline-start: 4px solid var(--rule);
|
|
107
107
|
}
|
|
108
108
|
.code-output--error {
|
|
109
109
|
color: #f87171;
|
|
110
|
-
border-
|
|
110
|
+
border-inline-start-color: #ef4444;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
/* ── Code taller than its place on the slide (src/runtime/fit.js) ───────── */
|
package/src/components/poll.css
CHANGED
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
.poll-row { display: grid; grid-template-columns: minmax(120px, 28%) 1fr 3ch; gap: 24px; align-items: center; }
|
|
10
10
|
.poll-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
11
11
|
/* buttons="letters|numbers": the key phones show, before the option. */
|
|
12
|
-
.poll-key { display: inline-grid; place-items: center; min-width: 1.6em; height: 1.6em; margin-
|
|
12
|
+
.poll-key { display: inline-grid; place-items: center; min-width: 1.6em; height: 1.6em; margin-inline-end: .5em; padding: 0 .3em; border-radius: .4em; background: color-mix(in srgb, var(--accent, #0d9488) 14%, transparent); color: var(--accent, #0d9488); font-weight: 700; font-size: .8em; vertical-align: .1em; }
|
|
13
13
|
/* A picture as an option: a thumbnail beside its bar. */
|
|
14
14
|
.poll-label:has(img) { white-space: normal; }
|
|
15
15
|
.poll-label img { max-height: 3.2em; max-width: 100%; vertical-align: middle; }
|
|
16
16
|
.poll .poll-several { margin: -20px 0 24px; font-size: var(--fs-small, 24px); color: var(--muted, #666); }
|
|
17
17
|
.poll-track { height: 1.1em; background: color-mix(in srgb, var(--ink, #111) 8%, transparent); border-radius: 6px; overflow: hidden; }
|
|
18
18
|
.poll-fill { display: block; height: 100%; background: var(--accent, #0d9488); border-radius: 6px; transition: width .6s cubic-bezier(.2, .8, .2, 1); }
|
|
19
|
-
.poll-count { text-align:
|
|
19
|
+
.poll-count { text-align: end; font-variant-numeric: tabular-nums; font-weight: 600; }
|
|
20
20
|
.poll .poll-total { display: flex; align-items: center; gap: 14px; margin: 36px 0 0; font-size: var(--fs-small, 24px); color: var(--muted, #666); }
|
|
21
21
|
.poll-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--muted, #999); opacity: .5; }
|
|
22
22
|
.poll-dot.is-live { background: #16a34a; opacity: 1; }
|
|
@@ -44,16 +44,16 @@ deck-stage[data-follower] .poll.is-results-hidden .poll-fill, deck-stage[data-fo
|
|
|
44
44
|
.poll-link { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 20px; color: var(--muted, #555); }
|
|
45
45
|
/* The join code in the slide's own colours (see QrCode.jsx). */
|
|
46
46
|
.poll-join { flex: none; display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 18px; border: 1px solid var(--rule, rgba(0,0,0,.12)); border-radius: 12px; color: var(--ink, #111); text-decoration: none; font-size: var(--fs-small, 24px); }
|
|
47
|
-
.poll-join--local { display: block; max-width: 460px; text-align:
|
|
47
|
+
.poll-join--local { display: block; max-width: 460px; text-align: start; line-height: 1.4; color: var(--ink-soft, var(--ink, #333)); }
|
|
48
48
|
.poll-join--local code { display: inline; font-size: .9em; }
|
|
49
49
|
.poll-join--local a { color: var(--accent, #0d9488); }
|
|
50
50
|
|
|
51
51
|
/* <question>: the newest answers as cards */
|
|
52
52
|
.question-cards { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 18px; }
|
|
53
53
|
.slide-body .question-cards li::before, .question-cards li::before, .question-cards li::marker { content: none; display: none; }
|
|
54
|
-
.slide-body .question-cards li { padding-
|
|
55
|
-
.question-cards li { margin: 0; padding: 18px 22px; border-radius: 10px; background: var(--surface, color-mix(in srgb, var(--ink, #111) 5%, transparent)); border-
|
|
56
|
-
.question-cards li:first-child { border-
|
|
54
|
+
.slide-body .question-cards li { padding-inline-start: 22px; }
|
|
55
|
+
.question-cards li { margin: 0; padding: 18px 22px; border-radius: 10px; background: var(--surface, color-mix(in srgb, var(--ink, #111) 5%, transparent)); border-inline-start: 5px solid var(--accent, #0d9488); font-size: .8em; line-height: 1.35; overflow-wrap: anywhere; animation: poll-in .4s ease both; }
|
|
56
|
+
.question-cards li:first-child { border-inline-start-color: var(--accent-2, var(--accent, #0d9488)); }
|
|
57
57
|
.question-empty { margin: 0; color: var(--muted, #666); font-style: italic; }
|
|
58
58
|
@keyframes poll-in { from { opacity: 0; transform: translateY(8px); } }
|
|
59
59
|
|
package/src/core/editDeck.js
CHANGED
|
@@ -135,12 +135,13 @@ export function setSlideMeta(deck, slideId, patch) {
|
|
|
135
135
|
if (yamlText) return edit(deck, [{ ...range, text: yamlText, expected: current }])
|
|
136
136
|
return edit(deck, [{ start: slide.source.start, end: lineEnd(deck.source, range.end), text: '' }])
|
|
137
137
|
}
|
|
138
|
-
// Legacy `---` metadata: keep its form while a slide key leads, otherwise
|
|
138
|
+
// Legacy `---` metadata: keep its form while a slide-only key leads, otherwise
|
|
139
139
|
// convert it into an explicit :::meta block (consuming the inner `---`).
|
|
140
140
|
const yamlText = patchYamlMapping(current, patch, { newline: nl, leadKeys: SLIDE_KEYS })
|
|
141
141
|
const end = slide.bodySource ? slide.bodySource.start : range.end
|
|
142
142
|
if (!yamlText) return slide.bodySource ? edit(deck, [{ start: range.start, end, text: '' }]) : removeSlide(deck, slideId).source
|
|
143
|
-
|
|
143
|
+
const lead = firstYamlKey(yamlText)
|
|
144
|
+
if (SLIDE_KEYS.has(lead) && !DECK_KEYS.has(lead)) return edit(deck, [{ ...range, text: yamlText, expected: current }])
|
|
144
145
|
return edit(deck, [{ start: range.start, end, text: block(yamlText) }])
|
|
145
146
|
}
|
|
146
147
|
|
package/src/core/icons.js
CHANGED
|
@@ -59,6 +59,7 @@ export const ICONS = {
|
|
|
59
59
|
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>', // moon
|
|
60
60
|
'fullscreen-exit': '<path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/>', // minimize
|
|
61
61
|
play: '<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>', // play
|
|
62
|
+
hotspots: '<path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><circle cx="12" cy="12" r="3"/><path d="m16 16-1.9-1.9"/>', // scan-search
|
|
62
63
|
pause: '<rect x="14" y="3" width="5" height="18" rx="1"/><rect x="5" y="3" width="5" height="18" rx="1"/>', // pause
|
|
63
64
|
loading: '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>', // loader-circle
|
|
64
65
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// A deck in several languages is one file per language, side by side:
|
|
2
|
+
// talk.en.md and talk.de.md. The file name says the language; the part before
|
|
3
|
+
// it names the deck, so files with the same name in other languages belong
|
|
4
|
+
// together. A language is a two-letter code, with a region if needed (de-CH).
|
|
5
|
+
|
|
6
|
+
const LANGUAGE_FILE = /^(.+)\.([a-z]{2}(?:-[A-Za-z0-9]{2,8})?)\.md$/i
|
|
7
|
+
|
|
8
|
+
let names = null
|
|
9
|
+
try { names = new Intl.DisplayNames(['en'], { type: 'language', fallback: 'none' }) } catch {}
|
|
10
|
+
// `talk.v1.md` or `talk.ok.md` is not a language; Intl knows which codes are.
|
|
11
|
+
const isLanguage = code => {
|
|
12
|
+
try { return !names || names.of(code) !== undefined } catch { return false }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** `{ name, lang }` for `talk.de.md`, or null when the file name names no language. */
|
|
16
|
+
export function languageOfFile(fileName) {
|
|
17
|
+
const match = LANGUAGE_FILE.exec(fileName)
|
|
18
|
+
if (!match || !isLanguage(match[2])) return null
|
|
19
|
+
return { name: match[1], lang: match[2] }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The languages among `fileNames` (one folder's files) that `fileName` is
|
|
24
|
+
* written in, as `[{ lang, file }]` sorted by language, or an empty list when
|
|
25
|
+
* there is no second language.
|
|
26
|
+
*/
|
|
27
|
+
export function languageFiles(fileName, fileNames) {
|
|
28
|
+
const own = languageOfFile(fileName)
|
|
29
|
+
if (!own) return []
|
|
30
|
+
const found = fileNames
|
|
31
|
+
.map(file => ({ file, language: languageOfFile(file) }))
|
|
32
|
+
.filter(({ language }) => language && language.name === own.name)
|
|
33
|
+
.map(({ file, language }) => ({ lang: language.lang, file }))
|
|
34
|
+
.sort((a, b) => a.lang.localeCompare(b.lang))
|
|
35
|
+
return found.length > 1 ? found : []
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Scripts written from right to left, by language; Intl knows it where the
|
|
39
|
+
// browser offers getTextInfo (not Firefox).
|
|
40
|
+
const RIGHT_TO_LEFT = new Set(['ar', 'arc', 'ckb', 'dv', 'fa', 'he', 'iw', 'ks', 'ku', 'nqo', 'ps', 'sd', 'syr', 'ug', 'ur', 'yi'])
|
|
41
|
+
|
|
42
|
+
/** 'rtl' for a language written from right to left (fa, ar, he …), else 'ltr'. */
|
|
43
|
+
export function textDirection(lang) {
|
|
44
|
+
if (typeof lang !== 'string' || !lang.trim()) return 'ltr'
|
|
45
|
+
try {
|
|
46
|
+
const locale = new Intl.Locale(lang.trim())
|
|
47
|
+
const info = locale.getTextInfo?.() ?? locale.textInfo
|
|
48
|
+
if (info?.direction) return info.direction
|
|
49
|
+
} catch {}
|
|
50
|
+
return RIGHT_TO_LEFT.has(lang.trim().toLowerCase().split('-')[0]) ? 'rtl' : 'ltr'
|
|
51
|
+
}
|
package/src/core/parseSlides.js
CHANGED
|
@@ -2,8 +2,8 @@ import yaml from 'js-yaml'
|
|
|
2
2
|
import { sourceLines, fenceState, scanDirectives, diagnostic } from './source.js'
|
|
3
3
|
export { sourceLines, fenceState } from './source.js'
|
|
4
4
|
|
|
5
|
-
export const DECK_KEYS = new Set('theme palette appearance params meta width height show lang labels callouts reader components server session bibliography csl nocite'.split(' '))
|
|
6
|
-
export const SLIDE_KEYS = new Set('layout id title section number part description label image alt overlay eyebrow attribution props'.split(' '))
|
|
5
|
+
export const DECK_KEYS = new Set('theme palette appearance params meta width height show lang labels callouts reader components server session bibliography csl nocite hotspots'.split(' '))
|
|
6
|
+
export const SLIDE_KEYS = new Set('layout id title section number part description label image alt overlay eyebrow attribution props hotspots'.split(' '))
|
|
7
7
|
|
|
8
8
|
// Names from before 2.0. They are still recognised, so that an old deck gets a
|
|
9
9
|
// message naming the new setting (see validateDeck) instead of being read as
|
|
@@ -43,6 +43,18 @@ function segmentsOf(source) {
|
|
|
43
43
|
return segments.filter(s => source.slice(s.start, s.end).trim())
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// The first block is the deck's settings when its first key is only a deck
|
|
47
|
+
// key. A key both may have (hotspots) leaves it to the others: a slide's own
|
|
48
|
+
// key makes it the first slide's, otherwise it is the deck's.
|
|
49
|
+
function isDeckBlock(text) {
|
|
50
|
+
const keys = sourceLines(text).map(line => line.text.match(/^([\w-]+):/)?.[1]).filter(Boolean)
|
|
51
|
+
const deckOnly = key => ANY_DECK_KEYS.has(key) && !SLIDE_KEYS.has(key)
|
|
52
|
+
if (!keys.length || deckOnly(keys[0])) return !!keys.length
|
|
53
|
+
if (!ANY_DECK_KEYS.has(keys[0])) return false
|
|
54
|
+
if (keys.some(deckOnly)) return true
|
|
55
|
+
return !keys.some(key => SLIDE_KEYS.has(key) && !ANY_DECK_KEYS.has(key))
|
|
56
|
+
}
|
|
57
|
+
|
|
46
58
|
export function looksLikeMeta(text, keys, { frontmatter = false } = {}) {
|
|
47
59
|
const first = sourceLines(text).find(line => line.text.trim() && !/^\s*#/.test(line.text))
|
|
48
60
|
const key = first?.text.match(/^([\w-]+):/)
|
|
@@ -109,7 +121,7 @@ export function parseSlides(source) {
|
|
|
109
121
|
let deckConfig = {}
|
|
110
122
|
let configSource = null
|
|
111
123
|
let i = 0
|
|
112
|
-
if (segments[0] && /^\s*---[ \t]*\r?\n/.test(source) && looksLikeMeta(text(segments[0]), ANY_DECK_KEYS, { frontmatter: true }) &&
|
|
124
|
+
if (segments[0] && /^\s*---[ \t]*\r?\n/.test(source) && looksLikeMeta(text(segments[0]), ANY_DECK_KEYS, { frontmatter: true }) && isDeckBlock(text(segments[0]))) {
|
|
113
125
|
deckConfig = readMeta(segments[0])
|
|
114
126
|
configSource = { start: segments[0].start, end: segments[0].end }
|
|
115
127
|
i++
|
package/src/core/validateDeck.js
CHANGED
|
@@ -4,6 +4,10 @@ import { palettesFor } from '../extensions/tokens.js'
|
|
|
4
4
|
import { LABEL_KEYS, CALLOUT_TYPES } from './labels.js'
|
|
5
5
|
import { resolveLayoutProps, propertyErrors } from '../layouts/layoutProps.js'
|
|
6
6
|
import { movedHint } from '../extensions/moved.js'
|
|
7
|
+
import { paramValue } from '../extensions/params.js'
|
|
8
|
+
|
|
9
|
+
// Hotspots (runtime/hotspots.js): the deck's setting and a slide's own.
|
|
10
|
+
const HOTSPOT_SETTINGS = ['auto', 'marked', 'off', true, false]
|
|
7
11
|
|
|
8
12
|
// `layouts`, `themes` and `palettes` are manifest maps from the extension
|
|
9
13
|
// registry (see src/extensions/discover.js). Checks for a kind are skipped when
|
|
@@ -47,6 +51,7 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
|
|
|
47
51
|
for (const key of Object.keys(config.session)) if (!['id', 'code'].includes(key)) add('invalid-config', `session.${key} is not a setting; available: id, code`, deck.configSource?.start, 'warning')
|
|
48
52
|
if (config.session.code != null && !/^\d{4,8}$/.test(String(config.session.code))) add('invalid-config', 'session.code must be 4 to 8 digits', deck.configSource?.start)
|
|
49
53
|
}
|
|
54
|
+
if (config.hotspots != null && !HOTSPOT_SETTINGS.includes(config.hotspots)) add('invalid-config', 'hotspots must be auto, marked, off, true or false', deck.configSource?.start)
|
|
50
55
|
const enums = { organization: ['title', 'all', 'none'], author: ['title', 'all', 'none'], numbers: ['slides', 'all', 'none'], sections: ['all', 'none'] }
|
|
51
56
|
const switches = ['citations']
|
|
52
57
|
if (isPlainObject(config.show)) {
|
|
@@ -66,7 +71,13 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
|
|
|
66
71
|
}
|
|
67
72
|
if (themes && config.params != null && isPlainObject(config.params) && Object.hasOwn(themes, config.theme ?? 'neue')) {
|
|
68
73
|
const theme = themes[config.theme ?? 'neue']
|
|
69
|
-
for (const name of Object.
|
|
74
|
+
for (const [name, value] of Object.entries(config.params)) {
|
|
75
|
+
const param = theme.params?.[name]
|
|
76
|
+
if (!param) { add('unknown-param', `Theme "${theme.id}" has no parameter "${name}"; available: ${Object.keys(theme.params ?? {}).join(', ') || 'none'}`, deck.configSource?.start, 'warning'); continue }
|
|
77
|
+
const result = paramValue(param, value)
|
|
78
|
+
if (result.problem) add('invalid-param', `params.${name} ${result.problem}; the theme's ${param.default} is used instead`, deck.configSource?.start, 'warning')
|
|
79
|
+
else if (result.note) add('invalid-param', `params.${name} ${result.note}; ${result.value} may not look right`, deck.configSource?.start, 'warning')
|
|
80
|
+
}
|
|
70
81
|
}
|
|
71
82
|
// Callout types the deck names under `callouts:` are its own, on purpose.
|
|
72
83
|
const calloutTypes = [...CALLOUT_TYPES, ...(isPlainObject(config.callouts) ? Object.keys(config.callouts).map(key => key.toLowerCase()) : [])]
|
|
@@ -84,6 +95,7 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
|
|
|
84
95
|
}
|
|
85
96
|
if (slide.meta.props != null && !isPlainObject(slide.meta.props)) add('invalid-props', 'props must be a mapping', offset)
|
|
86
97
|
if (slide.meta.overlay != null && typeof slide.meta.overlay !== 'boolean') add('invalid-metadata', 'overlay must be true or false', offset)
|
|
98
|
+
if (slide.meta.hotspots != null && !HOTSPOT_SETTINGS.includes(slide.meta.hotspots)) add('invalid-metadata', 'hotspots must be auto, marked, off, true or false', offset)
|
|
87
99
|
for (const node of directivesIn(deck.source.slice(slide.source.start, slide.source.end))) {
|
|
88
100
|
if (DIRECTIVES.includes(node.name) || calloutTypes.includes(node.name.toLowerCase())) continue
|
|
89
101
|
const near = closest(node.name.toLowerCase(), [...DIRECTIVES, ...calloutTypes])
|
|
@@ -9,6 +9,8 @@ import { Diagnostics } from './Diagnostics.jsx'
|
|
|
9
9
|
import { PalettePicker } from './PalettePicker.jsx'
|
|
10
10
|
import { LookThumbnail } from './LookThumbnail.jsx'
|
|
11
11
|
import { designUrl, DESIGN_TAB } from '../designLink.js'
|
|
12
|
+
import { normalMode } from '../../runtime/hotspots.js'
|
|
13
|
+
import { paramValue } from '../../extensions/params.js'
|
|
12
14
|
|
|
13
15
|
// What the `show` setting controls: the label for each choice and its values.
|
|
14
16
|
const SHOW = { organization: ['Organization', ['title', 'all', 'none']], author: ['Author and date', ['title', 'all', 'none']], numbers: ['Slide numbers', ['slides', 'all', 'none']], sections: ['Section labels', ['all', 'none']] }
|
|
@@ -74,11 +76,9 @@ export function DeckSettings({ state, edit }) {
|
|
|
74
76
|
<p class="section-title">Theme settings</p>
|
|
75
77
|
{Object.entries(theme.params).map(([key, def]) => {
|
|
76
78
|
const value = config.params?.[key]
|
|
77
|
-
const color = isColor(def.default)
|
|
78
79
|
return <Field key={key} label={def.title ?? key} hint={def.description}>
|
|
79
80
|
<div class="row">
|
|
80
|
-
|
|
81
|
-
<input type="text" value={value ?? ''} placeholder={def.default} onInput={e => param(key, e.currentTarget.value)} />
|
|
81
|
+
<ParamInput def={def} value={value} onChange={next => param(key, next)} />
|
|
82
82
|
{value != null && <button class="btn is-small is-icon" title="Back to the default" aria-label="Back to the default" onClick={() => param(key, '')}><Icon name="close" size={14} /></button>}
|
|
83
83
|
</div>
|
|
84
84
|
</Field>
|
|
@@ -93,6 +93,9 @@ export function DeckSettings({ state, edit }) {
|
|
|
93
93
|
{Object.entries(SHOW).map(([key, [label, options]]) => <Field key={key} label={label}>
|
|
94
94
|
<select value={config.show?.[key] ?? ''} onChange={e => show(key, e.currentTarget.value)}><option value="">default</option>{options.map(o => <option key={o} value={o}>{o}</option>)}</select>
|
|
95
95
|
</Field>)}
|
|
96
|
+
<Field label="Hotspots" hint="1–9 zoom in on a slide's pictures, code, tables and other parts while presenting; marked: only parts marked data-hotspot">
|
|
97
|
+
<select value={[null, 'off'].includes(normalMode(config.hotspots)) ? '' : normalMode(config.hotspots)} onChange={e => text('hotspots', e.currentTarget.value)}><option value="">off</option>{['auto', 'marked'].map(o => <option key={o} value={o}>{o}</option>)}</select>
|
|
98
|
+
</Field>
|
|
96
99
|
<Field label="Language" hint="of the deck, e.g. en, de or de-CH"><input type="text" value={config.lang ?? ''} placeholder="en" onInput={e => text('lang', e.currentTarget.value)} /></Field>
|
|
97
100
|
</div>
|
|
98
101
|
</div>
|
|
@@ -106,3 +109,34 @@ function DesignLink({ kind, item }) {
|
|
|
106
109
|
{own ? `Edit ${kind}` : `Customise ${kind}`} <span class="muted">{item.title}</span>
|
|
107
110
|
</a>
|
|
108
111
|
}
|
|
112
|
+
|
|
113
|
+
// The control for one theme parameter, by its type: a slider for a size or a
|
|
114
|
+
// number with a range, a box to tick for a switch, a list for a choice, else
|
|
115
|
+
// text (with a colour picker when the default is a colour). An empty value
|
|
116
|
+
// means the theme's own.
|
|
117
|
+
function ParamInput({ def, value, onChange }) {
|
|
118
|
+
const current = value != null && !paramValue(def, value).problem ? paramValue(def, value).value : def.default
|
|
119
|
+
if (def.type === 'switch') return <>
|
|
120
|
+
<input type="checkbox" checked={current === '1'} aria-label={def.title} onChange={e => onChange(e.currentTarget.checked ? 1 : 0)} style={{ flex: '0 0 auto', width: 'auto' }} />
|
|
121
|
+
<span class="muted" style={{ flex: '1 1 auto' }}>{current === '1' ? 'On' : 'Off'}</span>
|
|
122
|
+
</>
|
|
123
|
+
if (def.type === 'choice') return <select value={value ?? ''} onChange={e => onChange(e.currentTarget.value)}>
|
|
124
|
+
<option value="">{def.default} (the theme's)</option>
|
|
125
|
+
{def.choices.filter(choice => choice !== def.default).map(choice => <option key={choice} value={choice}>{choice}</option>)}
|
|
126
|
+
</select>
|
|
127
|
+
if ((def.type === 'size' || def.type === 'number') && def.min != null && def.max != null) {
|
|
128
|
+
const unit = def.type === 'size' ? 'px' : ''
|
|
129
|
+
const number = parseFloat(current)
|
|
130
|
+
const step = def.step ?? (def.type === 'size' ? 1 : (def.max - def.min) / 100)
|
|
131
|
+
return <>
|
|
132
|
+
<input type="range" min={def.min} max={def.max} step={step} value={number} aria-label={def.title}
|
|
133
|
+
onInput={e => onChange(unit ? `${e.currentTarget.value}${unit}` : Number(e.currentTarget.value))} />
|
|
134
|
+
<span class="muted" style={{ flex: '0 0 auto', minWidth: '4em', textAlign: 'end' }}>{number}{unit}</span>
|
|
135
|
+
</>
|
|
136
|
+
}
|
|
137
|
+
const color = isColor(def.default)
|
|
138
|
+
return <>
|
|
139
|
+
{color ? <input type="color" value={isColor(value) ? value : def.default} onInput={e => onChange(e.currentTarget.value)} style={{ flex: '0 0 auto' }} /> : null}
|
|
140
|
+
<input type="text" value={value ?? ''} placeholder={def.default} onInput={e => onChange(e.currentTarget.value)} />
|
|
141
|
+
</>
|
|
142
|
+
}
|
|
@@ -7,6 +7,8 @@ import { PalettePicker, Swatches } from './PalettePicker.jsx'
|
|
|
7
7
|
const isColor = value => /^#[0-9a-f]{3,8}$/i.test(String(value ?? '').trim())
|
|
8
8
|
const update = (list, index, patch) => list.map((item, i) => i === index ? { ...item, ...patch } : item)
|
|
9
9
|
const without = (list, index) => list.filter((_, i) => i !== index)
|
|
10
|
+
// The kinds of value a theme parameter takes (see src/extensions/params.js).
|
|
11
|
+
const PARAM_KINDS = [['', 'text'], ['size', 'size'], ['number', 'number'], ['switch', 'switch'], ['choice', 'choice'], ['font', 'font']]
|
|
10
12
|
|
|
11
13
|
function Identity({ model, onChange }) {
|
|
12
14
|
return <>
|
|
@@ -122,8 +124,18 @@ export function ThemeForm({ model, files, palettes = {}, onChange, onFile }) {
|
|
|
122
124
|
<input type="text" value={param.name} placeholder="fontBody" onInput={e => onChange({ params: update(model.params, index, { name: e.currentTarget.value }) })} />
|
|
123
125
|
<select value={param.token ?? ''} onChange={e => onChange({ params: update(model.params, index, { token: e.currentTarget.value }) })}><option value="">token…</option>{tokenNames.map(name => <option key={name} value={name}>{name}</option>)}</select>
|
|
124
126
|
<input type="text" value={param.title ?? ''} placeholder="Title" onInput={e => onChange({ params: update(model.params, index, { title: e.currentTarget.value }) })} />
|
|
127
|
+
<select value={param.type ?? ''} aria-label="Kind of value" title="Kind of value" onChange={e => onChange({ params: update(model.params, index, { type: e.currentTarget.value || undefined }) })}>
|
|
128
|
+
{PARAM_KINDS.map(([type, label]) => <option key={type} value={type}>{label}</option>)}
|
|
129
|
+
</select>
|
|
125
130
|
<button class="btn is-small is-icon" title="Remove setting" aria-label="Remove setting" onClick={() => onChange({ params: without(model.params, index) })}><Icon name="close" size={14} /></button>
|
|
126
131
|
</div>
|
|
132
|
+
{(param.type === 'size' || param.type === 'number') && <div class="row">
|
|
133
|
+
{['min', 'max', 'step'].map(key => <input key={key} type="number" value={param[key] ?? ''} placeholder={key} aria-label={key}
|
|
134
|
+
onInput={e => onChange({ params: update(model.params, index, { [key]: e.currentTarget.value === '' ? undefined : Number(e.currentTarget.value) }) })} />)}
|
|
135
|
+
</div>}
|
|
136
|
+
{param.type === 'choice' && <input type="text" value={(param.choices ?? []).join(', ')} placeholder="choices, separated by commas"
|
|
137
|
+
onInput={e => onChange({ params: update(model.params, index, { choices: e.currentTarget.value.split(',').map(choice => choice.trim()).filter(Boolean) }) })} />}
|
|
138
|
+
<input type="text" value={param.description ?? ''} placeholder="Description: the default and a sensible range" onInput={e => onChange({ params: update(model.params, index, { description: e.currentTarget.value }) })} />
|
|
127
139
|
</div>)}
|
|
128
140
|
<div><button class="btn is-small" onClick={() => onChange({ params: [...model.params, { name: '', token: tokenNames[0] ?? '', title: '' }] })}>+ setting</button></div>
|
|
129
141
|
<details class="advanced">
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { h } from 'preact'
|
|
2
2
|
import { setSlideMeta, setSlideNotes } from '../../core/editDeck.js'
|
|
3
3
|
import { Field, TextArea } from './Field.jsx'
|
|
4
|
+
import { normalMode } from '../../runtime/hotspots.js'
|
|
4
5
|
|
|
5
6
|
const MORE = [['section', 'Section label shown in the header'], ['number', 'Chapter number'], ['part', 'Part name for chapter slides'], ['label', 'Replaces the word "Chapter"'], ['description', 'Chapter description'], ['eyebrow', 'Small line above a heading'], ['attribution', 'Source of a quote']]
|
|
6
7
|
|
|
@@ -13,6 +14,12 @@ export function MetaFields({ slide, slideEdit }) {
|
|
|
13
14
|
}} />
|
|
14
15
|
return <>
|
|
15
16
|
<Field label="Slide id" hint="stable anchor and link target">{text('id')}</Field>
|
|
17
|
+
<Field label="Hotspots" hint="number keys zoom in on this slide's parts while presenting; default: the deck's setting">
|
|
18
|
+
<select value={normalMode(meta.hotspots) ?? ''} onChange={event => {
|
|
19
|
+
const value = event.currentTarget.value
|
|
20
|
+
slideEdit((deck, id) => setSlideMeta(deck, id, { hotspots: value === '' ? undefined : value === 'off' ? false : value }), { group: 'meta:hotspots' })
|
|
21
|
+
}}><option value="">the deck's</option>{['auto', 'marked', 'off'].map(o => <option key={o} value={o}>{o}</option>)}</select>
|
|
22
|
+
</Field>
|
|
16
23
|
<Field label="Speaker notes"><TextArea rows={4} value={slide.meta.notes ?? ''} onInput={value => slideEdit((deck, id) => setSlideNotes(deck, id, value), { group: 'notes' })} /></Field>
|
|
17
24
|
<details class="more" open={MORE.some(([key]) => meta[key] != null)}>
|
|
18
25
|
<summary>More metadata</summary>
|
package/src/editor/extensions.js
CHANGED
|
@@ -13,6 +13,9 @@ const STARTER_COLORS = {
|
|
|
13
13
|
|
|
14
14
|
const entries = table => Object.entries(table ?? {}).map(([name, value]) => ({ name, ...(value && typeof value === 'object' && !Array.isArray(value) ? value : { value }) }))
|
|
15
15
|
const table = (list, pick) => Object.fromEntries(list.filter(item => item.name).map(item => [item.name, pick(item)]))
|
|
16
|
+
// A parameter's settings as the manifest writes them; the typed ones only with a type.
|
|
17
|
+
const paramSettings = param => clean({ token: param.token, title: param.title, description: param.description,
|
|
18
|
+
...(param.type ? { type: param.type, min: param.min, max: param.max, step: param.step, choices: param.choices } : {}) })
|
|
16
19
|
const clean = object => Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined && value !== '' && value !== null && !(Array.isArray(value) && !value.length)))
|
|
17
20
|
|
|
18
21
|
export function parseManifest(text) {
|
|
@@ -39,7 +42,7 @@ export function toToml(model) {
|
|
|
39
42
|
else if (model.kind === 'theme') raw = {
|
|
40
43
|
...base, palette: model.palette, ...clean({ palettes: model.palettes }), ...(model.appearance === 'dark' ? { appearance: 'dark' } : {}), fonts: model.fonts ?? [], ...model.extra,
|
|
41
44
|
files: { styles: 'styles.css', ...(model.files ?? {}) }, tokens,
|
|
42
|
-
params: table(model.params ?? [],
|
|
45
|
+
params: table(model.params ?? [], paramSettings),
|
|
43
46
|
}
|
|
44
47
|
else if (model.kind === 'layout') raw = {
|
|
45
48
|
...base, ...(model.frame && model.frame !== 'standard' ? { frame: model.frame } : {}), ...model.extra, ...(Object.keys(model.files ?? {}).length ? { files: model.files } : {}),
|
|
@@ -67,7 +70,7 @@ export function toRuntimeManifest(model) {
|
|
|
67
70
|
if (model.kind === 'palette') return { ...shared, ...(model.theme ? { theme: model.theme } : {}), light: table(model.light ?? [], token => token.value ?? ''), dark: table(model.dark ?? [], token => token.value ?? '') }
|
|
68
71
|
if (model.kind === 'theme') return {
|
|
69
72
|
...shared, palette: model.palette, ...(model.palettes?.length ? { palettes: model.palettes } : {}), appearance: model.appearance ?? 'light', fonts: model.fonts ?? [], tokens,
|
|
70
|
-
params: table(model.params ?? [], param => ({
|
|
73
|
+
params: table(model.params ?? [], param => ({ ...paramSettings(param), default: tokens[param.token] ?? '' })),
|
|
71
74
|
...(model.extra?.guide ? { guide: runtimeGuide(model.extra.guide) } : {}),
|
|
72
75
|
}
|
|
73
76
|
return shared
|
|
@@ -89,6 +92,6 @@ export function starterFiles(kind, id, title, from = null, fromFiles = {}) {
|
|
|
89
92
|
return { [MANIFEST]: toToml({ kind, id, title, description: '', theme: '', light, dark, extra: {} }) }
|
|
90
93
|
}
|
|
91
94
|
if (kind !== 'theme' || !from) throw new Error('A new theme starts as a copy of another one')
|
|
92
|
-
const params = from.params && Object.fromEntries(Object.entries(from.params).map(([name, param]) => [name,
|
|
95
|
+
const params = from.params && Object.fromEntries(Object.entries(from.params).map(([name, param]) => [name, paramSettings(param)]))
|
|
93
96
|
return { [MANIFEST]: toToml({ ...toModel({ ...from, kind, params }), id, title, files: {} }), 'styles.css': fromFiles['styles.css'] ?? '' }
|
|
94
97
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// Theme tokens (type, spacing, fonts) and theme parameters come on top.
|
|
8
8
|
import { tokensToCss, COLOR_ROLES, APPEARANCES, palettesFor } from './tokens.js'
|
|
9
9
|
import { movedHint } from './moved.js'
|
|
10
|
+
import { paramValue } from './params.js'
|
|
10
11
|
|
|
11
12
|
// Code highlighting and logos on dark slides, unless the palette says otherwise.
|
|
12
13
|
export const DARK_TOKENS = {
|
|
@@ -62,8 +63,11 @@ export function buildAppearance({ theme, palettes = {}, palette, appearance, par
|
|
|
62
63
|
const overrides = {}
|
|
63
64
|
for (const [name, value] of Object.entries(params ?? {})) {
|
|
64
65
|
const param = theme.params?.[name]
|
|
65
|
-
if (param)
|
|
66
|
-
|
|
66
|
+
if (!param) { warnings.push(`Theme "${theme.id}" has no parameter "${name}"`); continue }
|
|
67
|
+
// A value that does not fit the parameter's type is left out: the theme's own value stays.
|
|
68
|
+
const result = paramValue(param, value)
|
|
69
|
+
if (result.problem) warnings.push(`params.${name} ${result.problem}; the theme's ${param.default} is used`)
|
|
70
|
+
else overrides[param.token] = result.value
|
|
67
71
|
}
|
|
68
72
|
for (const [key, value] of Object.entries(meta ?? {})) overrides[`--meta-${key}`] = JSON.stringify(String(value))
|
|
69
73
|
const overridesCss = tokensToCss(overrides)
|
|
@@ -6,6 +6,7 @@ import { resolve, relative, isAbsolute, sep } from 'node:path'
|
|
|
6
6
|
import { parse as parseToml, TomlError } from 'smol-toml'
|
|
7
7
|
import { TOKEN_NAME_RE, COLOR_ROLES, COLOR_FALLBACKS, APPEARANCES } from './tokens.js'
|
|
8
8
|
import { propertyErrors } from '../layouts/layoutProps.js'
|
|
9
|
+
import { paramSettingProblems, paramSettings } from './params.js'
|
|
9
10
|
|
|
10
11
|
export const SCHEMA_VERSION = 1
|
|
11
12
|
export const KINDS = ['layout', 'theme', 'palette']
|
|
@@ -198,10 +199,9 @@ function validateTheme(raw, shared, fail) {
|
|
|
198
199
|
const path = `params.${name}`
|
|
199
200
|
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)
|
|
200
201
|
if (!isPlainObject(param)) fail('must be a table with a token', path)
|
|
201
|
-
for (const key of Object.keys(param)) if (!['token', 'title', 'description'].includes(key)) fail('Unknown parameter setting. Allowed: token, title, description', `${path}.${key}`)
|
|
202
202
|
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`)
|
|
203
|
-
for (const key
|
|
204
|
-
params[name] = { token: param.token, default: tokens[param.token], ...(param.title ? { title: param.title } : {}), ...(param.description ? { description: param.description } : {}) }
|
|
203
|
+
for (const [key, message] of paramSettingProblems(param, tokens[param.token])) fail(message, `${path}.${key}`)
|
|
204
|
+
params[name] = { token: param.token, default: tokens[param.token], ...(param.title ? { title: param.title } : {}), ...(param.description ? { description: param.description } : {}), ...paramSettings(param) }
|
|
205
205
|
}
|
|
206
206
|
const guide = validateGuide(raw.guide, fail)
|
|
207
207
|
return {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// What a theme parameter may be set to. A parameter without a type takes any
|
|
2
|
+
// text, as before; a typed one says what kind of value it takes, and mdeck
|
|
3
|
+
// turns what the author wrote into CSS or says why it cannot:
|
|
4
|
+
//
|
|
5
|
+
// size pixels ("12px", or a number: 12), with min and max in pixels
|
|
6
|
+
// number a number, with min and max (a strength is one from 0 to 1)
|
|
7
|
+
// switch 1 or 0 (also true and false, on and off, yes and no)
|
|
8
|
+
// choice one of `choices`
|
|
9
|
+
// font a CSS font list
|
|
10
|
+
//
|
|
11
|
+
// The manifest check, `mdeck check`, the slides and the editor all use these
|
|
12
|
+
// functions, so they agree on what a value means.
|
|
13
|
+
|
|
14
|
+
export const PARAM_TYPES = ['size', 'number', 'switch', 'choice', 'font']
|
|
15
|
+
export const PARAM_KEYS = ['token', 'title', 'description', 'type', 'min', 'max', 'step', 'choices']
|
|
16
|
+
const RANGED = ['size', 'number']
|
|
17
|
+
const NUMBER_RE = /^-?(\d+\.?\d*|\.\d+)$/
|
|
18
|
+
const SWITCH = new Map([['1', '1'], ['0', '0'], ['true', '1'], ['false', '0'], ['on', '1'], ['off', '0'], ['yes', '1'], ['no', '0']])
|
|
19
|
+
|
|
20
|
+
// "12px", 12 or "12" as a number of pixels; null when it is not a size.
|
|
21
|
+
function pixels(value) {
|
|
22
|
+
const text = String(value).trim().toLowerCase()
|
|
23
|
+
const number = text.endsWith('px') ? text.slice(0, -2).trim() : text
|
|
24
|
+
return NUMBER_RE.test(number) ? Number(number) : null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const unit = param => param.type === 'size' ? 'px' : ''
|
|
28
|
+
|
|
29
|
+
/** The range a parameter was made for, as text ("6px to 20px", "at least 0"), or ''. */
|
|
30
|
+
export function paramRange(param) {
|
|
31
|
+
const { min, max } = param
|
|
32
|
+
const u = unit(param)
|
|
33
|
+
if (min != null && max != null) return `${min}${u} to ${max}${u}`
|
|
34
|
+
if (min != null) return `at least ${min}${u}`
|
|
35
|
+
if (max != null) return `at most ${max}${u}`
|
|
36
|
+
return ''
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The kind of value a parameter takes, for listings: " (size, 0px to 48px)", " (1 or 0)", or '' without a type. */
|
|
40
|
+
export function paramKind(param) {
|
|
41
|
+
if (!param.type) return ''
|
|
42
|
+
if (param.type === 'switch') return ' (1 or 0)'
|
|
43
|
+
if (param.type === 'choice') return ` (${param.choices.join(', ')})`
|
|
44
|
+
const range = paramRange(param)
|
|
45
|
+
return ` (${param.type}${range ? ', ' + range : ''})`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The CSS for a value an author set, or why it cannot be used.
|
|
50
|
+
* Returns { value } with a note when it lies outside the range (it is still
|
|
51
|
+
* used: the range is what the theme was made for, not a limit), or { problem }.
|
|
52
|
+
*/
|
|
53
|
+
export function paramValue(param, raw) {
|
|
54
|
+
const type = param.type
|
|
55
|
+
if (raw == null || (typeof raw === 'object')) return { problem: 'needs a single value' }
|
|
56
|
+
if (!type) return { value: String(raw) }
|
|
57
|
+
const text = String(raw).trim()
|
|
58
|
+
if (!text) return { problem: 'is empty' }
|
|
59
|
+
if (type === 'font') return { value: text }
|
|
60
|
+
if (type === 'switch') {
|
|
61
|
+
const value = SWITCH.get(text.toLowerCase())
|
|
62
|
+
return value ? { value } : { problem: 'is a switch: 1 or 0' }
|
|
63
|
+
}
|
|
64
|
+
if (type === 'choice') {
|
|
65
|
+
return param.choices.includes(text) ? { value: text } : { problem: `must be one of: ${param.choices.join(', ')}` }
|
|
66
|
+
}
|
|
67
|
+
const number = type === 'size' ? pixels(text) : (NUMBER_RE.test(text) ? Number(text) : null)
|
|
68
|
+
if (number == null) return { problem: type === 'size' ? 'is a size in pixels, such as 12px' : 'is a number, such as 0.5' }
|
|
69
|
+
const value = type === 'size' ? `${number}px` : String(number)
|
|
70
|
+
const outside = (param.min != null && number < param.min) || (param.max != null && number > param.max)
|
|
71
|
+
return outside ? { value, note: `is made for ${paramRange(param)}` } : { value }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* What is wrong with a parameter's settings in a manifest, as [key, message]
|
|
76
|
+
* pairs; `fallback` is its default (the token's value), which must fit it too.
|
|
77
|
+
*/
|
|
78
|
+
export function paramSettingProblems(param, fallback) {
|
|
79
|
+
const problems = []
|
|
80
|
+
for (const key of Object.keys(param)) if (!PARAM_KEYS.includes(key)) problems.push([key, `Unknown parameter setting. Allowed: ${PARAM_KEYS.join(', ')}`])
|
|
81
|
+
for (const key of ['title', 'description']) if (param[key] != null && typeof param[key] !== 'string') problems.push([key, 'must be text'])
|
|
82
|
+
const type = param.type
|
|
83
|
+
if (type == null) {
|
|
84
|
+
for (const key of ['min', 'max', 'step', 'choices']) if (param[key] != null) problems.push([key, 'needs a type (size or number for min, max and step; choice for choices)'])
|
|
85
|
+
return problems
|
|
86
|
+
}
|
|
87
|
+
if (!PARAM_TYPES.includes(type)) return [...problems, ['type', `must be one of: ${PARAM_TYPES.join(', ')}`]]
|
|
88
|
+
for (const key of ['min', 'max', 'step']) {
|
|
89
|
+
if (param[key] == null) continue
|
|
90
|
+
if (!RANGED.includes(type)) problems.push([key, `is for a size or a number, not a ${type}`])
|
|
91
|
+
else if (typeof param[key] !== 'number' || !Number.isFinite(param[key])) problems.push([key, `must be a number${type === 'size' ? ' of pixels, such as 12' : ''}`])
|
|
92
|
+
}
|
|
93
|
+
if (typeof param.step === 'number' && param.step <= 0) problems.push(['step', 'must be more than 0'])
|
|
94
|
+
if (typeof param.min === 'number' && typeof param.max === 'number' && param.min > param.max) problems.push(['max', 'must not be less than min'])
|
|
95
|
+
if (type === 'choice') {
|
|
96
|
+
const choices = param.choices
|
|
97
|
+
if (!Array.isArray(choices) || !choices.length || choices.some(choice => typeof choice !== 'string' || !choice.trim())) problems.push(['choices', 'a choice needs a list of its values, such as choices = ["left", "right"]'])
|
|
98
|
+
else if (new Set(choices).size !== choices.length) problems.push(['choices', 'lists a value twice'])
|
|
99
|
+
} else if (param.choices != null) problems.push(['choices', `is for a choice, not a ${type}`])
|
|
100
|
+
if (problems.length || fallback == null) return problems
|
|
101
|
+
// The default is the token's value: it has to be a value of this type too.
|
|
102
|
+
const result = paramValue(param, fallback)
|
|
103
|
+
if (result.problem) problems.push(['type', `the token's value ${JSON.stringify(fallback)} ${result.problem}`])
|
|
104
|
+
else if (result.note) problems.push(['type', `the token's value ${JSON.stringify(fallback)} lies outside ${paramRange(param)}`])
|
|
105
|
+
else if (type !== 'font' && result.value !== fallback) problems.push(['type', `write the token's value as ${JSON.stringify(result.value)}`])
|
|
106
|
+
return problems
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A parameter's typed settings, as a normalized manifest keeps them. */
|
|
110
|
+
export function paramSettings(param) {
|
|
111
|
+
if (!param.type) return {}
|
|
112
|
+
return Object.fromEntries(['type', 'min', 'max', 'step', 'choices'].filter(key => param[key] != null).map(key => [key, key === 'choices' ? [...param[key]] : param[key]]))
|
|
113
|
+
}
|