@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/pptx.js ADDED
@@ -0,0 +1,310 @@
1
+ // A deck as a PowerPoint file to change in PowerPoint, Keynote or Google Slides: each layout a
2
+ // slide layout of its own (a master, in PptxGenJS's words) with a placeholder for its title and
3
+ // each of its slots, so a slide added there looks like the deck's; words as PowerPoint's own text,
4
+ // a list's levels as its levels; a table as a table; a chart of bars, lines, areas, pies, rings,
5
+ // points or a radar as PowerPoint's own chart, its numbers in the file to change; anything else (a
6
+ // diagram, another chart, a picture) as a picture; the speaker's notes as notes; each slide's
7
+ // transition as PowerPoint's (Morph where it has one, a fade where it hasn't); the look's colours
8
+ // as the file's theme. Read from the deck as drawn, each box where the browser put it, so the file
9
+ // looks like the slides, in the scheme shown. PptxGenJS (with its JSZip) is the host's to load.
10
+
11
+ import { labelOf } from './kit.js'
12
+
13
+ const W = 13.333, H = 7.5
14
+ const TEXT = 'h1, h2, h3, h4, h5, h6, .deck-h, p, ul, ol, blockquote'
15
+
16
+ /** @param {string} value rgb(…) or #… @returns {string} six hex digits */
17
+ function hexOf(value) {
18
+ const m = /rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/.exec(value)
19
+ if (m) return [m[1], m[2], m[3]].map((n) => Math.round(Number(n)).toString(16).padStart(2, '0')).join('').toUpperCase()
20
+ return value.replace('#', '').toUpperCase().slice(0, 6) || '000000'
21
+ }
22
+ /** @param {string} value */
23
+ const opaque = (value) => !/rgba\([^)]*,\s*0\)|transparent/.test(value)
24
+
25
+ /**
26
+ * @param {import('./deck.js').DeckView} view
27
+ * @param {{ PptxGenJS: any, JSZip: any }} libraries
28
+ * @returns {Promise<{ blob: Blob, report: { slides: number, charts: number, pictures: number, tables: number, placeholders: number } }>}
29
+ */
30
+ export async function toPptx(view, { PptxGenJS, JSZip }) {
31
+ const pres = new PptxGenJS()
32
+ pres.layout = 'LAYOUT_WIDE'
33
+ pres.title = view.deck.slides[0]?.title ?? ''
34
+ pres.rtlMode = view.dir === 'rtl'
35
+ const rootStyle = getComputedStyle(view.root)
36
+ const firstFont = (/** @type {string} */ stack) => stack.split(',').map((f) => f.trim().replace(/^["']|["']$/g, '')).find((f) => !/^(ui-|system-ui|-apple|sans-serif|serif|monospace)/.test(f)) ?? 'Arial'
37
+ pres.theme = { headFontFace: firstFont(rootStyle.getPropertyValue('--deck-heading')), bodyFontFace: firstFont(rootStyle.getPropertyValue('--deck-body')) }
38
+ const report = { slides: 0, charts: 0, pictures: 0, tables: 0, placeholders: 0 }
39
+ const rtl = view.dir === 'rtl'
40
+ const slides = view.slides()
41
+ const masters = new Map()
42
+ const transitions = []
43
+
44
+ for (const [i, element] of slides.entries()) {
45
+ const box = element.getBoundingClientRect()
46
+ const sx = W / box.width
47
+ const place = (/** @type {Element} */ el) => {
48
+ const r = el.getBoundingClientRect()
49
+ return { x: (r.left - box.left) * sx, y: (r.top - box.top) * sx, w: r.width * sx, h: r.height * sx }
50
+ }
51
+ // Words as wide as their slot, never as their line: PowerPoint sets type a little wider than a browser.
52
+ const across = (/** @type {Element} */ el) => {
53
+ const slot = el.closest('.deck-slot') ?? el
54
+ const { x, w } = place(slot), { y, h } = place(el)
55
+ const pad = getComputedStyle(slot)
56
+ const start = parseFloat(pad.paddingInlineStart) * sx, end = parseFloat(pad.paddingInlineEnd) * sx
57
+ return { x: x + (rtl ? end : start), y, w: w - start - end, h: h * 1.08 }
58
+ }
59
+ // Where a layout centres its words, PowerPoint's box centres them too.
60
+ const anchor = (/** @type {Element} */ slot) => (getComputedStyle(slot).justifyContent === 'center' ? 'middle' : 'top')
61
+ const pt = (/** @type {number} */ px) => Math.round(px * sx * 72 * 10) / 10
62
+ const style = getComputedStyle(element)
63
+ const layout = element.dataset.layout ?? 'default'
64
+ const slots = /** @type {HTMLElement[]} */ ([...element.querySelectorAll('.deck-slot')])
65
+ const firstBlock = slots[0]?.firstElementChild
66
+ const title = firstBlock && firstBlock.matches('h1, h2, h3, .deck-h') ? firstBlock : null
67
+
68
+ // One master a layout, its placeholders where the first slide of it has its title and slots.
69
+ // Named as the kit names the layout, in the deck's language, so PowerPoint's Layout menu reads it.
70
+ const definition = view.kit.layouts[layout]
71
+ const masterName = `${definition ? labelOf(definition, layout, view.lang) : layout}${element.classList.contains('deck-class-invert') ? ' (dark)' : ''}`
72
+ if (!masters.has(masterName)) {
73
+ const objects = []
74
+ if (title) objects.push({ placeholder: { options: { name: 'title', type: 'title', ...across(title), valign: 'bottom', margin: 0, fontFace: pres.theme.headFontFace, color: hexOf(getComputedStyle(title).color), align: rtl ? 'right' : 'left' }, text: '' } })
75
+ for (const slot of slots) {
76
+ const r = place(slot)
77
+ const top = title && slot === slots[0] ? place(title).y + place(title).h : r.y
78
+ const inner = slot.firstElementChild && slot.firstElementChild !== title ? across(slot.firstElementChild) : { x: r.x, w: r.w }
79
+ objects.push({ placeholder: { options: { name: `body-${slot.dataset.slot}`, type: 'body', x: inner.x, y: top + 0.08, w: inner.w, h: Math.max(0.3, r.y + r.h - top - 0.08), margin: 0, valign: anchor(slot), color: hexOf(getComputedStyle(slot).color) }, text: '' } })
80
+ }
81
+ pres.defineSlideMaster({
82
+ title: masterName, background: { color: hexOf(style.backgroundColor) }, objects,
83
+ slideNumber: definition?.numbered !== false ? { x: rtl ? 0.3 : W - 0.9, y: H - 0.55, w: 0.6, h: 0.3, fontSize: 10, color: hexOf(rootStyle.getPropertyValue('--deck-muted').trim() || '#666666') } : undefined,
84
+ })
85
+ masters.set(masterName, new Set(objects.map((o) => o.placeholder.options.name)))
86
+ }
87
+ const names = masters.get(masterName)
88
+ const slide = pres.addSlide({ masterName })
89
+ report.slides++
90
+
91
+ const background = element.querySelector('.deck-image')
92
+ if (background) {
93
+ const url = /url\("?(.*?)"?\)/.exec(getComputedStyle(background).backgroundImage)?.[1]
94
+ const data = url && await pictureOf(url, background.clientWidth * 2, background.clientHeight * 2, true)
95
+ if (data) { slide.addImage({ data, ...place(background) }); report.pictures++ }
96
+ }
97
+ if (title && names.has('title')) {
98
+ slide.addText(runs(title, { pt, rtl, lang: view.lang }), { placeholder: 'title' })
99
+ report.placeholders++
100
+ }
101
+ for (const slot of slots) {
102
+ const blocks = [...slot.children].filter((child) => child !== title)
103
+ const textOnly = blocks.length > 0 && blocks.every((child) => child.matches(TEXT))
104
+ if (textOnly && names.has(`body-${slot.dataset.slot}`)) {
105
+ slide.addText(blocks.flatMap((block) => runs(block, { pt, rtl, lang: view.lang })), { placeholder: `body-${slot.dataset.slot}`, valign: anchor(slot), ...(spaced(slot)) })
106
+ report.placeholders++
107
+ continue
108
+ }
109
+ for (const block of blocks) {
110
+ const at = place(block)
111
+ if (block.matches(TEXT)) slide.addText(runs(block, { pt, rtl, lang: view.lang }), { ...across(block), valign: 'top', margin: 0, ...(spaced(slot)) })
112
+ else if (block.matches('pre')) {
113
+ slide.addText(block.textContent?.replace(/\n$/, '') ?? '', { ...at, fontFace: 'Consolas', fontSize: pt(parseFloat(getComputedStyle(block).fontSize)), color: hexOf(getComputedStyle(block).color), fill: { color: hexOf(getComputedStyle(block).backgroundColor) }, valign: 'top', margin: 6, rtlMode: false, align: 'left' })
114
+ } else if (block.matches('.deck-table')) {
115
+ table(slide, block, at, { pt, rtl, lang: view.lang })
116
+ report.tables++
117
+ } else if (block.matches('.deck-echarts') && /** @type {any} */ (block).chart) {
118
+ const chart = /** @type {any} */ (block).chart
119
+ if (nativeChart(pres, slide, chart, at, { pt, rtl, lang: view.lang, ink: hexOf(style.color) })) report.charts++
120
+ else { slide.addImage({ data: await pictureOf(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(chart.renderToSVGString())}`, at.w / sx * 2, at.h / sx * 2), ...at }); report.pictures++ }
121
+ } else {
122
+ const img = block.querySelector('img')
123
+ if (img) {
124
+ const data = await pictureOf(img.src, img.clientWidth * 2, img.clientHeight * 2)
125
+ if (data) { slide.addImage({ data, ...place(img), altText: img.alt }); report.pictures++ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+ const notes = view.deck.slides[i]?.notes
131
+ if (notes) slide.addNotes(notes)
132
+ const settings = view.deck.slides[i]?.settings ?? {}
133
+ transitions.push(String(settings.transition ?? view.deck.settings.transition ?? 'none').split('|')[0].trim())
134
+ }
135
+
136
+ // What PptxGenJS doesn't write: each slide's transition, and the look's colours as the theme.
137
+ const zip = await JSZip.loadAsync(await pres.write({ outputType: 'arraybuffer', compression: true }))
138
+ for (const [i, kind] of transitions.entries()) {
139
+ const path = `ppt/slides/slide${i + 1}.xml`
140
+ const xml = await zip.file(path)?.async('string')
141
+ if (!xml || kind === 'none') continue
142
+ zip.file(path, xml.replace(/<\/p:clrMapOvr>|(?=<p:timing|<\/p:sld>)/, (/** @type {string} */ found) => `${found}${transitionXml(kind, rtl)}`))
143
+ }
144
+ const theme = await zip.file('ppt/theme/theme1.xml')?.async('string')
145
+ if (theme) {
146
+ const colour = (/** @type {string} */ name) => hexOf(rootStyle.getPropertyValue(`--deck-${name}`).trim())
147
+ const series = [colour('fill'), ...[2, 3, 4, 5, 6].map((n) => colour(`series-${n}`))]
148
+ const scheme = `<a:clrScheme name="Deck"><a:dk1><a:srgbClr val="${colour('ink')}"/></a:dk1><a:lt1><a:srgbClr val="${colour('paper')}"/></a:lt1><a:dk2><a:srgbClr val="${colour('ink')}"/></a:dk2><a:lt2><a:srgbClr val="${colour('soft')}"/></a:lt2>${series.map((c, n) => `<a:accent${n + 1}><a:srgbClr val="${c}"/></a:accent${n + 1}>`).join('')}<a:hlink><a:srgbClr val="${colour('primary')}"/></a:hlink><a:folHlink><a:srgbClr val="${colour('primary')}"/></a:folHlink></a:clrScheme>`
149
+ zip.file('ppt/theme/theme1.xml', theme.replace(/<a:clrScheme[\s\S]*?<\/a:clrScheme>/, scheme))
150
+ }
151
+ const blob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE', mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' })
152
+ return { blob, report }
153
+ }
154
+
155
+ /** A slot's paragraphs' spacing, from the space under its first block. @param {HTMLElement} slot */
156
+ function spaced(slot) {
157
+ const first = slot.firstElementChild
158
+ const after = first ? parseFloat(getComputedStyle(first).marginBlockEnd) : 0
159
+ return { paraSpaceAfter: Math.round(after * 0.5) }
160
+ }
161
+
162
+ /**
163
+ * A block's words as PowerPoint's runs: each paragraph or list item ends its line; bold, italics,
164
+ * code and links kept; a list's level as its indent level.
165
+ * @param {Element} block @param {{ pt: (px: number) => number, rtl: boolean, lang: string }} how @returns {any[]}
166
+ */
167
+ function runs(block, how) {
168
+ /** @type {any[]} */
169
+ const out = []
170
+ const paragraph = (/** @type {Element} */ el, /** @type {Record<string, any>} */ extra) => {
171
+ const style = getComputedStyle(el)
172
+ const base = { fontSize: how.pt(parseFloat(style.fontSize)), color: hexOf(style.color), bold: Number(style.fontWeight) >= 600, lang: how.lang, rtlMode: how.rtl, align: style.textAlign === 'center' ? 'center' : how.rtl ? 'right' : 'left', ...extra }
173
+ const pieces = inlineRuns(el, base)
174
+ if (!pieces.length) return
175
+ pieces[pieces.length - 1].options.breakLine = true
176
+ out.push(...pieces)
177
+ }
178
+ const list = (/** @type {Element} */ el, /** @type {number} */ level) => {
179
+ let n = 0
180
+ for (const li of el.children) {
181
+ n++
182
+ const nested = [...li.children].filter((c) => c.matches('ul, ol'))
183
+ const clone = /** @type {Element} */ (li.cloneNode(true))
184
+ for (const c of [...clone.children]) if (c.matches('ul, ol, input')) c.remove()
185
+ li.after(clone)
186
+ paragraph(clone, { bullet: el.tagName === 'OL' ? { type: 'number', startAt: n === 1 ? Number(el.getAttribute('start') ?? 1) : undefined } : true, indentLevel: level })
187
+ clone.remove()
188
+ for (const sub of nested) list(sub, level + 1)
189
+ }
190
+ }
191
+ if (block.matches('ul, ol')) list(block, 0)
192
+ else if (block.matches('blockquote')) for (const p of block.children) paragraph(p, { italic: getComputedStyle(p).fontStyle === 'italic' })
193
+ else paragraph(block, {})
194
+ return out
195
+ }
196
+
197
+ /** @param {Node} node @param {Record<string, any>} base @returns {any[]} */
198
+ function inlineRuns(node, base) {
199
+ /** @type {any[]} */
200
+ const out = []
201
+ const walk = (/** @type {Node} */ n, /** @type {Record<string, any>} */ options) => {
202
+ if (n.nodeType === Node.TEXT_NODE) {
203
+ const text = (n.textContent ?? '').replace(/\s+/g, ' ')
204
+ if (text) out.push({ text, options: { ...options } })
205
+ return
206
+ }
207
+ if (!(n instanceof HTMLElement)) return
208
+ if (n.tagName === 'BR') { if (out.length) out[out.length - 1].options.breakLine = true; return }
209
+ const own = { ...options }
210
+ if (n.matches('strong, b')) { own.bold = true; own.color = hexOf(getComputedStyle(n).color) }
211
+ if (n.matches('em, i')) own.italic = true
212
+ if (n.matches('del, s')) own.strike = 'sngStrike'
213
+ if (n.matches('code')) own.fontFace = 'Consolas'
214
+ if (n.matches('a[href]')) own.hyperlink = { url: n.getAttribute('href') }
215
+ for (const child of n.childNodes) walk(child, own)
216
+ }
217
+ for (const child of node.childNodes) walk(child, base)
218
+ if (out.length) { out[0].text = out[0].text.trimStart(); out[out.length - 1].text = out[out.length - 1].text.trimEnd() }
219
+ return out.filter((run) => run.text)
220
+ }
221
+
222
+ /** @param {any} slide @param {Element} block @param {{ x: number, y: number, w: number, h: number }} at @param {{ pt: (px: number) => number, rtl: boolean, lang: string }} how */
223
+ function table(slide, block, at, how) {
224
+ const rows = [...block.querySelectorAll('tr')].map((tr) => [...tr.children].map((cell) => {
225
+ const style = getComputedStyle(cell)
226
+ const align = cell.getAttribute('data-align')
227
+ return { text: cell.textContent?.trim() ?? '', options: {
228
+ bold: cell.tagName === 'TH', fontSize: how.pt(parseFloat(style.fontSize)), color: hexOf(style.color), lang: how.lang, rtlMode: how.rtl,
229
+ align: align === 'center' ? 'center' : (align === 'right') !== how.rtl ? 'right' : 'left',
230
+ border: [{ type: 'none' }, { type: 'none' }, { type: 'solid', pt: cell.tagName === 'TH' ? 1.5 : 0.75, color: hexOf(style.borderBlockEndColor) }, { type: 'none' }],
231
+ } }
232
+ }))
233
+ const widths = [...(block.querySelector('tr')?.children ?? [])].map((cell) => cell.getBoundingClientRect().width)
234
+ const total = widths.reduce((a, b) => a + b, 0) || 1
235
+ slide.addTable(how.rtl ? rows.map((r) => r.reverse()) : rows, { ...at, colW: (how.rtl ? widths.reverse() : widths).map((w) => (w / total) * at.w), margin: 0.06 })
236
+ }
237
+
238
+ /**
239
+ * An ECharts chart as PowerPoint's own, when it is one PowerPoint draws: bars, lines, areas, a pie
240
+ * or a ring, points, a radar; false when it isn't. @returns {boolean}
241
+ */
242
+ function nativeChart(/** @type {any} */ pres, /** @type {any} */ slide, /** @type {any} */ chart, /** @type {{ x: number, y: number, w: number, h: number }} */ at, /** @type {{ pt: (px: number) => number, rtl: boolean, lang: string, ink: string }} */ how) {
243
+ const option = chart.getOption()
244
+ const series = /** @type {any[]} */ (option.series ?? [])
245
+ if (!series.length) return false
246
+ const types = new Set(series.map((s) => s.type))
247
+ const colours = /** @type {string[]} */ (option.color ?? []).map(hexOf)
248
+ const value = (/** @type {any} */ v) => (Array.isArray(v) ? Number(v.at(-1)) : typeof v === 'object' && v !== null ? Number(v.value) : Number(v))
249
+ const common = { ...at, showLegend: series.length > 1 || types.has('pie'), legendPos: 'b', legendFontSize: 12, legendColor: how.ink, chartColors: colours, showTitle: Boolean(option.title?.[0]?.text), title: option.title?.[0]?.text, titleFontSize: 16, titleColor: how.ink, catAxisLabelColor: how.ink, valAxisLabelColor: how.ink, dataLabelColor: how.ink, lang: how.lang }
250
+ if (types.size === 1 && (types.has('pie'))) {
251
+ const pie = series[0]
252
+ const ring = Array.isArray(pie.radius) && parseFloat(pie.radius[0]) > 0
253
+ slide.addChart(ring ? pres.charts.DOUGHNUT : pres.charts.PIE, [{ name: pie.name ?? '', labels: pie.data.map((/** @type {any} */ d) => d.name), values: pie.data.map(value) }], { ...common, showPercent: true, showLegend: true, holeSize: ring ? 55 : undefined })
254
+ return true
255
+ }
256
+ if ([...types].every((t) => t === 'bar' || t === 'line')) {
257
+ const horizontal = option.yAxis?.[0]?.type === 'category'
258
+ const labels = (horizontal ? option.yAxis : option.xAxis)?.[0]?.data ?? []
259
+ const of = (/** @type {any} */ s) => [{ name: s.name ?? '', labels: labels.map(String), values: s.data.map(value) }]
260
+ const kind = (/** @type {any} */ s) => (s.type === 'bar' ? pres.charts.BAR : s.areaStyle ? pres.charts.AREA : pres.charts.LINE)
261
+ const axes = { catAxisLabelFontSize: 11, valAxisLabelFontSize: 11, valGridLine: { color: 'D9D9D9', size: 0.5 }, catAxisOrientation: how.rtl && !horizontal ? 'maxMin' : 'minMax', valAxisLabelFormatCode: '#,##0' }
262
+ if (types.size === 1) {
263
+ const data = series.flatMap(of)
264
+ slide.addChart(kind(series[0]), data, { ...common, ...axes, barDir: horizontal ? 'bar' : 'col', barGrouping: series.some((s) => s.stack) ? 'stacked' : 'clustered', lineDataSymbol: 'circle', lineSize: 2 })
265
+ } else slide.addChart(series.map((s) => ({ type: kind(s), data: of(s), options: { barDir: horizontal ? 'bar' : 'col', chartColors: [colours[series.indexOf(s) % colours.length]] } })), { ...common, ...axes })
266
+ return true
267
+ }
268
+ if (types.size === 1 && types.has('scatter')) {
269
+ const points = series[0].data.map((/** @type {any} */ d) => (Array.isArray(d) ? d : [d.value?.[0], d.value?.[1]]))
270
+ slide.addChart(pres.charts.SCATTER, [{ name: 'X', values: points.map((/** @type {any[]} */ p) => p[0]) }, { name: series[0].name ?? 'Y', values: points.map((/** @type {any[]} */ p) => p[1]) }], { ...common, lineSize: 0, lineDataSymbolSize: 8 })
271
+ return true
272
+ }
273
+ if (types.size === 1 && types.has('radar')) {
274
+ const indicators = option.radar?.[0]?.indicator ?? []
275
+ slide.addChart(pres.charts.RADAR, series[0].data.map((/** @type {any} */ d) => ({ name: d.name ?? '', labels: indicators.map((/** @type {any} */ i) => i.name), values: d.value })), { ...common, showLegend: true, radarStyle: 'standard' })
276
+ return true
277
+ }
278
+ return false
279
+ }
280
+
281
+ /** PowerPoint's transition for Slidev's name: Morph (with a fade where it isn't known), a fade, a push. @param {string} kind @param {boolean} rtl */
282
+ function transitionXml(kind, rtl) {
283
+ const push = (/** @type {string} */ dir) => `<p:transition spd="med"><p:push dir="${dir}"/></p:transition>`
284
+ if (kind === 'view-transition' || kind === 'morph') return '<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:p159="http://schemas.microsoft.com/office/powerpoint/2015/09/main" Requires="p159"><p:transition spd="slow"><p159:morph option="byObject"/></p:transition></mc:Choice><mc:Fallback><p:transition spd="slow"><p:fade/></p:transition></mc:Fallback></mc:AlternateContent>'
285
+ if (kind === 'slide-left') return push(rtl ? 'r' : 'l')
286
+ if (kind === 'slide-right') return push(rtl ? 'l' : 'r')
287
+ if (kind === 'slide-up') return push('u')
288
+ if (kind === 'slide-down') return push('d')
289
+ return '<p:transition spd="med"><p:fade/></p:transition>'
290
+ }
291
+
292
+ /** A picture's data as PNG, drawn at a size; null when it can't be read (another site's, unshared). @param {string} src @param {number} w @param {number} h @param {boolean} [cover] */
293
+ async function pictureOf(src, w, h, cover = false) {
294
+ try {
295
+ const img = new Image()
296
+ img.crossOrigin = 'anonymous'
297
+ img.src = src
298
+ await img.decode()
299
+ const canvas = document.createElement('canvas')
300
+ canvas.width = Math.max(1, Math.round(w))
301
+ canvas.height = Math.max(1, Math.round(h))
302
+ const ctx = /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d'))
303
+ const iw = img.naturalWidth || w, ih = img.naturalHeight || h
304
+ const scale = cover ? Math.max(canvas.width / iw, canvas.height / ih) : Math.min(canvas.width / iw, canvas.height / ih)
305
+ ctx.drawImage(img, (canvas.width - iw * scale) / 2, (canvas.height - ih * scale) / 2, iw * scale, ih * scale)
306
+ return canvas.toDataURL('image/png')
307
+ } catch {
308
+ return null
309
+ }
310
+ }
package/src/render.js ADDED
@@ -0,0 +1,242 @@
1
+ // A slide drawn from its Markdown: marked's tokens made into elements with the DOM's own calls, so
2
+ // nothing here needs innerHTML, a style attribute or a Trusted Types policy, and a page under any
3
+ // policy can draw a deck. Each block that can be changed by hand says where its words are in the
4
+ // source (data-edit, offsets), so an edit made on the slide goes back into the Markdown. HTML a
5
+ // deck holds is not drawn: Slidev's <v-click> and <v-clicks> become steps, <br> a line break, and
6
+ // any other tag gives up its words alone. ```echarts and ```mermaid go to the host's drawers.
7
+ import { Lexer } from 'marked'
8
+ import { STANDARD, inferLayout, ALIASES } from './kit.js'
9
+
10
+ /** @typedef {import('./parse.js').Slide} Slide */
11
+ /** @typedef {import('./parse.js').Slot} Slot */
12
+ /** @typedef {(element: HTMLElement, source: string) => void | Promise<void>} Drawer */
13
+ /** @typedef {{ blocks: Record<string, Drawer>, resolve: (src: string) => string | null, headings: 'real' | 'aria', number: (n: number) => string, total: number, kit: import('./kit.js').Kit }} Context */
14
+
15
+ /**
16
+ * @param {string} tag @param {Record<string, string | number | boolean | undefined>} [attributes]
17
+ * @param {(Node | string | null | undefined | false)[]} [children]
18
+ */
19
+ export function h(tag, attributes = {}, children = []) {
20
+ const element = document.createElement(tag)
21
+ for (const [name, value] of Object.entries(attributes)) if (value !== undefined && value !== false) element.setAttribute(name, value === true ? '' : String(value))
22
+ for (const child of children) if (child || child === '') element.append(child)
23
+ return element
24
+ }
25
+
26
+ /** An address a slide may show: the web's, a picture held in the file, or one beside it. @param {string} href */
27
+ export function safeUrl(href) {
28
+ const url = String(href ?? '').trim()
29
+ if (/^(https?:|mailto:|blob:|#)/i.test(url) || /^data:image\/(png|jpe?g|gif|webp|avif|svg\+xml);/i.test(url)) return url
30
+ if (/^[a-z][\w+.-]*:/i.test(url)) return null
31
+ return url
32
+ }
33
+
34
+ /**
35
+ * A slide, laid out by its layout, with its slots, steps and number.
36
+ * @param {Slide} slide @param {Context} context @returns {{ element: HTMLElement, steps: number }}
37
+ */
38
+ export function renderSlide(slide, context) {
39
+ // The layout the slide names, by its name or a name brains give it, else the one its content suits.
40
+ const asked = String(slide.settings.layout ?? '').trim().toLowerCase()
41
+ const layout = context.kit.layouts[asked] ? asked : context.kit.layouts[ALIASES[asked]] ? ALIASES[asked] : inferLayout(slide)
42
+ const definition = context.kit.layouts[layout] ?? STANDARD.layouts.default
43
+ const classes = String(slide.settings.class ?? '').split(/\s+/).filter((name) => ['invert', 'text-center', 'text-left'].includes(name))
44
+ const counter = { step: 0 }
45
+ const slots = new Map(slide.slots.map((slot) => [slot.name, slot]))
46
+ const names = definition.slots
47
+ const section = h('section', {
48
+ class: ['deck-slide', `deck-layout-${layout}`, ...classes.map((name) => `deck-class-${name}`)].join(' '),
49
+ 'data-index': slide.index, 'data-layout': layout, 'data-paper': definition.paper, 'data-align': definition.align, 'data-justify': definition.justify, 'data-title': definition.title,
50
+ 'aria-roledescription': 'slide', role: 'group',
51
+ })
52
+ // Its grid, from the layout's definition, through the CSSOM: areas named s-<slot>, as `default` is no name CSS allows.
53
+ const canvas = h('div', { class: 'deck-canvas' })
54
+ canvas.style.setProperty('grid-template-areas', definition.areas.map((row) => `"${row.map((cell) => (cell === '.' ? '.' : `s-${cell}`)).join(' ')}"`).join(' '))
55
+ canvas.style.setProperty('grid-template-columns', definition.columns.join(' '))
56
+ canvas.style.setProperty('grid-template-rows', definition.rows.join(' '))
57
+ section.append(canvas)
58
+ const image = slide.settings.image ?? slide.settings.background
59
+ if (typeof image === 'string' && ['image-left', 'image-right', 'image', 'cover'].includes(layout)) {
60
+ const url = context.resolve(image)
61
+ if (url) {
62
+ const picture = h('div', { class: 'deck-image', role: 'img', 'aria-label': String(slide.settings.alt ?? '') || undefined })
63
+ picture.style.setProperty('--deck-image', `url("${url.replace(/["\\\n]/g, encodeURIComponent)}")`)
64
+ canvas.append(picture)
65
+ section.classList.add('has-image')
66
+ }
67
+ }
68
+ for (const name of names) {
69
+ const slot = slots.get(name)
70
+ const element = h('div', { class: 'deck-slot', 'data-slot': name }, slot ? blocks(slot, context, counter) : [])
71
+ element.style.setProperty('grid-area', `s-${name}`)
72
+ canvas.append(element)
73
+ }
74
+ // Slots a layout doesn't have still show, under the rest, rather than being lost.
75
+ for (const slot of slide.slots) if (!names.includes(slot.name)) canvas.append(h('div', { class: 'deck-slot', 'data-slot': slot.name }, blocks(slot, context, counter)))
76
+ if (definition.numbered && slide.settings.paginate !== false) section.append(h('span', { class: 'deck-number', 'aria-hidden': 'true' }, [context.number(slide.index + 1)]))
77
+ return { element: section, steps: counter.step }
78
+ }
79
+
80
+ /** @param {Slot} slot @param {Context} context @param {{ step: number }} counter @returns {Node[]} */
81
+ function blocks(slot, context, counter) {
82
+ const tokens = new Lexer({ gfm: true }).lex(slot.text)
83
+ /** @type {Node[]} */
84
+ const out = []
85
+ let at = slot.start, mode = /** @type {'' | 'click' | 'clicks'} */ ('')
86
+ for (const token of tokens) {
87
+ const start = at
88
+ at += token.raw.length
89
+ // Pandoc's fences: `::: incremental` shows its list a point a click, as <v-clicks> does; the others, nothing.
90
+ const fences = token.type === 'paragraph' ? token.raw.trim().split('\n').map((/** @type {string} */ line) => line.trim()) : []
91
+ if (fences.length && fences.every((/** @type {string} */ line) => /^:{3,}/.test(line))) {
92
+ for (const fence of fences) {
93
+ if (/^:{3,}[ \t]*\{?[ \t]*\.?incremental\b/.test(fence)) mode = 'clicks'
94
+ else if (/^:{3,}\s*$/.test(fence) && mode === 'clicks') mode = ''
95
+ }
96
+ continue
97
+ }
98
+ if (token.type === 'html') {
99
+ const tag = /^\s*<(\/?)(v-clicks?|br)\b[^>]*>\s*$/i.exec(token.raw)
100
+ if (tag && tag[2] === 'br') out.push(h('br'))
101
+ else if (tag) mode = tag[1] ? '' : /** @type {'click' | 'clicks'} */ (tag[2].toLowerCase() === 'v-clicks' ? 'clicks' : 'click')
102
+ else if (/<li\b/i.test(token.raw)) {
103
+ // A list written as HTML, as brains write Slidev's steps (<li v-click>): its items' words, each
104
+ // a step when it asks to be one.
105
+ const list = h(/<ol\b/i.test(token.raw) ? 'ol' : 'ul', { dir: 'auto' })
106
+ for (const [, attrs, inner] of token.raw.matchAll(/<li\b([^>]*)>([\s\S]*?)(?=<\/li>|<li\b|<\/[ou]l>|$)/gi)) {
107
+ const words = inner.replace(/<!--[\s\S]*?-->/g, '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
108
+ if (!words) continue
109
+ const li = h('li', { dir: 'auto' }, [words])
110
+ if (/\bv-click\b/.test(attrs) || mode === 'clicks') step(li, ++counter.step)
111
+ list.append(li)
112
+ }
113
+ if (list.childElementCount) out.push(list)
114
+ } else {
115
+ const words = token.raw.replace(/<!--[\s\S]*?-->/g, '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
116
+ if (words) out.push(h('p', {}, [words]))
117
+ }
118
+ continue
119
+ }
120
+ const element = block(token, start, context, mode === 'clicks' ? counter : null)
121
+ if (!element) continue
122
+ if (mode === 'click' || (mode === 'clicks' && token.type !== 'list')) step(element, ++counter.step)
123
+ out.push(element)
124
+ }
125
+ return out
126
+ }
127
+
128
+ /** @param {Element} element @param {number} n */
129
+ const step = (element, n) => { element.classList.add('deck-step'); element.setAttribute('data-step', String(n)) }
130
+
131
+ /**
132
+ * @param {any} token @param {number} start where its raw starts in the source
133
+ * @param {Context} context @param {{ step: number } | null} stepped list items each a step
134
+ * @returns {HTMLElement | null}
135
+ */
136
+ function block(token, start, context, stepped) {
137
+ switch (token.type) {
138
+ case 'heading': {
139
+ const head = /^\s*#{1,6}[ \t]+/.exec(token.raw)
140
+ const edit = head ? range(start + head[0].length, start + token.raw.replace(/\s*#*\s*$/, '').length) : undefined
141
+ return context.headings === 'real'
142
+ ? h(`h${token.depth}`, { dir: 'auto', 'data-edit': edit }, inline(token.tokens, context))
143
+ : h('p', { class: `deck-h deck-h${token.depth}`, role: 'heading', 'aria-level': Math.min(6, token.depth + 2), dir: 'auto', 'data-edit': edit }, inline(token.tokens, context))
144
+ }
145
+ case 'paragraph': {
146
+ const lone = token.tokens.length === 1 && token.tokens[0].type === 'image'
147
+ return h('p', { dir: 'auto', class: lone ? 'deck-picture' : undefined, 'data-edit': lone ? undefined : range(start, start + token.raw.trimEnd().length) }, inline(token.tokens, context))
148
+ }
149
+ case 'text': return h('p', { dir: 'auto' }, inline(token.tokens ?? [{ type: 'text', text: token.text }], context))
150
+ // No dir=auto here: the browser would skip its paragraphs, which have their own, and find no letter.
151
+ case 'blockquote': return h('blockquote', {}, children(token.tokens, start, token.raw, context))
152
+ case 'list': {
153
+ let from = 0
154
+ const list = h(token.ordered ? 'ol' : 'ul', { start: token.ordered && token.start !== 1 ? token.start : undefined, class: token.items.some((/** @type {any} */ item) => item.task) ? 'deck-tasks' : undefined })
155
+ for (const item of token.items) {
156
+ const offset = token.raw.indexOf(item.raw, from)
157
+ from = offset + item.raw.length
158
+ const itemStart = start + Math.max(0, offset)
159
+ const marker = /^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?/.exec(item.raw)
160
+ const simple = item.tokens.length === 1 && item.tokens[0].type === 'text' && !/\n\s*\S/.test(item.raw.trim().split('\n').slice(1).join('\n'))
161
+ const li = h('li', { dir: 'auto', 'data-edit': simple && marker ? range(itemStart + marker[0].length, itemStart + item.raw.trimEnd().length) : undefined }, [
162
+ item.task ? h('input', { type: 'checkbox', checked: item.checked, disabled: true, 'aria-label': item.text }) : null,
163
+ ...item.tokens.flatMap((/** @type {any} */ inner) => inner.type === 'text' ? inline(inner.tokens ?? [{ type: 'text', text: inner.text }], context)
164
+ : [block(inner, itemStart + Math.max(0, item.raw.indexOf(inner.raw)), context, null)]),
165
+ ])
166
+ if (stepped) step(li, ++stepped.step)
167
+ list.append(li)
168
+ }
169
+ return list
170
+ }
171
+ case 'code': {
172
+ const info = /^(\S*)\s*(?:\{([^}]*)\})?/.exec(token.lang ?? '') ?? []
173
+ const lang = (info[1] ?? '').toLowerCase()
174
+ const drawer = context.blocks[lang]
175
+ if (drawer) {
176
+ const box = h('div', { class: `deck-block deck-${lang}`, 'data-source': token.text })
177
+ box.append(h('pre', { class: 'deck-block-source', dir: 'ltr' }, [h('code', {}, [token.text])]))
178
+ queueMicrotask(() => Promise.resolve(drawer(box, token.text)).catch(() => box.classList.add('is-failed')))
179
+ return box
180
+ }
181
+ const marked = lines(info[2] ?? '')
182
+ const code = h('code', { class: lang ? `language-${lang}` : undefined })
183
+ for (const [i, line] of token.text.split('\n').entries()) code.append(h('span', { class: marked.has(i + 1) ? 'deck-line is-marked' : 'deck-line' }, [line, '\n']))
184
+ return h('pre', { dir: 'ltr', class: marked.size ? 'has-marks' : undefined, tabindex: 0 }, [code])
185
+ }
186
+ case 'table': {
187
+ const align = (/** @type {string | null} */ a) => a ?? undefined
188
+ return h('div', { class: 'deck-table' }, [h('table', {}, [
189
+ h('thead', {}, [h('tr', {}, token.header.map((/** @type {any} */ cell, /** @type {number} */ i) => h('th', { 'data-align': align(token.align[i]), dir: 'auto' }, inline(cell.tokens, context))))]),
190
+ h('tbody', {}, token.rows.map((/** @type {any[]} */ row) => h('tr', {}, row.map((cell, i) => h('td', { 'data-align': align(token.align[i]), dir: 'auto' }, inline(cell.tokens, context)))))),
191
+ ])])
192
+ }
193
+ default: return null
194
+ }
195
+ }
196
+
197
+ /** @param {any[]} tokens @param {number} start @param {string} raw @param {Context} context */
198
+ function children(tokens, start, raw, context) {
199
+ let from = 0
200
+ return tokens.map((token) => {
201
+ const offset = raw.indexOf(token.raw, from)
202
+ from = Math.max(from, offset + token.raw.length)
203
+ return block(token, start + Math.max(0, offset), context, null)
204
+ })
205
+ }
206
+
207
+ /** Line numbers as Slidev marks them, `{2,4-5}`; `|` steps are read as all their lines at once. @param {string} text */
208
+ function lines(text) {
209
+ const marked = new Set()
210
+ for (const part of text.split(/[,|]/)) {
211
+ const [a, b] = part.split('-').map((n) => parseInt(n, 10))
212
+ if (Number.isFinite(a)) for (let n = a; n <= (Number.isFinite(b) ? b : a); n++) marked.add(n)
213
+ }
214
+ return marked
215
+ }
216
+
217
+ /** @param {number} from @param {number} to */
218
+ const range = (from, to) => (to > from ? `${from}-${to}` : undefined)
219
+
220
+ /** @param {any[]} tokens @param {Context} context @returns {Node[]} */
221
+ export function inline(tokens = [], context) {
222
+ return tokens.flatMap((token) => {
223
+ switch (token.type) {
224
+ case 'text': case 'escape': return token.tokens ? inline(token.tokens, context) : [token.text.replace(/&(amp|lt|gt|quot|#39);/g, (/** @type {string} */ _, /** @type {string} */ e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" })[/** @type {'amp'} */ (e)])]
225
+ case 'strong': return [h('strong', {}, inline(token.tokens, context))]
226
+ case 'em': return [h('em', {}, inline(token.tokens, context))]
227
+ case 'del': return [h('del', {}, inline(token.tokens, context))]
228
+ case 'codespan': return [h('code', {}, [token.text.replace(/&(amp|lt|gt|quot|#39);/g, (/** @type {string} */ _, /** @type {string} */ e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" })[/** @type {'amp'} */ (e)])])]
229
+ case 'br': return [h('br')]
230
+ case 'link': {
231
+ const href = safeUrl(token.href)
232
+ return href ? [h('a', { href, rel: 'noopener noreferrer', target: href.startsWith('#') ? undefined : '_blank' }, inline(token.tokens, context))] : inline(token.tokens, context)
233
+ }
234
+ case 'image': {
235
+ const src = context.resolve(token.href)
236
+ return src ? [h('img', { src, alt: token.text, loading: 'lazy' })] : [token.text]
237
+ }
238
+ case 'html': return /^<br\s*\/?>$/i.test(token.raw) ? [h('br')] : []
239
+ default: return token.text ? [token.text] : []
240
+ }
241
+ })
242
+ }
package/src/repair.js ADDED
@@ -0,0 +1,51 @@
1
+ // What brains get wrong when they write a deck, mended before it is read:
2
+ // - a deck fenced as ```markdown, ```md, ```slidev or with nothing, three backticks around a
3
+ // chart's three (an inner block is closed by the next bare fence);
4
+ // - a slide's settings written after an empty line (`---`, ``, `layout: fact`, `---`), which
5
+ // Slidev would show as words;
6
+ // - <v-clicks> and </v-clicks> without the empty lines that keep the list a list;
7
+ // - a chart fenced as ```json (an ECharts option: it has `series`);
8
+ // - a slot's marker with words after it on its line (`::right:: The canal`).
9
+ // Pure. Returns the deck and what was mended, so a test set can count both.
10
+
11
+ const OUTER = /^(`{3,}|~{3,})[ \t]*(deck|slidev|markdown|md)?[ \t]*$/
12
+ const KEYS = 'layout|transition|class|image|background|clicks|title|level|hide|zoom|theme|colorSchema|lang|themeConfig|paginate|alt'
13
+
14
+ /** The deck an answer holds, or null. @param {string} answer @returns {{ source: string, mended: string[] } | null} */
15
+ export function deckIn(answer) {
16
+ const lines = answer.replace(/\r\n?/g, '\n').split('\n')
17
+ /** @type {{ tag: string, body: string[] }[]} */
18
+ const found = []
19
+ for (let i = 0; i < lines.length; i++) {
20
+ const open = OUTER.exec(lines[i])
21
+ if (!open) continue
22
+ const fence = open[1]
23
+ let j = i + 1, inner = false
24
+ const body = []
25
+ for (; j < lines.length; j++) {
26
+ const line = lines[j].trimEnd()
27
+ if (!inner && line === fence) break
28
+ if (/^(`{3,}|~{3,})\s*\S/.test(line) && !inner) inner = true
29
+ else if (inner && /^(`{3,}|~{3,})\s*$/.test(line)) inner = false
30
+ body.push(lines[j])
31
+ }
32
+ found.push({ tag: open[2] ?? '', body })
33
+ i = j
34
+ }
35
+ const deck = found.find((f) => f.tag === 'deck') ?? found.find((f) => f.body.filter((l) => /^---\s*$/.test(l)).length >= 2)
36
+ if (!deck) return null
37
+ /** @type {string[]} */
38
+ const mended = []
39
+ if (deck.tag !== 'deck') mended.push(`fenced as ${deck.tag || 'nothing'}`)
40
+ let source = deck.body.join('\n')
41
+ const mend = (/** @type {string} */ what, /** @type {RegExp} */ pattern, /** @type {string} */ by) => {
42
+ const next = source.replace(pattern, by)
43
+ if (next !== source) { mended.push(what); source = next }
44
+ }
45
+ mend('settings after an empty line', new RegExp(`^---[ \\t]*\\n\\s*\\n((?:(?:${KEYS})\\s*:.*\\n(?:[ \\t]+\\S.*\\n)*)+)---[ \\t]*$`, 'gm'), '---\n$1---')
46
+ mend('steps without empty lines', /^[ \t]*(<\/?v-clicks?>)[ \t]*$/gm, '\n$1\n')
47
+ const charts = source.replace(/^```json[ \t]*\n([\s\S]*?)^```/gm, (block, body) => (body.includes('"series"') ? `\`\`\`echarts\n${body}\`\`\`` : block))
48
+ if (charts !== source) { mended.push('a chart fenced as json'); source = charts }
49
+ mend('words after a slot marker', /^::([\w-]+)::[ \t]+(\S.*)$/gm, '::$1::\n\n$2')
50
+ return { source, mended }
51
+ }