@codename/deck 0.3.0 → 0.4.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.css CHANGED
@@ -43,6 +43,8 @@
43
43
  .deck-slide [data-align="center"] { text-align: center; }
44
44
  .deck-block { position: relative; flex: 1 1 auto; min-block-size: 12cqi; overflow: hidden; }
45
45
  .deck-block .deck-chart, .deck-block .deck-diagram { position: absolute; inset: 0; display: grid; place-items: center; }
46
+ /* A diagram's picture held to its box, its track sized so its share of it can be. */
47
+ .deck-block .deck-diagram { grid-template: minmax(0, 1fr) / minmax(0, 1fr); }
46
48
  .deck-block .deck-diagram img { max-inline-size: 100%; max-block-size: 100%; }
47
49
  .deck-block .deck-block-source { display: none; }
48
50
  .deck-block.is-failed .deck-block-source { display: block; }
@@ -79,9 +81,113 @@
79
81
  .deck-slide[data-title="huge"] :is(h2, .deck-h2) { font-size: 2.4em; }
80
82
  /* What the usual layouts add to their definition: Slidev's looks for them. */
81
83
  .deck-layout-section .deck-slot::before { content: ""; display: block; inline-size: 3em; block-size: 0.3em; background: var(--deck-fill); margin-block-end: 0.8em; border-radius: 0.15em; }
84
+ .deck-layout-subsection .deck-slot::before { content: ""; display: block; inline-size: 2em; block-size: 0.2em; background: var(--deck-primary); margin-block-end: 0.7em; border-radius: 0.1em; }
82
85
  .deck-layout-statement :is(h1, h2, p, .deck-h1, .deck-h2) { font-size: 2.4em; font-family: var(--deck-heading); font-weight: 700; line-height: 1.2; }
83
86
  .deck-slide.deck-layout-fact :is(h1, .deck-h1) { font-size: 5em; color: var(--deck-primary); margin-block-end: 0.15em; }
84
87
  .deck-layout-quote blockquote { font-size: 1.7em; border: 0; padding: 0; font-family: var(--deck-heading); }
