@codename/deck 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +96 -2
- package/dist/deck.bundle.mjs +114 -0
- package/dist/deck.bundle.mjs.map +7 -0
- package/dist/deck.css +164 -0
- package/dist/deck.js +275 -0
- package/dist/deck.js.map +7 -0
- package/dist/deck.mjs +50 -0
- package/dist/deck.mjs.map +7 -0
- package/docs/findings.md +57 -0
- package/docs/format.md +70 -0
- package/docs/host.md +49 -0
- package/docs/kits.md +76 -0
- package/package.json +54 -4
- package/src/blocks.js +99 -0
- package/src/css.d.ts +5 -0
- package/src/deck.css +164 -0
- package/src/deck.js +356 -0
- package/src/file.js +74 -0
- package/src/index.js +12 -0
- package/src/kit.js +185 -0
- package/src/look.js +124 -0
- package/src/parse.js +180 -0
- package/src/pictures.js +47 -0
- package/src/pptx.js +310 -0
- package/src/render.js +242 -0
- package/src/repair.js +51 -0
- package/src/standalone.js +89 -0
- package/src/strings.js +10 -0
- package/src/version.js +2 -0
- package/src/yaml.js +137 -0
package/src/deck.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// A deck on a page: its slides drawn into the element given, paged with the bar, the keys a
|
|
2
|
+
// presenter's clicker sends, a click or a swipe; each step shown in turn; moved between with the
|
|
3
|
+
// transition its settings name, through the View Transitions API; presented full screen, with
|
|
4
|
+
// the presenter's view (this slide, the next, the notes, the time) and all slides at once; light
|
|
5
|
+
// or dark; changed as Markdown beside the slides, or in place on a slide, live. Host-agnostic:
|
|
6
|
+
// the host gives the drawers of ```echarts and ```mermaid, how a picture's address resolves, its
|
|
7
|
+
// words, and what to do when the deck changes or is saved.
|
|
8
|
+
import { parseDeck } from './parse.js'
|
|
9
|
+
import { renderSlide, h } from './render.js'
|
|
10
|
+
import { tokensOf, schemeOf, VOICES } from './look.js'
|
|
11
|
+
import { mergeKits } from './kit.js'
|
|
12
|
+
import { stringsFor } from './strings.js'
|
|
13
|
+
|
|
14
|
+
/** @typedef {import('./parse.js').Deck} Deck */
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {{
|
|
17
|
+
* source: string, lang?: string, dir?: string, kit?: Record<string, any>, scheme?: 'light' | 'dark' | 'auto',
|
|
18
|
+
* blocks?: Record<string, import('./render.js').Drawer>, resolve?: (src: string) => string | null,
|
|
19
|
+
* strings?: Record<string, string>, headings?: 'real' | 'aria', editable?: boolean,
|
|
20
|
+
* onChange?: (source: string) => void, onSave?: (source: string) => void, onExport?: (deck: DeckView) => void,
|
|
21
|
+
* }} Options
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const RTL = /^(ar|he|fa|ur|ps|sd|ug|yi|dv|ckb)\b/i
|
|
25
|
+
const ICONS = {
|
|
26
|
+
back: 'M15 5l-7 7 7 7', next: 'M9 5l7 7-7 7', present: 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5',
|
|
27
|
+
presenter: 'M3 5h12v9H3zM18 8h3M18 12h3M6 19h6', grid: 'M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z',
|
|
28
|
+
edit: 'M4 20h4L19 9l-4-4L4 16zM13.5 6.5l4 4', scheme: 'M12 3a9 9 0 1 0 9 9 7 7 0 0 1-9-9z', export: 'M12 4v11M7 10l5 5 5-5M5 20h14',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @param {string} name */
|
|
32
|
+
function icon(name) {
|
|
33
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
34
|
+
svg.setAttribute('viewBox', '0 0 24 24')
|
|
35
|
+
svg.setAttribute('aria-hidden', 'true')
|
|
36
|
+
if (name === 'back' || name === 'next') svg.setAttribute('class', 'deck-flip')
|
|
37
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
38
|
+
path.setAttribute('d', ICONS[/** @type {keyof typeof ICONS} */ (name)])
|
|
39
|
+
svg.append(path)
|
|
40
|
+
return svg
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @typedef {ReturnType<typeof createDeck>} DeckView */
|
|
44
|
+
|
|
45
|
+
/** @param {HTMLElement} root @param {Options} options */
|
|
46
|
+
export function createDeck(root, options) {
|
|
47
|
+
let source = options.source.replace(/\r\n?/g, '\n')
|
|
48
|
+
let deck = parseDeck(source)
|
|
49
|
+
let at = 0, stepAt = 0
|
|
50
|
+
/** @type {{ element: HTMLElement, steps: number }[]} */
|
|
51
|
+
let drawn = []
|
|
52
|
+
const lang = String(options.lang ?? deck.settings.lang ?? document.documentElement.lang ?? 'en') || 'en'
|
|
53
|
+
const dir = String(options.dir ?? deck.settings.dir ?? (RTL.test(lang) ? 'rtl' : 'ltr'))
|
|
54
|
+
const words = { ...stringsFor(lang), ...options.strings }
|
|
55
|
+
const number = new Intl.NumberFormat(lang).format
|
|
56
|
+
const dark = matchMedia('(prefers-color-scheme: dark)')
|
|
57
|
+
let scheme = /** @type {'light' | 'dark'} */ ('light')
|
|
58
|
+
const started = Date.now()
|
|
59
|
+
|
|
60
|
+
root.classList.add('deck')
|
|
61
|
+
root.lang = lang
|
|
62
|
+
root.dir = dir
|
|
63
|
+
const stage = h('div', { class: 'deck-stage', tabindex: 0, role: 'region', 'aria-roledescription': 'carousel', 'aria-label': words.deck })
|
|
64
|
+
const where = h('span', { class: 'deck-where', 'aria-live': 'polite' })
|
|
65
|
+
const button = (/** @type {string} */ name, /** @type {string} */ label, /** @type {() => void} */ act) => {
|
|
66
|
+
const b = h('button', { type: 'button', 'aria-label': label, 'data-act': name }, [icon(name)])
|
|
67
|
+
b.addEventListener('click', act)
|
|
68
|
+
return b
|
|
69
|
+
}
|
|
70
|
+
const back = button('back', words.previous, () => move(-1))
|
|
71
|
+
const next = button('next', words.next, () => move(1))
|
|
72
|
+
const editButton = button('edit', words.edit, () => toggle('is-editing'))
|
|
73
|
+
const presenterButton = button('presenter', words.presenter, () => toggle('is-presenting'))
|
|
74
|
+
const overviewButton = button('grid', words.overview, () => toggle('is-overview'))
|
|
75
|
+
const bar = h('div', { class: 'deck-bar' }, [back, where, next, h('span', { class: 'deck-gap' }),
|
|
76
|
+
overviewButton, presenterButton, options.editable === false ? null : editButton,
|
|
77
|
+
button('scheme', words.scheme, () => { scheme = scheme === 'dark' ? 'light' : 'dark'; paintLook() }),
|
|
78
|
+
options.onExport ? button('export', words.export, () => options.onExport?.(view)) : null,
|
|
79
|
+
document.fullscreenEnabled ? button('present', words.present, () => present()) : null])
|
|
80
|
+
const sourceBox = /** @type {HTMLTextAreaElement} */ (h('textarea', { class: 'deck-source', spellcheck: 'false', 'aria-label': words.source, dir: 'auto' }))
|
|
81
|
+
const nextBox = h('div', { class: 'deck-next' })
|
|
82
|
+
const notes = h('div', { class: 'deck-notes', dir: 'auto', 'aria-label': words.notes })
|
|
83
|
+
const clock = h('span', { class: 'deck-clock' })
|
|
84
|
+
const presenter = h('div', { class: 'deck-presenter' }, [nextBox, notes])
|
|
85
|
+
root.replaceChildren(sourceBox, stage, presenter, bar)
|
|
86
|
+
bar.insertBefore(clock, bar.querySelector('.deck-gap'))
|
|
87
|
+
|
|
88
|
+
// The kit: deck's own, under the host's (an organisation's, a project's), under the file's own.
|
|
89
|
+
let kitProblems = /** @type {string[]} */ ([])
|
|
90
|
+
const kitOf = () => {
|
|
91
|
+
const merged = mergeKits(options.kit, deck.settings.kit)
|
|
92
|
+
kitProblems = merged.problems
|
|
93
|
+
return merged.kit
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function paintLook() {
|
|
97
|
+
context.kit = kitOf()
|
|
98
|
+
const tokens = tokensOf(deck.settings, context.kit.tokens)
|
|
99
|
+
const colours = schemeOf(tokens, scheme)
|
|
100
|
+
const stack = (/** @type {string[]} */ faces) => faces.map((f) => (/^[\w-]+$/.test(f) ? f : `"${f.replace(/["\\]/g, '')}"`)).join(', ')
|
|
101
|
+
const voice = tokens.fonts ? { heading: stack(tokens.fonts.heading), body: stack(tokens.fonts.body) } : VOICES[tokens.voice]
|
|
102
|
+
for (const [name, value] of Object.entries({
|
|
103
|
+
paper: colours.paper, ink: colours.ink, muted: colours.muted, line: colours.line, primary: colours.primary,
|
|
104
|
+
fill: tokens.primary, 'on-fill': colours.onPrimary, soft: colours.soft, heading: voice.heading, body: voice.body, radius: `${tokens.radius}px`,
|
|
105
|
+
...Object.fromEntries(colours.series.map((colour, n) => [`series-${n + 1}`, colour])),
|
|
106
|
+
})) root.style.setProperty(`--deck-${name}`, value)
|
|
107
|
+
root.dataset.scheme = scheme
|
|
108
|
+
root.dispatchEvent(new CustomEvent('deck-scheme', { detail: { scheme, colours, tokens } }))
|
|
109
|
+
}
|
|
110
|
+
const chooseScheme = () => {
|
|
111
|
+
const asked = options.scheme ?? deck.settings.colorSchema ?? 'auto'
|
|
112
|
+
scheme = asked === 'dark' || (asked === 'auto' && dark.matches) ? 'dark' : 'light'
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @type {import('./render.js').Context} */
|
|
116
|
+
const context = {
|
|
117
|
+
blocks: options.blocks ?? {}, resolve: options.resolve ?? ((src) => src), headings: options.headings ?? 'real', number: (n) => number(n), total: 0, kit: mergeKits().kit,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function draw() {
|
|
121
|
+
context.total = deck.slides.length
|
|
122
|
+
drawn = deck.slides.map((slide) => renderSlide(slide, context))
|
|
123
|
+
for (const [i, { element }] of drawn.entries()) {
|
|
124
|
+
element.setAttribute('aria-label', words.where.replace('{n}', number(i + 1)).replace('{count}', number(drawn.length)))
|
|
125
|
+
element.addEventListener('click', () => { if (root.classList.contains('is-overview')) { toggle('is-overview'); go(i, 0) } })
|
|
126
|
+
}
|
|
127
|
+
stage.replaceChildren(...drawn.map(({ element }) => element))
|
|
128
|
+
at = Math.min(at, drawn.length - 1)
|
|
129
|
+
show()
|
|
130
|
+
requestAnimationFrame(() => fitAll())
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Each slot's type shrunk, down to six tenths, until it holds what it says; a slide still over is marked. */
|
|
134
|
+
function fitAll() {
|
|
135
|
+
for (const { element } of drawn) fit(element)
|
|
136
|
+
}
|
|
137
|
+
/** @param {HTMLElement} slide */
|
|
138
|
+
function fit(slide) {
|
|
139
|
+
let over = false
|
|
140
|
+
for (const slot of /** @type {NodeListOf<HTMLElement>} */ (slide.querySelectorAll('.deck-slot'))) {
|
|
141
|
+
let scale = 1
|
|
142
|
+
slot.style.setProperty('--deck-fit', '1')
|
|
143
|
+
while (slot.scrollHeight > slot.clientHeight + 1 && scale > 0.6) slot.style.setProperty('--deck-fit', String((scale = Math.round((scale - 0.05) * 100) / 100)))
|
|
144
|
+
if (slot.scrollHeight > slot.clientHeight + 1) over = true
|
|
145
|
+
}
|
|
146
|
+
if (over) slide.dataset.overflow = 'true'
|
|
147
|
+
else delete slide.dataset.overflow
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function show() {
|
|
151
|
+
for (const [i, { element, steps }] of drawn.entries()) {
|
|
152
|
+
element.classList.toggle('is-shown', i === at)
|
|
153
|
+
element.inert = i !== at && !root.classList.contains('is-overview')
|
|
154
|
+
for (const one of element.querySelectorAll('.deck-step')) one.classList.toggle('is-on', i < at || (i === at && Number(one.getAttribute('data-step')) <= stepAt) || steps === 0)
|
|
155
|
+
}
|
|
156
|
+
where.textContent = `${number(at + 1)} / ${number(drawn.length)}`
|
|
157
|
+
back.toggleAttribute('disabled', at === 0 && stepAt === 0)
|
|
158
|
+
next.toggleAttribute('disabled', at >= drawn.length - 1 && stepAt >= (drawn[at]?.steps ?? 0))
|
|
159
|
+
notes.textContent = deck.slides[at]?.notes || words.noNotes
|
|
160
|
+
const following = deck.slides[at + 1]
|
|
161
|
+
nextBox.replaceChildren(h('span', {}, [following ? words.upNext : words.last]), ...(following ? [renderSlide(following, context).element] : []))
|
|
162
|
+
root.dispatchEvent(new CustomEvent('deck-move', { detail: { at, step: stepAt } }))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A click on, or the next key: the next step, else the next slide. @param {number} by */
|
|
166
|
+
function move(by) {
|
|
167
|
+
const steps = drawn[at]?.steps ?? 0
|
|
168
|
+
if (by > 0 && stepAt < steps) { stepAt++; show(); return }
|
|
169
|
+
if (by < 0 && stepAt > 0) { stepAt--; show(); return }
|
|
170
|
+
const to = at + by
|
|
171
|
+
if (to < 0 || to >= drawn.length) return
|
|
172
|
+
go(to, by > 0 ? 0 : drawn[to].steps)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** @param {number} to @param {number} [toStep] */
|
|
176
|
+
function go(to, toStep = 0) {
|
|
177
|
+
if (to === at) { stepAt = toStep; show(); return }
|
|
178
|
+
const forward = to > at
|
|
179
|
+
const settings = deck.slides[forward ? to : at]?.settings ?? {}
|
|
180
|
+
const named = String(settings.transition ?? deck.settings.transition ?? 'none')
|
|
181
|
+
// Slidev's `a | b`: one going forward, the other going back.
|
|
182
|
+
const [ahead, behind] = named.split('|').map((part) => part.trim())
|
|
183
|
+
let kind = forward ? ahead : behind ?? ahead
|
|
184
|
+
if (dir === 'rtl') kind = kind.replace(/-(left|right)$/, (_, side) => (side === 'left' ? '-right' : '-left'))
|
|
185
|
+
if (kind === 'view-transition') kind = 'morph'
|
|
186
|
+
const update = () => { at = to; stepAt = toStep; if (kind === 'morph') name(drawn[to].element); show() }
|
|
187
|
+
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
188
|
+
if (!document.startViewTransition || kind === 'none' || reduced || root.classList.contains('is-overview')) return update()
|
|
189
|
+
if (kind === 'morph') name(drawn[at].element, drawn[to].element)
|
|
190
|
+
document.documentElement.dataset.deckTransition = kind
|
|
191
|
+
const transition = document.startViewTransition(update)
|
|
192
|
+
// A transition skipped by the next one, as a clicker pressed twice does, is no error.
|
|
193
|
+
transition.ready.catch(() => {})
|
|
194
|
+
transition.finished.finally(() => {
|
|
195
|
+
delete document.documentElement.dataset.deckTransition
|
|
196
|
+
for (const { element } of drawn) for (const one of element.querySelectorAll('[data-morph]')) { /** @type {HTMLElement} */ (one).style.removeProperty('view-transition-name'); one.removeAttribute('data-morph') }
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Morph, as Keynote's Magic Move and PowerPoint's Morph do it, with nothing to write: what two
|
|
202
|
+
* slides share (a heading's words, a list item's, a picture, a chart) gets one name on each,
|
|
203
|
+
* so the browser moves it from where it was to where it goes. Called with the slide shown and
|
|
204
|
+
* the next, then with the next alone once it shows. @param {HTMLElement} slide @param {HTMLElement} [toward]
|
|
205
|
+
*/
|
|
206
|
+
function name(slide, toward) {
|
|
207
|
+
const key = (/** @type {Element} */ el) => `${el.tagName}:${el.tagName === 'IMG' ? el.getAttribute('src') : (el.textContent ?? '').trim().slice(0, 80)}`
|
|
208
|
+
const parts = (/** @type {HTMLElement} */ s) => /** @type {HTMLElement[]} */ ([...s.querySelectorAll('h1, h2, h3, h4, .deck-h, li, p, img, .deck-block, blockquote, .deck-image')])
|
|
209
|
+
if (toward) {
|
|
210
|
+
const there = new Map(parts(toward).map((el) => [key(el), el]))
|
|
211
|
+
let n = 0
|
|
212
|
+
for (const el of parts(slide)) {
|
|
213
|
+
const match = there.get(key(el))
|
|
214
|
+
if (!match || match.dataset.morph || el.closest('[data-morph]')) continue
|
|
215
|
+
const id = `deck-m${n++}`
|
|
216
|
+
el.dataset.morph = match.dataset.morph = id
|
|
217
|
+
el.style.setProperty('view-transition-name', id)
|
|
218
|
+
}
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
for (const el of /** @type {NodeListOf<HTMLElement>} */ (slide.querySelectorAll('[data-morph]'))) {
|
|
222
|
+
const id = /** @type {string} */ (el.dataset.morph)
|
|
223
|
+
for (const old of /** @type {NodeListOf<HTMLElement>} */ (stage.querySelectorAll(`[data-morph="${id}"]`))) if (old !== el) old.style.removeProperty('view-transition-name')
|
|
224
|
+
el.style.setProperty('view-transition-name', id)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** @param {string} mode */
|
|
229
|
+
function toggle(mode) {
|
|
230
|
+
for (const other of ['is-editing', 'is-presenting', 'is-overview']) if (other !== mode) root.classList.remove(other)
|
|
231
|
+
root.classList.toggle(mode)
|
|
232
|
+
editButton.setAttribute('aria-pressed', String(root.classList.contains('is-editing')))
|
|
233
|
+
presenterButton.setAttribute('aria-pressed', String(root.classList.contains('is-presenting')))
|
|
234
|
+
overviewButton.setAttribute('aria-pressed', String(root.classList.contains('is-overview')))
|
|
235
|
+
if (root.classList.contains('is-editing')) { sourceBox.value = source; sourceBox.focus() }
|
|
236
|
+
show()
|
|
237
|
+
requestAnimationFrame(() => fitAll())
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function present() {
|
|
241
|
+
stage.requestFullscreen?.().then(() => stage.focus(), () => {})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The source changed, by the Markdown or on a slide: read again, drawn again, where the user is. @param {string} text */
|
|
245
|
+
function setSource(text) {
|
|
246
|
+
source = text.replace(/\r\n?/g, '\n')
|
|
247
|
+
deck = parseDeck(source)
|
|
248
|
+
paintLook()
|
|
249
|
+
draw()
|
|
250
|
+
options.onChange?.(source)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Changed as Markdown: drawn again as the user types, the slide under the caret shown.
|
|
254
|
+
let pending = 0
|
|
255
|
+
sourceBox.addEventListener('input', (event) => {
|
|
256
|
+
if (/** @type {InputEvent} */ (event).isComposing) return
|
|
257
|
+
clearTimeout(pending)
|
|
258
|
+
pending = setTimeout(() => setSource(sourceBox.value), 120)
|
|
259
|
+
})
|
|
260
|
+
const followCaret = () => {
|
|
261
|
+
const caret = sourceBox.selectionStart
|
|
262
|
+
const found = deck.slides.findIndex((slide) => caret >= slide.start && caret <= slide.end + 1)
|
|
263
|
+
if (found >= 0 && found !== at) { at = found; stepAt = drawn[found]?.steps ?? 0; show() }
|
|
264
|
+
}
|
|
265
|
+
sourceBox.addEventListener('keyup', followCaret)
|
|
266
|
+
sourceBox.addEventListener('click', followCaret)
|
|
267
|
+
|
|
268
|
+
// Changed in place: a double click on words makes them editable; Enter or leaving keeps them.
|
|
269
|
+
stage.addEventListener('dblclick', (event) => {
|
|
270
|
+
if (options.editable === false || document.fullscreenElement) return
|
|
271
|
+
const target = /** @type {HTMLElement | null} */ (/** @type {Element} */ (event.target).closest('[data-edit]'))
|
|
272
|
+
if (!target) return
|
|
273
|
+
const [from, to] = /** @type {string} */ (target.dataset.edit).split('-').map(Number)
|
|
274
|
+
target.contentEditable = 'true'
|
|
275
|
+
target.focus()
|
|
276
|
+
const done = (/** @type {boolean} */ keep) => {
|
|
277
|
+
target.removeEventListener('keydown', key)
|
|
278
|
+
target.removeEventListener('blur', blur)
|
|
279
|
+
target.contentEditable = 'false'
|
|
280
|
+
const words = markdownOf(target)
|
|
281
|
+
if (keep && words !== source.slice(from, to)) setSource(source.slice(0, from) + words + source.slice(to))
|
|
282
|
+
else draw()
|
|
283
|
+
}
|
|
284
|
+
const key = (/** @type {KeyboardEvent} */ e) => {
|
|
285
|
+
if (e.isComposing) return
|
|
286
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); done(true) }
|
|
287
|
+
if (e.key === 'Escape') { e.preventDefault(); done(false) }
|
|
288
|
+
}
|
|
289
|
+
const blur = () => done(true)
|
|
290
|
+
target.addEventListener('keydown', key)
|
|
291
|
+
target.addEventListener('blur', blur)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
// The keys a clicker sends, and the deck's own.
|
|
295
|
+
root.addEventListener('keydown', (event) => {
|
|
296
|
+
const target = /** @type {HTMLElement} */ (event.target)
|
|
297
|
+
if (target.isContentEditable || target === sourceBox || (target.tagName === 'BUTTON' && (event.key === ' ' || event.key === 'Enter'))) {
|
|
298
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 's') { event.preventDefault(); options.onSave?.(source) }
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
const forward = dir === 'rtl' ? 'ArrowLeft' : 'ArrowRight', backward = dir === 'rtl' ? 'ArrowRight' : 'ArrowLeft'
|
|
302
|
+
if (event.altKey || event.ctrlKey || event.metaKey) {
|
|
303
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 's') { event.preventDefault(); options.onSave?.(source) }
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
const acts = /** @type {Record<string, () => void>} */ ({
|
|
307
|
+
[forward]: () => move(1), [backward]: () => move(-1), ArrowDown: () => move(1), ArrowUp: () => move(-1), PageDown: () => move(1), PageUp: () => move(-1), ' ': () => move(1),
|
|
308
|
+
Home: () => go(0), End: () => go(drawn.length - 1, drawn.at(-1)?.steps ?? 0), f: present, p: () => toggle('is-presenting'), o: () => toggle('is-overview'), e: () => toggle('is-editing'),
|
|
309
|
+
})
|
|
310
|
+
const act = acts[event.key]
|
|
311
|
+
if (!act) return
|
|
312
|
+
event.preventDefault()
|
|
313
|
+
act()
|
|
314
|
+
})
|
|
315
|
+
stage.addEventListener('click', (event) => {
|
|
316
|
+
if (document.fullscreenElement && !(/** @type {Element} */ (event.target).closest('a, button, input'))) move(1)
|
|
317
|
+
})
|
|
318
|
+
let touch = 0
|
|
319
|
+
stage.addEventListener('touchstart', (event) => { touch = event.touches[0].clientX }, { passive: true })
|
|
320
|
+
stage.addEventListener('touchend', (event) => {
|
|
321
|
+
const dx = event.changedTouches[0].clientX - touch
|
|
322
|
+
if (Math.abs(dx) > 50) move((dx < 0) === (dir !== 'rtl') ? 1 : -1)
|
|
323
|
+
})
|
|
324
|
+
dark.addEventListener('change', () => { chooseScheme(); paintLook() })
|
|
325
|
+
const tick = setInterval(() => {
|
|
326
|
+
const s = Math.floor((Date.now() - started) / 1000)
|
|
327
|
+
clock.textContent = root.classList.contains('is-presenting') ? `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` : ''
|
|
328
|
+
}, 1000)
|
|
329
|
+
|
|
330
|
+
chooseScheme()
|
|
331
|
+
paintLook()
|
|
332
|
+
draw()
|
|
333
|
+
document.fonts?.ready.then(() => fitAll())
|
|
334
|
+
|
|
335
|
+
const view = {
|
|
336
|
+
root, stage, get source() { return source }, get deck() { return deck }, get at() { return at }, get scheme() { return scheme }, get kit() { return context.kit }, get kitProblems() { return kitProblems }, lang, dir,
|
|
337
|
+
slides: () => drawn.map(({ element }) => element), setSource, go, toggle, present, fitAll,
|
|
338
|
+
setScheme: (/** @type {'light' | 'dark'} */ value) => { scheme = value; paintLook() },
|
|
339
|
+
destroy: () => clearInterval(tick),
|
|
340
|
+
}
|
|
341
|
+
return view
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** The Markdown of words changed in place: bold, italics, code and links kept as Markdown writes them. @param {Node} node @returns {string} */
|
|
345
|
+
export function markdownOf(node) {
|
|
346
|
+
let out = ''
|
|
347
|
+
for (const child of node.childNodes) {
|
|
348
|
+
if (child.nodeType === Node.TEXT_NODE) out += (child.textContent ?? '').replace(/([*_`[\]\\])/g, '\\$1')
|
|
349
|
+
else if (child instanceof HTMLElement) {
|
|
350
|
+
const inner = markdownOf(child)
|
|
351
|
+
out += ({ STRONG: `**${inner}**`, B: `**${inner}**`, EM: `*${inner}*`, I: `*${inner}*`, CODE: `\`${child.textContent}\``, DEL: `~~${inner}~~`, BR: ' \n',
|
|
352
|
+
A: `[${inner}](${child.getAttribute('href') ?? ''})` })[child.tagName] ?? inner
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return out.replace(/ /g, ' ').replace(/\n+$/, '')
|
|
356
|
+
}
|
package/src/file.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// A deck file, `.deck.html`: a web page any browser opens, whose Markdown sits whole in one
|
|
2
|
+
// element, <script type="text/markdown" id="deck">, which no browser runs. Read and written here
|
|
3
|
+
// as text, never parsed as HTML, so a host can read a deck without running anything in it.
|
|
4
|
+
// The one escape: `<script` and `</script`, in any case, gain a backslash after their `<` (and
|
|
5
|
+
// one more for each already there), which reading takes away again: the HTML parser ends the
|
|
6
|
+
// element at `</script`, and a `<script` after `<!--` would keep it from ending. Pure.
|
|
7
|
+
|
|
8
|
+
import { VERSION } from './version.js'
|
|
9
|
+
|
|
10
|
+
export const OPEN = '<script type="text/markdown" id="deck">'
|
|
11
|
+
const CLOSE = '</script>'
|
|
12
|
+
|
|
13
|
+
/** @param {string} markdown */
|
|
14
|
+
export const escape = (markdown) => markdown.replace(/<(\\*)(\/?script)/gi, '<\\$1$2')
|
|
15
|
+
/** @param {string} text */
|
|
16
|
+
export const unescape = (text) => text.replace(/<\\(\\*)(\/?script)/gi, '<$1$2')
|
|
17
|
+
|
|
18
|
+
/** @param {string} text */
|
|
19
|
+
const attribute = (text) => text.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
|
|
20
|
+
/** @param {string} text */
|
|
21
|
+
const content = (text) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Code held whole in the file: `</script`, `<script` and `<!--`, which only a string or a regular
|
|
25
|
+
* expression of a bundle holds, written with `\x3C` for their `<`, which reads the same there.
|
|
26
|
+
* @param {string} code
|
|
27
|
+
*/
|
|
28
|
+
export function inlineCode(code) {
|
|
29
|
+
if (/\\<(!--|\/?script)/i.test(code)) throw new Error('This code cannot be held in a page as it is.')
|
|
30
|
+
return code.replace(/<(!--|\/?script)/gi, '\\x3C$1')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A deck's Markdown, from a file's text, or null when it holds none.
|
|
35
|
+
* @param {string} html @returns {string | null}
|
|
36
|
+
*/
|
|
37
|
+
export function readDeckFile(html) {
|
|
38
|
+
const at = html.indexOf(OPEN)
|
|
39
|
+
if (at < 0) return null
|
|
40
|
+
const from = at + OPEN.length
|
|
41
|
+
const end = html.slice(from).search(/<\/script/i)
|
|
42
|
+
if (end < 0) return null
|
|
43
|
+
return unescape(html.slice(from, from + end).replace(/^\n/, '').replace(/\n$/, ''))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Where a linked file finds its renderer: this version of the package, on jsDelivr's npm mirror. @param {string} [version] */
|
|
47
|
+
export const rendererUrl = (version = VERSION) => `https://cdn.jsdelivr.net/npm/@codename/deck@${version}/dist/deck.js`
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A deck file around its Markdown: its title, language and direction in the page's head for
|
|
51
|
+
* whatever reads it before the renderer does, then the Markdown, in the body (the head is never shown), readable as it is when no
|
|
52
|
+
* renderer comes, then the renderer: linked by default, to this version's address, or held whole;
|
|
53
|
+
* `base` tells it where to load the libraries a slide needs (a CDN's npm mirror, or a node_modules).
|
|
54
|
+
* @param {string} markdown
|
|
55
|
+
* @param {{ title?: string, lang?: string, dir?: string, renderer?: { src: string } | { code: string }, base?: string, generator?: string }} [options]
|
|
56
|
+
*/
|
|
57
|
+
export function writeDeckFile(markdown, { title = '', lang = 'en', dir = 'auto', renderer = { src: rendererUrl() }, base, generator = `deck ${VERSION}` } = {}) {
|
|
58
|
+
const data = base ? ` data-base="${attribute(base)}"` : ''
|
|
59
|
+
const script = 'src' in renderer ? `<script src="${attribute(renderer.src)}"${data}></script>` : `<script${data}>${inlineCode(renderer.code)}</script>`
|
|
60
|
+
return /* html */ `<!doctype html>
|
|
61
|
+
<html lang="${attribute(lang)}" dir="${attribute(dir)}">
|
|
62
|
+
<meta charset="utf-8">
|
|
63
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
64
|
+
<meta name="generator" content="${attribute(generator)}">
|
|
65
|
+
<title>${content(title)}</title>
|
|
66
|
+
<style>#deck{display:block;white-space:pre-wrap;overflow-wrap:anywhere;font:1rem/1.6 ui-monospace,Menlo,Consolas,monospace;max-width:72ch;margin:2rem auto;padding:0 1rem}</style>
|
|
67
|
+
<body>
|
|
68
|
+
${OPEN}
|
|
69
|
+
${escape(markdown)}
|
|
70
|
+
${CLOSE}
|
|
71
|
+
${script}
|
|
72
|
+
</html>
|
|
73
|
+
`
|
|
74
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// deck for a host: draw a deck into an element, read and write its file, save it as PowerPoint.
|
|
2
|
+
export { createDeck, markdownOf } from './deck.js'
|
|
3
|
+
export { parseDeck } from './parse.js'
|
|
4
|
+
export { renderSlide } from './render.js'
|
|
5
|
+
export { STANDARD, mergeKits, layoutOf, inferLayout, labelOf, tokensFrom } from './kit.js'
|
|
6
|
+
export { tokensOf, schemeOf, LOOKS, readable, contrast } from './look.js'
|
|
7
|
+
export { readDeckFile, writeDeckFile, rendererUrl, escape, unescape } from './file.js'
|
|
8
|
+
export { toPptx } from './pptx.js'
|
|
9
|
+
export { defaultBlocks, libraryAt } from './blocks.js'
|
|
10
|
+
export { inlinePictures } from './pictures.js'
|
|
11
|
+
export { deckIn } from './repair.js'
|
|
12
|
+
export { VERSION } from './version.js'
|
package/src/kit.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// A kit: a design system and the layouts that go with it, as data and never as CSS or code, so a
|
|
2
|
+
// person, an organisation or a brain can make one and no kit can do more than lay out a slide.
|
|
3
|
+
// deck's own kit holds the usual layouts; a host's (an organisation's, a project's) and the deck
|
|
4
|
+
// file's own `kit:` go over it, in that order. A layout is a grid: its slots by name, the areas
|
|
5
|
+
// they take, its tracks, how its words sit, what paper it is on, and the PowerPoint layout it is
|
|
6
|
+
// nearest to. Everything is checked against a small grammar; what fails is dropped, with a reason.
|
|
7
|
+
// Pure.
|
|
8
|
+
|
|
9
|
+
/** @typedef {{ required?: boolean, max_words?: number, max_chars?: number, items?: [number, number], item_max_words?: number, heading_max_words?: number, kind?: string }} Budget */
|
|
10
|
+
/** @typedef {{ label?: Record<string, string> | string, intent?: string, use_when?: string, avoid_when?: string, slots: string[], budgets: Record<string, Budget>, areas: string[][], columns: string[], rows: string[], align: 'start' | 'center' | 'end', justify: 'start' | 'center' | 'end', paper: 'paper' | 'fill' | 'ink' | 'soft', title: 'normal' | 'large' | 'huge', pptx: string, numbered: boolean }} Layout */
|
|
11
|
+
/** @typedef {{ name?: string, tokens: Record<string, any>, layouts: Record<string, Layout> }} Kit */
|
|
12
|
+
|
|
13
|
+
const NAME = /^[a-z][a-z0-9-]{0,31}$/
|
|
14
|
+
const TRACK = /^(auto|min-content|max-content|\d{1,2}(\.\d)?fr|\d{1,3}%)$/
|
|
15
|
+
const PPTX = ['Title Slide', 'Title and Content', 'Section Header', 'Two Content', 'Comparison', 'Title Only', 'Blank', 'Content with Caption', 'Picture with Caption']
|
|
16
|
+
const ENUMS = /** @type {const} */ ({ align: ['start', 'center', 'end'], justify: ['start', 'center', 'end'], paper: ['paper', 'fill', 'ink', 'soft'], title: ['normal', 'large', 'huge'] })
|
|
17
|
+
|
|
18
|
+
/** The names brains give the usual layouts, for the ones deck has. */
|
|
19
|
+
export const ALIASES = /** @type {Record<string, string>} */ ({
|
|
20
|
+
title: 'cover', 'title-slide': 'cover', 'two-columns': 'two-cols', 'two-column': 'two-cols', columns: 'two-cols', comparison: 'two-cols-header',
|
|
21
|
+
'three-columns': 'three-cols', 'section-header': 'section', divider: 'section', big: 'fact', number: 'fact', 'image-text': 'image-right', closing: 'end', thanks: 'end',
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
/** deck's own kit: the usual layouts, the ones Slidev names and the ones PowerPoint has, as data. */
|
|
25
|
+
export const STANDARD = /** @type {Kit} */ ({
|
|
26
|
+
name: 'standard',
|
|
27
|
+
tokens: {},
|
|
28
|
+
layouts: Object.fromEntries(Object.entries(/** @type {Record<string, any>} */ ({
|
|
29
|
+
default: { label: { en: 'Title and content', fr: 'Titre et contenu' }, pptx: 'Title and Content' },
|
|
30
|
+
cover: { label: { en: 'Cover', fr: 'Couverture' }, justify: 'center', paper: 'fill', title: 'huge', pptx: 'Title Slide', numbered: false },
|
|
31
|
+
intro: { label: { en: 'Introduction', fr: 'Introduction' }, justify: 'center', paper: 'fill', title: 'large', pptx: 'Title Slide', numbered: false },
|
|
32
|
+
section: { label: { en: 'Section', fr: 'Section' }, justify: 'center', paper: 'ink', title: 'huge', pptx: 'Section Header', numbered: false },
|
|
33
|
+
center: { label: { en: 'Centred', fr: 'Centré' }, justify: 'center', align: 'center', pptx: 'Title Only' },
|
|
34
|
+
statement: { label: { en: 'Statement', fr: 'Affirmation' }, justify: 'center', align: 'center', title: 'large', pptx: 'Title Only' },
|
|
35
|
+
fact: { label: { en: 'Big number', fr: 'Grand chiffre' }, justify: 'center', align: 'center', title: 'huge', pptx: 'Title Only' },
|
|
36
|
+
quote: { label: { en: 'Quote', fr: 'Citation' }, justify: 'center', pptx: 'Title Only' },
|
|
37
|
+
end: { label: { en: 'Closing', fr: 'Clôture' }, justify: 'center', align: 'center', paper: 'fill', title: 'large', pptx: 'Title Slide', numbered: false },
|
|
38
|
+
full: { label: { en: 'Full', fr: 'Plein cadre' }, pptx: 'Blank', numbered: false },
|
|
39
|
+
none: { label: { en: 'Blank', fr: 'Vide' }, pptx: 'Blank', numbered: false },
|
|
40
|
+
'two-cols': { label: { en: 'Two columns', fr: 'Deux colonnes' }, slots: ['default', 'right'], areas: 'default right', columns: '1fr 1fr', pptx: 'Two Content' },
|
|
41
|
+
'two-cols-header': { label: { en: 'Comparison', fr: 'Comparaison' }, slots: ['default', 'left', 'right', 'bottom'], areas: 'default default | left right | bottom bottom', columns: '1fr 1fr', rows: 'auto 1fr auto', pptx: 'Comparison' },
|
|
42
|
+
'three-cols': { label: { en: 'Three columns', fr: 'Trois colonnes' }, slots: ['default', 'left', 'middle', 'right'], areas: 'default default default | left middle right', columns: '1fr 1fr 1fr', rows: 'auto 1fr', pptx: 'Comparison' },
|
|
43
|
+
'image-left': { label: { en: 'Picture left', fr: 'Image à gauche' }, pptx: 'Picture with Caption', justify: 'center' },
|
|
44
|
+
'image-right': { label: { en: 'Picture right', fr: 'Image à droite' }, pptx: 'Picture with Caption', justify: 'center' },
|
|
45
|
+
image: { label: { en: 'Picture', fr: 'Image' }, pptx: 'Blank', numbered: false },
|
|
46
|
+
})).map(([name, given]) => [name, /** @type {Layout} */ (layoutOf(given).layout)])),
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A layout as given, checked: its slots, areas and tracks in the grammar, its words' place, paper
|
|
51
|
+
* and size among their names. @param {any} given @returns {{ layout: Layout | null, problems: string[] }}
|
|
52
|
+
*/
|
|
53
|
+
export function layoutOf(given) {
|
|
54
|
+
const problems = []
|
|
55
|
+
if (!given || typeof given !== 'object') return { layout: null, problems: ['not a layout'] }
|
|
56
|
+
// Slots as names, or as a catalog writes them: each a budget, `title` being the heading the
|
|
57
|
+
// default slot opens with rather than a slot of its own.
|
|
58
|
+
/** @type {Record<string, Budget>} */
|
|
59
|
+
const budgets = {}
|
|
60
|
+
const named = Array.isArray(given.slots) || typeof given.slots === 'string' ? [given.slots].flat().map(String)
|
|
61
|
+
: given.slots && typeof given.slots === 'object' ? Object.entries(given.slots).map(([name, budget]) => {
|
|
62
|
+
if (budget && typeof budget === 'object') budgets[name] = clean(budget)
|
|
63
|
+
return name === 'title' ? 'default' : name
|
|
64
|
+
}) : ['default']
|
|
65
|
+
const slots = [...new Set(named)]
|
|
66
|
+
if (!slots.every((slot) => NAME.test(slot))) return { layout: null, problems: ['a slot name is not a-z, 0-9 and -'] }
|
|
67
|
+
if (!slots.includes('default')) slots.unshift('default')
|
|
68
|
+
// Areas as Slidev's and CSS's grid write them, rows split by | or new lines.
|
|
69
|
+
/** @type {string[]} */
|
|
70
|
+
const lines = Array.isArray(given.areas) ? given.areas.map((/** @type {any} */ row) => [row].flat().join(' ')) : String(given.areas ?? slots.join(' ')).split(/\||\n/)
|
|
71
|
+
const rows = lines.map((row) => row.trim().replace(/^"|"$/g, '').split(/\s+/)).filter((row) => row.length && row[0])
|
|
72
|
+
const width = rows[0]?.length ?? 0
|
|
73
|
+
if (!rows.length || rows.some((row) => row.length !== width) || rows.flat().some((cell) => cell !== '.' && !slots.includes(cell))) return { layout: null, problems: ['areas are not a rectangle of its slots'] }
|
|
74
|
+
for (const slot of slots) {
|
|
75
|
+
const cells = rows.flatMap((row, y) => row.map((cell, x) => /** @type {[string, number, number]} */ ([cell, x, y]))).filter(([cell]) => cell === slot)
|
|
76
|
+
if (!cells.length) { if (slot !== 'default') problems.push(`slot ${slot} has no area`); continue }
|
|
77
|
+
const xs = cells.map((c) => c[1]), ys = cells.map((c) => c[2])
|
|
78
|
+
if (cells.length !== (Math.max(...xs) - Math.min(...xs) + 1) * (Math.max(...ys) - Math.min(...ys) + 1)) return { layout: null, problems: [`slot ${slot} is not a rectangle`] }
|
|
79
|
+
}
|
|
80
|
+
const tracks = (/** @type {any} */ value, /** @type {number} */ count) => {
|
|
81
|
+
const list = value === undefined ? Array(count).fill('1fr') : (Array.isArray(value) ? value : String(value).split(/\s+/)).map(String)
|
|
82
|
+
return list.length === count && list.every((t) => TRACK.test(t)) ? list : null
|
|
83
|
+
}
|
|
84
|
+
const columns = tracks(given.columns, width), rowTracks = tracks(given.rows, rows.length)
|
|
85
|
+
if (!columns || !rowTracks) return { layout: null, problems: ['columns or rows are not tracks deck knows (auto, 1fr, 40%…), one per column or row'] }
|
|
86
|
+
const pick = (/** @type {keyof typeof ENUMS} */ key, /** @type {string} */ fallback) => {
|
|
87
|
+
const value = String(given[key] ?? fallback)
|
|
88
|
+
if (!(/** @type {readonly string[]} */ (ENUMS[key])).includes(value)) { problems.push(`${key} ${value} is not one of ${ENUMS[key].join(', ')}`); return fallback }
|
|
89
|
+
return value
|
|
90
|
+
}
|
|
91
|
+
const label = typeof given.label === 'string' || (given.label && typeof given.label === 'object') ? given.label : undefined
|
|
92
|
+
const words = (/** @type {any} */ v) => (typeof v === 'string' ? v.slice(0, 300) : undefined)
|
|
93
|
+
return {
|
|
94
|
+
layout: /** @type {Layout} */ ({
|
|
95
|
+
label, intent: words(given.intent), use_when: words(given.use_when), avoid_when: words(given.avoid_when), slots, budgets, areas: rows, columns, rows: rowTracks,
|
|
96
|
+
align: pick('align', 'start'), justify: pick('justify', 'start'), paper: pick('paper', 'paper'), title: pick('title', 'normal'),
|
|
97
|
+
pptx: PPTX.includes(given.pptx) ? given.pptx : slots.length > 1 ? 'Comparison' : 'Title and Content', numbered: given.numbered !== false,
|
|
98
|
+
}),
|
|
99
|
+
problems,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A slot's budget, its numbers only. @param {Record<string, any>} given @returns {Budget} */
|
|
104
|
+
function clean(given) {
|
|
105
|
+
/** @type {Record<string, any>} */
|
|
106
|
+
const kept = {}
|
|
107
|
+
for (const key of ['max_words', 'max_chars', 'item_max_words', 'heading_max_words']) if (Number.isInteger(given[key]) && given[key] > 0) kept[key] = given[key]
|
|
108
|
+
if (Array.isArray(given.items) && given.items.length === 2 && given.items.every(Number.isInteger)) kept.items = given.items
|
|
109
|
+
if (typeof given.required === 'boolean') kept.required = given.required
|
|
110
|
+
if (typeof given.kind === 'string' && /^[a-z-]{1,20}$/.test(given.kind)) kept.kind = given.kind
|
|
111
|
+
return kept
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Design tokens as the W3C's Design Tokens format (DTCG 2025.10) writes them, read into a kit's:
|
|
116
|
+
* `color.light` and `color.dark` groups (bg, surface, fg, muted, accent) or flat colours, `font.sans`
|
|
117
|
+
* and the like; each `$value` a hex string or a colour object with its `hex`. Tokens already flat
|
|
118
|
+
* pass through. @param {any} given @returns {Record<string, any>}
|
|
119
|
+
*/
|
|
120
|
+
export function tokensFrom(given) {
|
|
121
|
+
if (!given || typeof given !== 'object') return {}
|
|
122
|
+
if (!JSON.stringify(given).includes('"$value"')) return given
|
|
123
|
+
const value = (/** @type {any} */ t) => (t && typeof t === 'object' && '$value' in t ? t.$value : t)
|
|
124
|
+
const colour = (/** @type {any} */ t) => { const v = value(t); return typeof v === 'string' ? v : typeof v?.hex === 'string' ? v.hex : undefined }
|
|
125
|
+
const face = (/** @type {any} */ t) => { const v = value(t); return Array.isArray(v) ? v.map(String) : typeof v === 'string' ? [v] : undefined }
|
|
126
|
+
const NAMES = /** @type {const} */ ({ bg: 'paper', background: 'paper', paper: 'paper', fg: 'ink', text: 'ink', ink: 'ink', accent: 'primary', primary: 'primary', muted: 'muted', surface: 'soft', soft: 'soft' })
|
|
127
|
+
const group = (/** @type {any} */ g) => Object.fromEntries(Object.entries(g ?? {}).filter(([k]) => k in NAMES).map(([k, t]) => [NAMES[/** @type {keyof typeof NAMES} */ (k)], colour(t)]).filter(([, v]) => v))
|
|
128
|
+
const colours = given.color ?? given.colour ?? {}
|
|
129
|
+
/** @type {Record<string, any>} */
|
|
130
|
+
const tokens = { ...group(colours), ...group(colours.light) }
|
|
131
|
+
const dark = group(colours.dark)
|
|
132
|
+
if (Object.keys(dark).length) tokens.dark = dark
|
|
133
|
+
const fonts = given.font ?? given.fontFamily ?? {}
|
|
134
|
+
const heading = face(fonts.heading ?? fonts.display ?? fonts.sans), body = face(fonts.body ?? fonts.text ?? fonts.sans)
|
|
135
|
+
if (heading || body) tokens.fonts = { heading: heading ?? body, body: body ?? heading }
|
|
136
|
+
return tokens
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Kits merged, the later over the earlier: their tokens, and their layouts by name, each checked.
|
|
141
|
+
* @param {...any} kits @returns {{ kit: Kit, problems: string[] }}
|
|
142
|
+
*/
|
|
143
|
+
export function mergeKits(...kits) {
|
|
144
|
+
/** @type {Kit} */
|
|
145
|
+
const kit = { name: 'standard', tokens: {}, layouts: { ...STANDARD.layouts } }
|
|
146
|
+
const problems = []
|
|
147
|
+
for (const given of kits) {
|
|
148
|
+
if (!given || typeof given !== 'object') continue
|
|
149
|
+
if (typeof given.name === 'string') kit.name = given.name
|
|
150
|
+
if (given.tokens && typeof given.tokens === 'object') kit.tokens = { ...kit.tokens, ...tokensFrom(given.tokens) }
|
|
151
|
+
for (const [name, definition] of Object.entries(given.layouts ?? given.types ?? {})) {
|
|
152
|
+
if (!NAME.test(name)) { problems.push(`layout ${name}: not a-z, 0-9 and -`); continue }
|
|
153
|
+
const { layout, problems: found } = layoutOf(definition)
|
|
154
|
+
problems.push(...found.map((p) => `layout ${name}: ${p}`))
|
|
155
|
+
if (layout) kit.layouts[name] = layout
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { kit, problems }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A layout's name in a language, else its own name. @param {Layout} layout @param {string} name @param {string} lang */
|
|
162
|
+
export function labelOf(layout, name, lang) {
|
|
163
|
+
if (typeof layout.label === 'string') return layout.label
|
|
164
|
+
return layout.label?.[lang] ?? layout.label?.[lang.split('-')[0]] ?? layout.label?.en ?? name
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The layout a slide that names none is given, from what it holds, as Pandoc's PowerPoint writer
|
|
169
|
+
* chooses: the first slide is the cover; a heading alone, a section; slots beyond the first, the
|
|
170
|
+
* layout of two (or three) columns that has them; a number alone as its heading, a fact; a quote
|
|
171
|
+
* alone, a quote; else the default. @param {{ index: number, slots: { name: string, text: string }[] }} slide
|
|
172
|
+
*/
|
|
173
|
+
export function inferLayout(slide) {
|
|
174
|
+
const names = slide.slots.map((slot) => slot.name)
|
|
175
|
+
if (names.includes('middle')) return 'three-cols'
|
|
176
|
+
if (names.includes('left')) return 'two-cols-header'
|
|
177
|
+
if (names.includes('right')) return 'two-cols'
|
|
178
|
+
if (slide.index === 0) return 'cover'
|
|
179
|
+
const text = slide.slots.map((slot) => slot.text).join('\n').trim()
|
|
180
|
+
const lines = text.split('\n').filter((line) => line.trim())
|
|
181
|
+
if (lines.length === 1 && /^#{1,2}\s+\S/.test(lines[0])) return 'section'
|
|
182
|
+
if (/^#\s+[^\s]*\d[\d\s.,%€$£¥]*\S*\s*$/u.test(lines[0] ?? '') && lines.length <= 3 && !/^\s*[-*\d]+[.)]?\s/m.test(lines.slice(1).join('\n'))) return 'fact'
|
|
183
|
+
if (lines.length && lines.every((line, i) => line.startsWith('>') || (i === lines.length - 1 && !line.startsWith('#')))) return lines[0].startsWith('>') ? 'quote' : 'default'
|
|
184
|
+
return 'default'
|
|
185
|
+
}
|