@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/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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
20
+ /** @param {string} text */
21
+ const content = (text) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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
+ }