88
+ /* A mind map (mindmap.js): its words where its layout puts them, in em of its own type, the whole shrunk to its box; its
89
+ root on the fill, each branch in a colour of the charts', its lines and its first words' rim. */
90
+ .deck-mindmap { position: relative; flex: 1 1 auto; align-self: stretch; min-block-size: 12cqi; overflow: hidden; }
91
+ .deck-map-plane { position: absolute; top: 50%; left: 50%; translate: -50% -50%; }
92
+ .deck-map-lines { position: absolute; inset: 0; inline-size: 100%; block-size: 100%; overflow: visible; }
93
+ .deck-map-lines path { fill: none; stroke: var(--deck-map-colour); stroke-width: 0.07; stroke-linecap: round; }
94
+ .deck-map-lines path[data-depth="1"] { stroke-width: 0.15; }
95
+ .deck-slide .deck-map-tree, .deck-slide .deck-map-tree :is(ul, li) { margin: 0; padding: 0; list-style: none; }
96
+ .deck-slide .deck-mindmap .deck-map-node { position: absolute; inline-size: max-content; max-inline-size: 11em; margin: 0; padding: 0.15em 0.35em; font-family: var(--deck-body); font-size: 0.85em; font-weight: 400; line-height: 1.2; letter-spacing: 0; text-align: center; text-wrap: balance; color: inherit; }
97
+ /* A chart or a diagram in a node, under its words, at a size of its own. */
98
+ .deck-slide .deck-mindmap .deck-map-node.has-block[data-depth] { display: flex; flex-direction: column; align-items: center; gap: 0.2em; max-inline-size: none; }
99
+ .deck-slide .deck-mindmap .deck-map-node .deck-block { flex: none; inline-size: 13em; block-size: 7.5em; min-block-size: 0; margin: 0; font-size: 0.85em; }
100
+ /* Words wrapped beyond the first branches keep to the side their line comes from. */
101
+ .deck-slide .deck-mindmap .deck-map-node[data-side="right"]:not([data-depth="1"]) { text-align: left; }
102
+ .deck-slide .deck-mindmap .deck-map-node[data-side="left"]:not([data-depth="1"]) { text-align: right; }
103
+ .deck-slide .deck-mindmap .deck-map-node[data-depth="0"] { max-inline-size: 9em; padding: 0.55em 1em; font-family: var(--deck-heading); font-size: 1.35em; font-weight: 700; background: var(--deck-fill); color: var(--deck-on-fill); border-radius: 1.2em; }
104
+ .deck-slide .deck-mindmap .deck-map-node[data-depth="0"]:empty { inline-size: 0.8em; block-size: 0.8em; padding: 0; border-radius: 50%; }
105
+ .deck-slide .deck-mindmap .deck-map-node[data-depth="1"] { max-inline-size: 9em; padding: 0.3em 0.8em; font-size: 1em; font-weight: 600; background: color-mix(in srgb, var(--deck-map-colour) 14%, transparent); border: 0.12em solid var(--deck-map-colour); border-radius: 1em; }
106
+ .deck-mindmap [data-branch="1"] { --deck-map-colour: var(--deck-series-1); }
107
+ .deck-mindmap [data-branch="2"] { --deck-map-colour: var(--deck-series-2); }
108
+ .deck-mindmap [data-branch="3"] { --deck-map-colour: var(--deck-series-3); }
109
+ .deck-mindmap [data-branch="4"] { --deck-map-colour: var(--deck-series-4); }
110
+ .deck-mindmap [data-branch="5"] { --deck-map-colour: var(--deck-series-5); }
111
+ .deck-mindmap [data-branch="6"] { --deck-map-colour: var(--deck-series-6); }
112
+ /* A branch shown a click fades in where it is: moved, its words would be placed from it. */
113
+ .deck-mindmap .deck-step:not(.is-on) { translate: none; }
114
+ /* Figures (figures.js): a slot's lists as cards, key figures, a timeline, chevrons, a programme, a pyramid or a bento;
115
+ its parts face to face or in a matrix; the deck's contents. In em of the slot's type, so they shrink with it. */
116
+ .deck-figure { flex: 1 1 auto; min-block-size: 0; display: grid; gap: 1em; align-content: center; line-break: strict; }
117
+ .deck-slide .deck-figure :is(p, ul, ol) { margin-block: 0 0.4em; }
118
+ .deck-slide .deck-figure :is(p, ul, ol):last-child { margin-block-end: 0; }
119
+ .deck-words { display: block; }
120
+ .deck-figure .deck-lead { font-family: var(--deck-heading); font-weight: 700; line-height: 1.2; }
121
+ .deck-figure .deck-text { line-height: 1.35; }
122
+ .deck-figure :is(ul, ol).deck-words { font-size: 0.85em; }
123
+ /* On the fill, words are on the fill's ink, their bold and links too. */
124
+ :is(.deck-tile.is-big, .deck-part.is-highlight, .deck-chevron, .deck-level) :is(strong, a, h3, .deck-h3) { color: inherit; }
125
+ :is(.deck-tile.is-big, .deck-part.is-highlight) li::marker { color: inherit; }
126
+ /* A chart or a diagram on the fill would lose its first colour, the fill's: what holds one is on the soft fill, ringed with it. */
127
+ :is(.deck-tile.is-big, .deck-part.is-highlight):has(.deck-block) { background: var(--deck-soft); color: var(--deck-ink); border: 0.15em solid var(--deck-fill); }
128
+ /* Steps: a row of cards, each with its number. */
129
+ .deck-steps { align-items: stretch; }
130
+ .deck-card { display: flex; flex-direction: column; gap: 0.45em; padding: 1.1em 1em; background: var(--deck-soft); border-radius: var(--deck-radius); }
131
+ .deck-badge { display: grid; place-items: center; text-align: center; inline-size: 1.9em; block-size: 1.9em; border-radius: 50%; background: var(--deck-fill); color: var(--deck-on-fill); font-family: var(--deck-heading); font-weight: 700; font-variant-numeric: tabular-nums; }
132
+ .deck-card .deck-lead { font-size: 1.05em; }
133
+ .deck-card .deck-text { font-size: 0.9em; }
134
+ /* Key figures: a row of numbers, each with what it counts, a rule between them. */
135
+ .deck-metrics { gap: 1em 0; text-align: center; }
136
+ .deck-metric { display: flex; flex-direction: column; align-items: center; gap: 0.35em; padding: 0.3em 1em; }
137
+ .deck-metric + .deck-metric { border-inline-start: 1px solid var(--deck-line); }
138
+ .deck-metric .deck-lead { font-size: 3.4em; font-weight: 800; line-height: 1; letter-spacing: -0.02em; color: var(--deck-primary); font-variant-numeric: tabular-nums; }
139
+ /* A timeline: its milestones along a line, each its lead over its dot and its words under it. */
140
+ .deck-timeline { grid-template-rows: auto auto auto; gap: 0.6em 0; justify-items: center; text-align: center; }
141
+ .deck-timeline-line { grid-row: 2; grid-column: 1 / -1; justify-self: stretch; align-self: center; block-size: 0.2em; margin-inline: calc(50% / var(--deck-count)); background: var(--deck-line); border-radius: 0.1em; }
142
+ .deck-timeline .deck-lead { align-self: end; padding-inline: 0.4em; font-size: 1.2em; color: var(--deck-primary); }
143
+ .deck-dot { inline-size: 1.15em; block-size: 1.15em; border-radius: 50%; background: var(--deck-fill); border: 0.22em solid var(--deck-paper); }
144
+ .deck-milestone { align-self: start; padding-inline: 0.4em; font-size: 0.9em; }
145
+ /* A process: chevrons, each holding a step's name, its words under it; they point the way the language reads. */
146
+ .deck-process { gap: 0.7em 0; }
147
+ .deck-chevron { --deck-tip: 0.9em; display: flex; align-items: center; justify-content: center; min-block-size: 3.2em; padding: 0.5em calc(var(--deck-tip) + 0.3em) 0.5em calc(var(--deck-tip) * 2); margin-inline-end: calc(0.3em - var(--deck-tip)); background: var(--deck-fill); color: var(--deck-on-fill); text-align: center;
148
+ clip-path: polygon(0% 0%, calc(100% - var(--deck-tip)) 0%, 100% 50%, calc(100% - var(--deck-tip)) 100%, 0% 100%, var(--deck-tip) 50%); }
149
+ .deck-chevron[data-first] { padding-inline-start: 0.9em; clip-path: polygon(0% 0%, calc(100% - var(--deck-tip)) 0%, 100% 50%, calc(100% - var(--deck-tip)) 100%, 0% 100%); }
150
+ .deck-chevron[data-last] { margin-inline-end: 0; }
151
+ .deck[dir="rtl"] .deck-chevron { clip-path: polygon(100% 0%, var(--deck-tip) 0%, 0% 50%, var(--deck-tip) 100%, 100% 100%, calc(100% - var(--deck-tip)) 50%); }
152
+ .deck[dir="rtl"] .deck-chevron[data-first] { clip-path: polygon(100% 0%, var(--deck-tip) 0%, 0% 50%, var(--deck-tip) 100%, 100% 100%); }
153
+ .deck-under { align-self: start; padding-inline: 0.6em; font-size: 0.9em; text-align: center; }
154
+ /* A programme, and the deck's contents: rows of a time (or a number) and its words, in two columns from seven. */
155
+ .deck-agenda { gap: 0 3em; }
156
+ .deck-agenda-col { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 1.2em; align-content: start; }
157
+ .deck-row { display: grid; grid-column: 1 / -1; grid-template-columns: subgrid; align-items: baseline; padding-block: 0.55em; border-block-end: 1px solid var(--deck-line); }
158
+ .deck-row > :not(.deck-lead) { grid-column: 2; }
159
+ .deck-row .deck-lead { color: var(--deck-primary); white-space: nowrap; font-variant-numeric: tabular-nums; }
160
+ .deck-contents { font-size: 1.1em; }
161
+ .deck-contents .deck-row { cursor: pointer; }
162
+ .deck-contents .deck-row:focus-visible { outline: 0.12em solid var(--deck-primary); outline-offset: 0.1em; }
163
+ .deck-contents .deck-row .deck-text { font-family: var(--deck-heading); font-weight: 600; }
164
+ .deck-contents .deck-row.is-sub { padding-block: 0.3em; border-block-end-style: dotted; font-size: 0.85em; }
165
+ .deck-contents .deck-row.is-sub .deck-text { font-family: var(--deck-body); font-weight: 400; }
166
+ /* A pyramid: its levels stacked, the first the narrowest, on top; what each says beside it. */
167
+ .deck-pyramid { gap: 0.3em 2em; justify-content: center; }
168
+ .deck-level { display: flex; align-items: center; justify-content: center; min-block-size: 2.6em; padding-block: 0.45em; background: var(--deck-fill); color: var(--deck-on-fill); text-align: center; }
169
+ .deck-level .deck-words { font-family: var(--deck-heading); font-weight: 700; }
170
+ .deck-beside { align-self: center; font-size: 0.9em; }
171
+ /* A bento: the first tile big, the others around it. */
172
+ .deck-bento { align-content: stretch; gap: 0.9em; grid-template-columns: 1.4fr repeat(2, minmax(0, 1fr)); grid-auto-rows: 1fr; }
173
+ .deck-tile { display: flex; flex-direction: column; justify-content: safe flex-end; gap: 0.35em; padding: 1em 1.1em; background: var(--deck-soft); border-radius: var(--deck-radius); }
174
+ .deck-tile.is-big { grid-row: span 2; background: var(--deck-fill); color: var(--deck-on-fill); }
175
+ .deck-tile .deck-lead { font-size: 1.15em; }
176
+ .deck-tile.is-big .deck-lead { font-size: 1.9em; }
177
+ .deck-tile .deck-block { min-block-size: 6cqi; }
178
+ .deck-bento[data-count="1"] { grid-template-columns: 1fr; }
179
+ .deck-bento[data-count="2"] { grid-template-columns: 1.3fr 1fr; }
180
+ .deck-bento[data-count="2"] .deck-tile.is-big { grid-row: auto; }
181
+ .deck-bento[data-count="3"] { grid-template-columns: 1.3fr 1fr; }
182
+ .deck-bento[data-count="4"] .deck-tile:nth-child(2) { grid-column: span 2; }
183
+ /* Parts face to face, a medallion between them, the last on the fill; or four in a matrix, the second, top at the end, on the fill. */
184
+ .deck-versus, .deck-matrix { align-content: stretch; }
185
+ .deck-versus { column-gap: 0; grid-auto-rows: 1fr; }
186
+ .deck-matrix { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: 1fr; gap: 0.8em; }
187
+ .deck-part { display: flex; flex-direction: column; padding: 1.1em 1.4em; background: var(--deck-soft); border-radius: var(--deck-radius); }
188
+ .deck-part.is-highlight { background: var(--deck-fill); color: var(--deck-on-fill); }
189
+ .deck-slide .deck-part :is(h1, h2, h3, h4, .deck-h) { font-size: 1.35em; color: inherit; margin-block-end: 0.45em; }
190
+ .deck-vs { position: relative; z-index: 1; align-self: center; display: grid; place-items: center; text-align: center; inline-size: 3.2em; block-size: 3.2em; margin-inline: -1em; border-radius: 50%; background: var(--deck-ink); color: var(--deck-paper); border: 0.25em solid var(--deck-paper); font-family: var(--deck-heading); font-weight: 800; font-size: 0.9em; letter-spacing: 0.02em; }
85
191
  /* Scripts without italics keep their upright letters. */
86
192
  .deck :is(blockquote, em):is(:lang(ar), :lang(fa), :lang(ur), :lang(he), :lang(ja), :lang(zh), :lang(ko), :lang(hi), :lang(th)) { font-style: normal; }
87
193
  .deck-layout-quote blockquote::before { content: "\201C"; display: block; font-size: 2.4em; line-height: 0.6; color: var(--deck-primary); }
@@ -93,6 +199,11 @@
93
199
  :is(.deck-layout-image-left, .deck-layout-image-right).has-image .deck-slot { padding: 6cqi 5cqi; justify-content: center; }
94
200
  :is(.deck-layout-image, .deck-layout-cover).has-image .deck-image { position: absolute; inset: 0; }
95
201
  :is(.deck-layout-image, .deck-layout-cover).has-image .deck-slot { position: relative; color: #fff; text-shadow: 0 0.05em 0.4em rgb(0 0 0 / 0.6); }
202
+ /* A quote over its picture, the whole slide, behind a veil. */
203
+ .deck-layout-quote.has-image .deck-image { position: absolute; inset: 0; }
204
+ .deck-layout-quote.has-image .deck-image::after { content: ""; position: absolute; inset: 0; background-color: rgb(0 0 0 / 0.55); }
205
+ .deck-layout-quote.has-image .deck-slot { position: relative; color: #fff; text-shadow: 0 0.05em 0.4em rgb(0 0 0 / 0.5); }
206
+ .deck-layout-quote.has-image blockquote::before { color: inherit; }
96
207
  .deck-layout-cover.has-image .deck-image::after { content: ""; position: absolute; inset: 0; background: linear-gradient(to top, rgb(0 0 0 / 0.65), rgb(0 0 0 / 0.1)); }
97
208
  .deck-class-invert { background: var(--deck-ink); color: var(--deck-paper); }
98
209
  .deck-class-text-center .deck-slot { text-align: center; align-items: center; }
package/src/deck.js CHANGED
@@ -124,6 +124,7 @@ export function createDeck(root, options) {
124
124
 
125
125
  function draw() {
126
126
  context.total = deck.slides.length
127
+ context.deck = deck
127
128
  drawn = deck.slides.map((slide) => renderSlide(slide, context))
128
129
  for (const [i, { element }] of drawn.entries()) {
129
130
  element.setAttribute('aria-label', words.where.replace('{n}', number(i + 1)).replace('{count}', number(drawn.length)))
@@ -210,7 +211,8 @@ export function createDeck(root, options) {
210
211
  */
211
212
  function name(slide, toward) {
212
213
  const key = (/** @type {Element} */ el) => `${el.tagName}:${el.tagName === 'IMG' ? el.getAttribute('src') : (el.textContent ?? '').trim().slice(0, 80)}`
213
- const parts = (/** @type {HTMLElement} */ s) => /** @type {HTMLElement[]} */ ([...s.querySelectorAll('h1, h2, h3, h4, .deck-h, li, p, img, .deck-block, blockquote, .deck-image')])
214
+ // A mind map's words, not its items, which hold no box of their own.
215
+ const parts = (/** @type {HTMLElement} */ s) => /** @type {HTMLElement[]} */ ([...s.querySelectorAll('h1, h2, h3, h4, .deck-h, li, p, img, .deck-block, blockquote, .deck-image, .deck-map-node')].filter((el) => !el.matches('.deck-mindmap li')))
214
216
  if (toward) {
215
217
  const there = new Map(parts(toward).map((el) => [key(el), el]))
216
218
  let n = 0
@@ -296,6 +298,8 @@ export function createDeck(root, options) {
296
298
  target.addEventListener('blur', blur)
297
299
  })
298
300
 
301
+ // The deck's contents: a part chosen, its slide shown.
302
+ const chosen = (/** @type {Event} */ event) => /** @type {HTMLElement | null} */ (/** @type {Element} */ (event.target).closest('.deck-slide.is-shown [data-go]'))
299
303
  // The keys a clicker sends, and the deck's own.
300
304
  root.addEventListener('keydown', (event) => {
301
305
  const target = /** @type {HTMLElement} */ (event.target)
@@ -303,6 +307,8 @@ export function createDeck(root, options) {
303
307
  if ((event.metaKey || event.ctrlKey) && event.key === 's') { event.preventDefault(); options.onSave?.(source) }
304
308
  return
305
309
  }
310
+ const part = chosen(event)
311
+ if (part && event.key === 'Enter') { event.preventDefault(); go(Number(part.dataset.go)); return }
306
312
  const forward = dir === 'rtl' ? 'ArrowLeft' : 'ArrowRight', backward = dir === 'rtl' ? 'ArrowRight' : 'ArrowLeft'
307
313
  if (event.altKey || event.ctrlKey || event.metaKey) {
308
314
  if ((event.metaKey || event.ctrlKey) && event.key === 's') { event.preventDefault(); options.onSave?.(source) }
@@ -317,6 +323,10 @@ export function createDeck(root, options) {
317
323
  event.preventDefault()
318
324
  act()
319
325
  })
326
+ stage.addEventListener('click', (event) => {
327
+ const part = chosen(event)
328
+ if (part && !root.classList.contains('is-editing') && !root.classList.contains('is-overview')) { event.stopPropagation(); go(Number(part.dataset.go)) }
329
+ }, true)
320
330
  stage.addEventListener('click', (event) => {
321
331
  if (document.fullscreenElement && !(/** @type {Element} */ (event.target).closest('a, button, input'))) move(1)
322
332
  })
package/src/figures.js ADDED
@@ -0,0 +1,255 @@
1
+ // The figures a layout draws from what a slide holds, as slides usually show it: a slot's lists as
2
+ // a row of numbered cards (steps), of key figures (metrics), a timeline, chevrons (process), a timed
3
+ // programme (agenda), a pyramid or a bento; its `##` parts face to face (versus) or four in a
4
+ // matrix; the deck's contents (summary). An item's lead, the words in bold it starts with (`**Q1**`,
5
+ // `**56%**`, `**6:00 PM**`), is its label and the rest its words, each changed in place as a list's
6
+ // are. What is drawn as a shape says so (.deck-shape), and what is words (.deck-words), for
7
+ // PowerPoint to write them as its own shapes and boxes of words.
8
+ import { h, inline, step, range, block } from './render.js'
9
+ import { layoutNameOf } from './kit.js'
10
+
11
+ /** @typedef {import('./render.js').Context} Context */
12
+ /** @typedef {{ tokens: any[], start: number, end: number, blocks: { token: any, start: number }[], step: number }} Entry */
13
+ /** @typedef {{ lead: any[] | null, leadAt: [number, number] | null, rest: any[], restAt: [number, number] | null }} Split */
14
+
15
+ // What may come between a lead and its words: a colon, a dash, a bar, a dot.
16
+ const SEP = /^\s*(?:[::—–|·-]\s*)?/
17
+ // A lead not in bold: a time starting a programme's line, a number starting a key figure's.
18
+ const TIME = /^\d{1,2}(?:[:h.]\d{2}|h)?(?:\s?[ap]\.?m\.?)?(?:\s?[–-]\s?\d{1,2}(?:[:h.]\d{2}|h)?(?:\s?[ap]\.?m\.?)?)?(?=\s|$)/i
19
+ const NUMBER = /^[+−-]?[^\p{L}\p{N}\s]?\d[\d.,  ]*\S*(?=\s|$)/u
20
+ const PATTERNS = /** @type {Record<string, RegExp>} */ ({ agenda: TIME, metrics: NUMBER })
21
+
22
+ /** A paragraph whose every line starts in bold, as key figures are written without a list. @param {any} token */
23
+ export const boldLines = (token) => token.type === 'paragraph' && token.raw.trim().split('\n').every((/** @type {string} */ line) => /^\s*(\*\*|__)\S/.test(line))
24
+
25
+ /**
26
+ * A list's items, or a paragraph's lines that start in bold, as a figure's entries: each one's words (its first line's
27
+ * inline tokens) and where they are in the source, `end` -1 when they run over lines; what is under an item (a list, a
28
+ * chart) its blocks. @param {any} token @param {number} start where its raw starts @param {(markdown: string) => any[]} lex
29
+ * @returns {Entry[]}
30
+ */
31
+ export function entriesOf(token, start, lex) {
32
+ if (token.type === 'paragraph') {
33
+ let at = start
34
+ return token.raw.split('\n').flatMap((/** @type {string} */ line) => {
35
+ const from = at
36
+ at += line.length + 1
37
+ const text = line.trim()
38
+ if (!text) return []
39
+ const [first] = lex(text)
40
+ return [{ tokens: first?.tokens ?? [], start: from + line.length - line.trimStart().length, end: from + line.trimEnd().length, blocks: [], step: 0 }]
41
+ })
42
+ }
43
+ let from = 0
44
+ return token.items.map((/** @type {any} */ item) => {
45
+ const offset = token.raw.indexOf(item.raw, from)
46
+ from = offset + item.raw.length
47
+ const itemStart = start + Math.max(0, offset)
48
+ const marker = /^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?/.exec(item.raw)
49
+ const [first, ...rest] = item.tokens
50
+ const words = first && (first.type === 'text' || first.type === 'paragraph') ? first : null
51
+ const at = itemStart + (marker?.[0].length ?? 0)
52
+ return {
53
+ tokens: words ? words.tokens ?? [{ type: 'text', raw: words.text, text: words.text }] : [],
54
+ start: at, end: words && marker && !words.raw.trim().includes('\n') ? at + words.raw.trim().length : -1,
55
+ blocks: (words ? rest : item.tokens).map((/** @type {any} */ inner) => ({ token: inner, start: itemStart + Math.max(0, item.raw.indexOf(inner.raw)) })), step: 0,
56
+ }
57
+ })
58
+ }
59
+
60
+ /**
61
+ * An entry's lead and the rest of its words, each with where it is in the source: the bold it starts with, else for a
62
+ * programme a time and for key figures a number; what comes between them (a colon, a dash) left out.
63
+ * @param {Entry} entry @param {string} kind @returns {Split}
64
+ */
65
+ export function leadOf(entry, kind) {
66
+ const [first, ...others] = entry.tokens
67
+ const editable = entry.end >= 0
68
+ const after = (/** @type {any[]} */ tokens, /** @type {number} */ from) => {
69
+ const next = tokens[0], cut = next?.type === 'text' ? /** @type {RegExpExecArray} */ (SEP.exec(next.raw))[0].length : 0
70
+ const rest = (cut ? [{ ...next, raw: next.raw.slice(cut), text: next.text.slice(/** @type {RegExpExecArray} */ (SEP.exec(next.text))[0].length) }, ...tokens.slice(1)] : tokens).filter((t) => t.raw.trim() || t.type !== 'text')
71
+ return { rest, restAt: /** @type {[number, number] | null} */ (editable && rest.length && from + cut < entry.end ? [from + cut, entry.end] : null) }
72
+ }
73
+ if (first?.type === 'strong') return { lead: first.tokens, leadAt: editable ? [entry.start + 2, entry.start + first.raw.length - 2] : null, ...after(others, entry.start + first.raw.length) }
74
+ const found = first?.type === 'text' && PATTERNS[kind]?.exec(first.raw)
75
+ if (found) {
76
+ const tail = first.raw.slice(found[0].length)
77
+ return { lead: [{ type: 'text', raw: found[0], text: found[0] }], leadAt: editable ? [entry.start, entry.start + found[0].length] : null, ...after([...(tail ? [{ type: 'text', raw: tail, text: tail }] : []), ...others], entry.start + found[0].length) }
78
+ }
79
+ return { lead: null, leadAt: null, rest: entry.tokens, restAt: editable ? [entry.start, entry.end] : null }
80
+ }
81
+
82
+ /**
83
+ * Where a slot's parts start: at each heading of the level its last heading has, but the heading the slot opens with when
84
+ * there are more of them than the parts asked for (the slide's title, written at their level).
85
+ * @param {any[]} tokens @param {number} count how many parts the layout has @returns {Set<number>}
86
+ */
87
+ export function partHeads(tokens, count) {
88
+ const heads = tokens.flatMap((token, i) => (token.type === 'heading' ? [{ depth: token.depth, i }] : []))
89
+ if (!heads.length) return new Set()
90
+ const depth = /** @type {{ depth: number }} */ (heads.at(-1)).depth
91
+ const at = heads.filter((one) => one.depth === depth).map((one) => one.i)
92
+ return new Set(at.length > count && at[0] === tokens.findIndex((token) => token.type !== 'space') ? at.slice(1) : at)
93
+ }
94
+
95
+ /**
96
+ * The deck's contents, as Slidev's <Toc> reads them: each slide's title and level, but a slide with `hideInToc: true`,
97
+ * the contents themselves, a cover, an introduction, the end and slides with no title. Where the deck has parts (slides
98
+ * on `section` or `subsection`, or with a `level`), the parts alone, a section at level 1 and a subsection at 2; else
99
+ * every slide, at its heading's level. `hideInToc: false` brings a slide back. Levels start at 1, `maxDepth` at most.
100
+ * @param {import('./parse.js').Slide[]} slides @param {import('./kit.js').Kit} kit @param {number} at the contents' own slide
101
+ * @param {number} [maxDepth] @returns {{ index: number, title: string, level: number }[]}
102
+ */
103
+ export function contentsOf(slides, kit, at, maxDepth = 2) {
104
+ const all = slides.map((slide) => {
105
+ const name = layoutNameOf(slide, kit), given = slide.settings.level, hidden = slide.settings.hideInToc
106
+ const depth = /^(#{1,6})\s/m.exec(slide.slots.map((slot) => slot.text).join('\n').replace(/^(`{3,}|~{3,})[\s\S]*?^\1/gm, ''))?.[1].length ?? 1
107
+ const part = Number.isInteger(given) || name === 'section' || name === 'subsection'
108
+ const level = Number.isInteger(given) ? given : name === 'section' ? 1 : name === 'subsection' ? 2 : depth
109
+ const kept = Boolean(slide.title) && (hidden === false || (hidden !== true && slide.index !== at && !kit.layouts[name]?.contents && !['cover', 'intro', 'end'].includes(name)))
110
+ return { index: slide.index, title: slide.title, level, part, kept, shown: hidden === false }
111
+ }).filter((one) => one.kept)
112
+ const chosen = all.some((one) => one.part) ? all.filter((one) => one.part || one.shown) : all
113
+ const top = Math.min(...chosen.map((one) => one.level))
114
+ return chosen.map(({ index, title, level }) => ({ index, title, level: level - top + 1 })).filter((one) => one.level <= Math.max(1, maxDepth))
115
+ }
116
+
117
+ /** Columns for a row of n, wrapping beyond `most`. @param {HTMLElement} figure @param {number} n @param {number} most */
118
+ const columns = (figure, n, most) => figure.style.setProperty('grid-template-columns', `repeat(${n <= most ? n : Math.ceil(n / 2)}, minmax(0, 1fr))`)
119
+
120
+ /**
121
+ * A slot's lists as the figure its layout draws. Entries a click each (<v-clicks>) show their card, milestone, chevron,
122
+ * row, level or tile at their step. @param {string} kind @param {Entry[]} entries @param {Context} context @returns {HTMLElement}
123
+ */
124
+ export function listFigure(kind, entries, context) {
125
+ const items = entries.map((entry) => ({ entry, ...leadOf(entry, kind) }))
126
+ const n = items.length
127
+ const figure = h('div', { class: `deck-figure deck-${kind}`, 'data-count': n })
128
+ figure.style.setProperty('--deck-count', String(n))
129
+ /** @typedef {(typeof items)[number]} Item */
130
+ const at = (/** @type {[number, number] | null} */ where) => (where ? range(...where) : undefined)
131
+ const lead = (/** @type {Item} */ item) => (item.lead ? h('span', { class: 'deck-words deck-lead', dir: 'auto', 'data-edit': at(item.leadAt) }, inline(item.lead, context)) : null)
132
+ const text = (/** @type {Item} */ item) => (item.rest.length ? h('span', { class: 'deck-words deck-text', dir: 'auto', 'data-edit': at(item.restAt) }, inline(item.rest, context)) : null)
133
+ // What is under an item: a list as words, a chart or a diagram as itself.
134
+ const more = (/** @type {Item} */ item) => item.entry.blocks.map(({ token, start }) => {
135
+ const element = block(token, start, context, null)
136
+ if (element && !element.matches('.deck-block, .deck-table, pre')) element.classList.add('deck-words')
137
+ return element
138
+ })
139
+ const stepped = (/** @type {Item} */ item, /** @type {(Element | null)[]} */ elements) => { if (item.entry.step) for (const one of elements) if (one) step(one, item.entry.step) }
140
+ const cell = (/** @type {HTMLElement} */ element, /** @type {number} */ column, /** @type {number} */ row) => { element.style.setProperty('grid-column', String(column)); element.style.setProperty('grid-row', String(row)); return element }
141
+ // The lead, else the words, as what a chevron or a level says; the words beside or under it when there is a lead.
142
+ const label = (/** @type {Item} */ item) => lead(item) ?? text(item) ?? h('span', { class: 'deck-words deck-lead' })
143
+ const under = (/** @type {Item} */ item, /** @type {string} */ name) => h('div', { class: name }, [item.lead ? text(item) : null, ...more(item)])
144
+ switch (kind) {
145
+ case 'steps':
146
+ columns(figure, n, 5)
147
+ for (const [i, item] of items.entries()) {
148
+ const card = h('div', { class: 'deck-card deck-shape' }, [h('span', { class: 'deck-words deck-badge deck-shape' }, [context.number(i + 1)]), lead(item), text(item), ...more(item)])
149
+ stepped(item, [card])
150
+ figure.append(card)
151
+ }
152
+ break
153
+ case 'metrics':
154
+ columns(figure, n, 4)
155
+ for (const item of items) {
156
+ const metric = h('div', { class: 'deck-metric deck-shape' }, [lead(item), text(item), ...more(item)])
157
+ stepped(item, [metric])
158
+ figure.append(metric)
159
+ }
160
+ break
161
+ case 'timeline':
162
+ columns(figure, n, 99)
163
+ // The line first, under its dots, from the first to the last.
164
+ figure.append(h('span', { class: 'deck-timeline-line deck-shape' }))
165
+ for (const [i, item] of items.entries()) {
166
+ const parts = [cell(lead(item) ?? h('span', { class: 'deck-lead' }), i + 1, 1), cell(h('span', { class: 'deck-dot deck-shape' }), i + 1, 2), cell(h('div', { class: 'deck-milestone' }, [text(item), ...more(item)]), i + 1, 3)]
167
+ stepped(item, parts)
168
+ figure.append(...parts)
169
+ }
170
+ break
171
+ case 'process':
172
+ columns(figure, n, 99)
173
+ for (const [i, item] of items.entries()) {
174
+ const parts = [cell(h('div', { class: 'deck-chevron deck-shape', 'data-first': i === 0 || undefined, 'data-last': i === n - 1 || undefined }, [label(item)]), i + 1, 1), cell(under(item, 'deck-under'), i + 1, 2)]
175
+ stepped(item, parts)
176
+ figure.append(...parts)
177
+ }
178
+ break
179
+ case 'agenda': {
180
+ // In two columns from seven lines, the first holding the more.
181
+ const halves = n >= 7 ? [items.slice(0, Math.ceil(n / 2)), items.slice(Math.ceil(n / 2))] : [items]
182
+ figure.style.setProperty('grid-template-columns', `repeat(${halves.length}, minmax(0, 1fr))`)
183
+ for (const half of halves) {
184
+ figure.append(h('div', { class: 'deck-agenda-col' }, half.map((item) => {
185
+ const row = h('div', { class: 'deck-row deck-shape' }, [lead(item) ?? h('span', { class: 'deck-lead' }), text(item), ...more(item)])
186
+ stepped(item, [row])
187
+ return row
188
+ })))
189
+ }
190
+ break
191
+ }
192
+ case 'pyramid': {
193
+ // Narrowest at the top, widest at the bottom, each level a band of one triangle; its words held to its top edge.
194
+ const beside = items.some((item) => item.lead && (item.rest.length || item.entry.blocks.length))
195
+ figure.style.setProperty('grid-template-columns', beside ? 'minmax(0, 1.15fr) minmax(0, 1fr)' : 'minmax(0, 30em)')
196
+ const half = (/** @type {number} */ k) => Math.round(5000 * (0.16 + (0.84 * k) / n)) / 100
197
+ for (const [i, item] of items.entries()) {
198
+ const band = cell(h('div', { class: 'deck-level deck-shape' }, [label(item)]), 1, i + 1)
199
+ band.style.setProperty('clip-path', `polygon(${50 - half(i)}% 0%, ${50 + half(i)}% 0%, ${50 + half(i + 1)}% 100%, ${50 - half(i + 1)}% 100%)`)
200
+ band.style.setProperty('padding-inline', `max(0.4em, ${Math.round((50 - half(i)) * 100) / 100}%)`)
201
+ const parts = [band, beside ? cell(under(item, 'deck-beside'), 2, i + 1) : null]
202
+ stepped(item, parts)
203
+ figure.append(...parts.filter((one) => one !== null))
204
+ }
205
+ break
206
+ }
207
+ case 'bento':
208
+ for (const [i, item] of items.entries()) {
209
+ const tile = h('div', { class: `deck-tile deck-shape${i === 0 ? ' is-big' : ''}` }, [lead(item), text(item), ...more(item)])
210
+ stepped(item, [tile])
211
+ figure.append(tile)
212
+ }
213
+ break
214
+ }
215
+ return figure
216
+ }
217
+
218
+ /**
219
+ * A slot's parts as its layout sets them: face to face, the last on the fill, a medallion between each two (versus), or
220
+ * in a grid of two columns, the second, top at the end, on the fill (matrix). Their words stay as they were drawn.
221
+ * @param {string} kind @param {Node[][]} parts @returns {HTMLElement}
222
+ */
223
+ export function partsFigure(kind, parts) {
224
+ const figure = h('div', { class: `deck-figure deck-${kind}`, 'data-count': parts.length })
225
+ if (kind === 'versus') figure.style.setProperty('grid-template-columns', parts.map(() => 'minmax(0, 1fr)').join(' auto '))
226
+ for (const [i, children] of parts.entries()) {
227
+ for (const child of children) if (child instanceof HTMLElement && !child.matches('.deck-block, .deck-table, pre, .deck-picture')) child.classList.add('deck-words')
228
+ if (kind === 'versus' && i > 0) figure.append(h('span', { class: 'deck-vs deck-shape deck-words', 'aria-hidden': 'true' }, ['VS']))
229
+ const highlight = parts.length > 1 && (kind === 'versus' ? i === parts.length - 1 : i === 1)
230
+ figure.append(h('div', { class: `deck-part deck-shape${highlight ? ' is-highlight' : ''}` }, children))
231
+ }
232
+ return figure
233
+ }
234
+
235
+ /**
236
+ * The deck's contents as rows, a part's number and its title, each going to its slide when chosen; a subsection under
237
+ * its section, without a number; in two columns from seven rows. @param {Context} context @param {number} at its own slide
238
+ * @param {number} [maxDepth] @returns {HTMLElement | null}
239
+ */
240
+ export function contentsFigure(context, at, maxDepth) {
241
+ const entries = context.deck ? contentsOf(context.deck.slides, context.kit, at, maxDepth) : []
242
+ if (!entries.length) return null
243
+ let n = 0
244
+ const rows = entries.map(({ index, title, level }) => {
245
+ const [heading] = context.lex(`# ${title}`)
246
+ return h('div', { class: `deck-row deck-shape${level > 1 ? ' is-sub' : ''}`, 'data-go': index, role: 'link', tabindex: 0 }, [
247
+ h('span', { class: 'deck-words deck-lead' }, level > 1 ? [] : [context.number(++n)]),
248
+ h('span', { class: 'deck-words deck-text', dir: 'auto' }, inline(heading?.tokens ?? [{ type: 'text', text: title }], context)),
249
+ ])
250
+ })
251
+ const halves = rows.length >= 7 ? [rows.slice(0, Math.ceil(rows.length / 2)), rows.slice(Math.ceil(rows.length / 2))] : [rows]
252
+ const figure = h('div', { class: 'deck-figure deck-agenda deck-contents', 'data-count': rows.length }, halves.map((half) => h('div', { class: 'deck-agenda-col' }, half)))
253
+ figure.style.setProperty('grid-template-columns', `repeat(${halves.length}, minmax(0, 1fr))`)
254
+ return figure
255
+ }
package/src/kit.js CHANGED
@@ -3,19 +3,24 @@
3
3
  // deck's own kit holds the usual layouts; a host's (an organisation's, a project's) and the deck
4
4
  // file's own `kit:` go over it, in that order. A layout is a grid: its slots by name, the areas
5
5
  // they take, its tracks, how its words sit, what paper it is on, how big its logo shows, and the
6
- // PowerPoint layout it is nearest to, and the art a template draws behind it, layers among the kit's
7
- // pictures; a kit may turn one of the usual layouts off. Its tokens may hold a logo, a picture inside
6
+ // PowerPoint layout it is nearest to, whether its lists are drawn as a mind map or a figure (cards, numbers, a timeline,
7
+ // chevrons, a programme, a pyramid, a bento), whether its `##` parts face each other or make a matrix, whether it shows
8
+ // the deck's contents, and the art a template draws behind it, layers among the kit's pictures; a kit may turn one of
9
+ // the usual layouts off. Its tokens may hold a logo, a picture inside
8
10
  // the kit and never an address, as its pictures are. Everything is checked against a small grammar;
9
11
  // what fails is dropped, with a reason. Pure.
10
12
 
11
13
  /** @typedef {{ required?: boolean, max_words?: number, max_chars?: number, items?: [number, number], item_max_words?: number, heading_max_words?: number, kind?: string }} Budget */
12
- /** @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', logo: 'big' | 'small' | 'none', pptx: string, numbered: boolean, art?: string[] }} Layout */
14
+ /** @typedef {'bullets' | 'mindmap' | 'steps' | 'metrics' | 'timeline' | 'process' | 'agenda' | 'pyramid' | 'bento'} List */
15
+ /** @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', logo: 'big' | 'small' | 'none', lists: List, parts: 'none' | 'versus' | 'matrix', contents: boolean, pptx: string, numbered: boolean, art?: string[] }} Layout */
13
16
  /** @typedef {{ name?: string, tokens: Record<string, any>, layouts: Record<string, Layout>, pictures: Record<string, string> }} Kit */
14
17
 
15
18
  const NAME = /^[a-z][a-z0-9-]{0,31}$/
16
19
  const TRACK = /^(auto|min-content|max-content|\d{1,2}(\.\d)?fr|\d{1,3}%)$/
17
20
  const PPTX = ['Title Slide', 'Title and Content', 'Section Header', 'Two Content', 'Comparison', 'Title Only', 'Blank', 'Content with Caption', 'Picture with Caption']
18
- const ENUMS = /** @type {const} */ ({ align: ['start', 'center', 'end'], justify: ['start', 'center', 'end'], paper: ['paper', 'fill', 'ink', 'soft'], title: ['normal', 'large', 'huge'], logo: ['small', 'big', 'none'] })
21
+ const ENUMS = /** @type {const} */ ({
22
+ align: ['start', 'center', 'end'], justify: ['start', 'center', 'end'], paper: ['paper', 'fill', 'ink', 'soft'], title: ['normal', 'large', 'huge'], logo: ['small', 'big', 'none'],
23
+ lists: ['bullets', 'mindmap', 'steps', 'metrics', 'timeline', 'process', 'agenda', 'pyramid', 'bento'], parts: ['none', 'versus', 'matrix'] })
19
24
  /** The corners a logo sits in, as `logoAt` names them, the first by default. */
20
25
  export const CORNERS = ['top-end', 'top-start', 'bottom-end', 'bottom-start']
21
26
 
@@ -34,7 +39,10 @@ export const artOf = (value) => (typeof value === 'string' && value.length <= 3_
34
39
  /** The names brains give the usual layouts, for the ones deck has. */
35
40
  export const ALIASES = /** @type {Record<string, string>} */ ({
36
41
  title: 'cover', 'title-slide': 'cover', 'two-columns': 'two-cols', 'two-column': 'two-cols', columns: 'two-cols', comparison: 'two-cols-header',
37
- 'three-columns': 'three-cols', 'section-header': 'section', divider: 'section', big: 'fact', number: 'fact', 'image-text': 'image-right', closing: 'end', thanks: 'end',
42
+ 'three-columns': 'three-cols', 'section-header': 'section', divider: 'section', big: 'fact', number: 'fact', 'image-text': 'image-right', closing: 'end', thanks: 'end', 'mind-map': 'mindmap', map: 'mindmap',
43
+ toc: 'summary', contents: 'summary', 'table-of-contents': 'summary', outline: 'summary', 'sub-section': 'subsection', 'subsection-header': 'subsection',
44
+ cards: 'steps', kpi: 'metrics', kpis: 'metrics', stats: 'metrics', numbers: 'metrics', roadmap: 'timeline', milestones: 'timeline', chevrons: 'process',
45
+ schedule: 'agenda', programme: 'agenda', program: 'agenda', hierarchy: 'pyramid', mosaic: 'bento', vs: 'versus', 'face-off': 'versus', quadrant: 'matrix', quadrants: 'matrix', '2x2': 'matrix',
38
46
  })
39
47
 
40
48
  /** deck's own kit: the usual layouts, the ones Slidev names and the ones PowerPoint has, as data. */
@@ -47,6 +55,8 @@ export const STANDARD = /** @type {Kit} */ ({
47
55
  cover: { label: { en: 'Cover', fr: 'Couverture' }, justify: 'center', paper: 'fill', title: 'huge', logo: 'big', pptx: 'Title Slide', numbered: false },
48
56
  intro: { label: { en: 'Introduction', fr: 'Introduction' }, justify: 'center', paper: 'fill', title: 'large', logo: 'big', pptx: 'Title Slide', numbered: false },
49
57
  section: { label: { en: 'Section', fr: 'Section' }, justify: 'center', paper: 'ink', title: 'huge', pptx: 'Section Header', numbered: false },
58
+ subsection: { label: { en: 'Subsection', fr: 'Sous-section' }, justify: 'center', paper: 'soft', title: 'large', pptx: 'Section Header', numbered: false },
59
+ summary: { label: { en: 'Contents', fr: 'Sommaire' }, contents: true, pptx: 'Title and Content' },
50
60
  center: { label: { en: 'Centred', fr: 'Centré' }, justify: 'center', align: 'center', pptx: 'Title Only' },
51
61
  statement: { label: { en: 'Statement', fr: 'Affirmation' }, justify: 'center', align: 'center', title: 'large', pptx: 'Title Only' },
52
62
  fact: { label: { en: 'Big number', fr: 'Grand chiffre' }, justify: 'center', align: 'center', title: 'huge', pptx: 'Title Only' },
@@ -60,6 +70,16 @@ export const STANDARD = /** @type {Kit} */ ({
60
70
  'image-left': { label: { en: 'Picture left', fr: 'Image à gauche' }, pptx: 'Picture with Caption', justify: 'center' },
61
71
  'image-right': { label: { en: 'Picture right', fr: 'Image à droite' }, pptx: 'Picture with Caption', justify: 'center' },
62
72
  image: { label: { en: 'Picture', fr: 'Image' }, logo: 'none', pptx: 'Blank', numbered: false },
73
+ mindmap: { label: { en: 'Mind map', fr: 'Carte mentale' }, lists: 'mindmap', pptx: 'Title Only' },
74
+ steps: { label: { en: 'Steps', fr: 'Étapes' }, lists: 'steps', pptx: 'Title Only' },
75
+ metrics: { label: { en: 'Key figures', fr: 'Chiffres clés' }, lists: 'metrics', pptx: 'Title Only' },
76
+ timeline: { label: { en: 'Timeline', fr: 'Frise' }, lists: 'timeline', pptx: 'Title Only' },
77
+ process: { label: { en: 'Process', fr: 'Processus' }, lists: 'process', pptx: 'Title Only' },
78
+ agenda: { label: { en: 'Agenda', fr: 'Programme' }, lists: 'agenda', pptx: 'Title Only' },
79
+ pyramid: { label: { en: 'Pyramid', fr: 'Pyramide' }, lists: 'pyramid', pptx: 'Title Only' },
80
+ bento: { label: { en: 'Bento', fr: 'Mosaïque' }, lists: 'bento', pptx: 'Title Only' },
81
+ versus: { label: { en: 'Versus', fr: 'Face à face' }, parts: 'versus', pptx: 'Title Only' },
82
+ matrix: { label: { en: 'Matrix', fr: 'Matrice' }, parts: 'matrix', pptx: 'Title Only' },
63
83
  })).map(([name, given]) => [name, /** @type {Layout} */ (layoutOf(given).layout)])),
64
84
  })
65
85
 
@@ -112,7 +132,7 @@ export function layoutOf(given) {
112
132
  return {
113
133
  layout: /** @type {Layout} */ ({
114
134
  label, intent: words(given.intent), use_when: words(given.use_when), avoid_when: words(given.avoid_when), slots, budgets, areas: rows, columns, rows: rowTracks,
115
- align: pick('align', 'start'), justify: pick('justify', 'start'), paper: pick('paper', 'paper'), title: pick('title', 'normal'), logo: pick('logo', 'small'),
135
+ align: pick('align', 'start'), justify: pick('justify', 'start'), paper: pick('paper', 'paper'), title: pick('title', 'normal'), logo: pick('logo', 'small'), lists: pick('lists', 'bullets'), parts: pick('parts', 'none'), contents: given.contents === true,
116
136
  pptx: PPTX.includes(given.pptx) ? given.pptx : slots.length > 1 ? 'Comparison' : 'Title and Content', numbered: given.numbered !== false, art,
117
137
  }),
118
138
  problems,
@@ -197,6 +217,15 @@ export function labelOf(layout, name, lang) {
197
217
  return layout.label?.[lang] ?? layout.label?.[lang.split('-')[0]] ?? layout.label?.en ?? name
198
218
  }
199
219
 
220
+ /**
221
+ * The layout a slide is drawn with: the one it names, by its name or a name brains give it, else the one its content
222
+ * suits. @param {{ index: number, settings: Record<string, any>, slots: { name: string, text: string }[] }} slide @param {Kit} kit
223
+ */
224
+ export function layoutNameOf(slide, kit) {
225
+ const asked = String(slide.settings.layout ?? '').trim().toLowerCase()
226
+ return kit.layouts[asked] ? asked : kit.layouts[ALIASES[asked]] ? ALIASES[asked] : inferLayout(slide)
227
+ }
228
+
200
229
  /**
201
230
  * The layout a slide that names none is given, from what it holds, as Pandoc's PowerPoint writer
202
231
  * chooses: the first slide is the cover; a heading alone, a section; slots beyond the first, the