@codename/deck 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +96 -2
- package/dist/deck.bundle.mjs +114 -0
- package/dist/deck.bundle.mjs.map +7 -0
- package/dist/deck.css +164 -0
- package/dist/deck.js +275 -0
- package/dist/deck.js.map +7 -0
- package/dist/deck.mjs +50 -0
- package/dist/deck.mjs.map +7 -0
- package/dist/types/blocks.d.ts +25 -0
- package/dist/types/deck.d.ts +42 -0
- package/dist/types/file.d.ts +38 -0
- package/dist/types/index.d.ts +11 -0
- package/dist/types/kit.d.ts +73 -0
- package/dist/types/look.d.ts +55 -0
- package/dist/types/parse.d.ts +20 -0
- package/dist/types/pictures.d.ts +19 -0
- package/dist/types/pptx.d.ts +18 -0
- package/dist/types/render.d.ts +34 -0
- package/dist/types/repair.d.ts +5 -0
- package/dist/types/strings.d.ts +2 -0
- package/dist/types/version.d.ts +1 -0
- package/dist/types/yaml.d.ts +2 -0
- package/docs/findings.md +57 -0
- package/docs/format.md +70 -0
- package/docs/host.md +50 -0
- package/docs/kits.md +76 -0
- package/package.json +60 -4
- package/src/blocks.js +99 -0
- package/src/css.d.ts +5 -0
- package/src/deck.css +164 -0
- package/src/deck.js +359 -0
- package/src/file.js +74 -0
- package/src/index.js +12 -0
- package/src/kit.js +185 -0
- package/src/look.js +124 -0
- package/src/parse.js +180 -0
- package/src/pictures.js +47 -0
- package/src/pptx.js +310 -0
- package/src/render.js +241 -0
- package/src/repair.js +51 -0
- package/src/standalone.js +89 -0
- package/src/strings.js +10 -0
- package/src/version.js +2 -0
- package/src/yaml.js +137 -0
package/src/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,241 @@
|
|
|
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 { STANDARD, inferLayout, ALIASES } from './kit.js'
|
|
8
|
+
|
|
9
|
+
/** @typedef {import('./parse.js').Slide} Slide */
|
|
10
|
+
/** @typedef {import('./parse.js').Slot} Slot */
|
|
11
|
+
/** @typedef {(element: HTMLElement, source: string) => void | Promise<void>} Drawer */
|
|
12
|
+
/** @typedef {{ blocks: Record<string, Drawer>, resolve: (src: string) => string | null, headings: 'real' | 'aria', number: (n: number) => string, total: number, kit: import('./kit.js').Kit, lex: (markdown: string) => any[], slide: string }} Context */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} tag @param {Record<string, string | number | boolean | undefined>} [attributes]
|
|
16
|
+
* @param {(Node | string | null | undefined | false)[]} [children]
|
|
17
|
+
*/
|
|
18
|
+
export function h(tag, attributes = {}, children = []) {
|
|
19
|
+
const element = document.createElement(tag)
|
|
20
|
+
for (const [name, value] of Object.entries(attributes)) if (value !== undefined && value !== false) element.setAttribute(name, value === true ? '' : String(value))
|
|
21
|
+
for (const child of children) if (child || child === '') element.append(child)
|
|
22
|
+
return element
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** An address a slide may show: the web's, a picture held in the file, or one beside it. @param {string} href */
|
|
26
|
+
export function safeUrl(href) {
|
|
27
|
+
const url = String(href ?? '').trim()
|
|
28
|
+
if (/^(https?:|mailto:|blob:|#)/i.test(url) || /^data:image\/(png|jpe?g|gif|webp|avif|svg\+xml);/i.test(url)) return url
|
|
29
|
+
if (/^[a-z][\w+.-]*:/i.test(url)) return null
|
|
30
|
+
return url
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A slide, laid out by its layout, with its slots, steps and number.
|
|
35
|
+
* @param {Slide} slide @param {Context} context @returns {{ element: HTMLElement, steps: number }}
|
|
36
|
+
*/
|
|
37
|
+
export function renderSlide(slide, context) {
|
|
38
|
+
// The layout the slide names, by its name or a name brains give it, else the one its content suits.
|
|
39
|
+
const asked = String(slide.settings.layout ?? '').trim().toLowerCase()
|
|
40
|
+
const layout = context.kit.layouts[asked] ? asked : context.kit.layouts[ALIASES[asked]] ? ALIASES[asked] : inferLayout(slide)
|
|
41
|
+
const definition = context.kit.layouts[layout] ?? STANDARD.layouts.default
|
|
42
|
+
const classes = String(slide.settings.class ?? '').split(/\s+/).filter((name) => ['invert', 'text-center', 'text-left'].includes(name))
|
|
43
|
+
const counter = { step: 0 }
|
|
44
|
+
const slots = new Map(slide.slots.map((slot) => [slot.name, slot]))
|
|
45
|
+
const names = definition.slots
|
|
46
|
+
const section = h('section', {
|
|
47
|
+
class: ['deck-slide', `deck-layout-${layout}`, ...classes.map((name) => `deck-class-${name}`)].join(' '),
|
|
48
|
+
'data-index': slide.index, 'data-layout': layout, 'data-paper': definition.paper, 'data-align': definition.align, 'data-justify': definition.justify, 'data-title': definition.title,
|
|
49
|
+
'aria-roledescription': context.slide, role: 'group',
|
|
50
|
+
})
|
|
51
|
+
// Its grid, from the layout's definition, through the CSSOM: areas named s-<slot>, as `default` is no name CSS allows.
|
|
52
|
+
const canvas = h('div', { class: 'deck-canvas' })
|
|
53
|
+
canvas.style.setProperty('grid-template-areas', definition.areas.map((row) => `"${row.map((cell) => (cell === '.' ? '.' : `s-${cell}`)).join(' ')}"`).join(' '))
|
|
54
|
+
canvas.style.setProperty('grid-template-columns', definition.columns.join(' '))
|
|
55
|
+
canvas.style.setProperty('grid-template-rows', definition.rows.join(' '))
|
|
56
|
+
section.append(canvas)
|
|
57
|
+
const image = slide.settings.image ?? slide.settings.background
|
|
58
|
+
if (typeof image === 'string' && ['image-left', 'image-right', 'image', 'cover'].includes(layout)) {
|
|
59
|
+
const url = context.resolve(image)
|
|
60
|
+
if (url) {
|
|
61
|
+
const picture = h('div', { class: 'deck-image', role: 'img', 'aria-label': String(slide.settings.alt ?? '') || undefined })
|
|
62
|
+
picture.style.setProperty('--deck-image', `url("${url.replace(/["\\\n]/g, encodeURIComponent)}")`)
|
|
63
|
+
canvas.append(picture)
|
|
64
|
+
section.classList.add('has-image')
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const name of names) {
|
|
68
|
+
const slot = slots.get(name)
|
|
69
|
+
const element = h('div', { class: 'deck-slot', 'data-slot': name }, slot ? blocks(slot, context, counter) : [])
|
|
70
|
+
element.style.setProperty('grid-area', `s-${name}`)
|
|
71
|
+
canvas.append(element)
|
|
72
|
+
}
|
|
73
|
+
// Slots a layout doesn't have still show, under the rest, rather than being lost.
|
|
74
|
+
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)))
|
|
75
|
+
if (definition.numbered && slide.settings.paginate !== false) section.append(h('span', { class: 'deck-number', 'aria-hidden': 'true' }, [context.number(slide.index + 1)]))
|
|
76
|
+
return { element: section, steps: counter.step }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @param {Slot} slot @param {Context} context @param {{ step: number }} counter @returns {Node[]} */
|
|
80
|
+
function blocks(slot, context, counter) {
|
|
81
|
+
const tokens = context.lex(slot.text)
|
|
82
|
+
/** @type {Node[]} */
|
|
83
|
+
const out = []
|
|
84
|
+
let at = slot.start, mode = /** @type {'' | 'click' | 'clicks'} */ ('')
|
|
85
|
+
for (const token of tokens) {
|
|
86
|
+
const start = at
|
|
87
|
+
at += token.raw.length
|
|
88
|
+
// Pandoc's fences: `::: incremental` shows its list a point a click, as <v-clicks> does; the others, nothing.
|
|
89
|
+
const fences = token.type === 'paragraph' ? token.raw.trim().split('\n').map((/** @type {string} */ line) => line.trim()) : []
|
|
90
|
+
if (fences.length && fences.every((/** @type {string} */ line) => /^:{3,}/.test(line))) {
|
|
91
|
+
for (const fence of fences) {
|
|
92
|
+
if (/^:{3,}[ \t]*\{?[ \t]*\.?incremental\b/.test(fence)) mode = 'clicks'
|
|
93
|
+
else if (/^:{3,}\s*$/.test(fence) && mode === 'clicks') mode = ''
|
|
94
|
+
}
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
if (token.type === 'html') {
|
|
98
|
+
const tag = /^\s*<(\/?)(v-clicks?|br)\b[^>]*>\s*$/i.exec(token.raw)
|
|
99
|
+
if (tag && tag[2] === 'br') out.push(h('br'))
|
|
100
|
+
else if (tag) mode = tag[1] ? '' : /** @type {'click' | 'clicks'} */ (tag[2].toLowerCase() === 'v-clicks' ? 'clicks' : 'click')
|
|
101
|
+
else if (/<li\b/i.test(token.raw)) {
|
|
102
|
+
// A list written as HTML, as brains write Slidev's steps (<li v-click>): its items' words, each
|
|
103
|
+
// a step when it asks to be one.
|
|
104
|
+
const list = h(/<ol\b/i.test(token.raw) ? 'ol' : 'ul', { dir: 'auto' })
|
|
105
|
+
for (const [, attrs, inner] of token.raw.matchAll(/<li\b([^>]*)>([\s\S]*?)(?=<\/li>|<li\b|<\/[ou]l>|$)/gi)) {
|
|
106
|
+
const words = inner.replace(/<!--[\s\S]*?-->/g, '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
|
|
107
|
+
if (!words) continue
|
|
108
|
+
const li = h('li', { dir: 'auto' }, [words])
|
|
109
|
+
if (/\bv-click\b/.test(attrs) || mode === 'clicks') step(li, ++counter.step)
|
|
110
|
+
list.append(li)
|
|
111
|
+
}
|
|
112
|
+
if (list.childElementCount) out.push(list)
|
|
113
|
+
} else {
|
|
114
|
+
const words = token.raw.replace(/<!--[\s\S]*?-->/g, '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
|
|
115
|
+
if (words) out.push(h('p', {}, [words]))
|
|
116
|
+
}
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
const element = block(token, start, context, mode === 'clicks' ? counter : null)
|
|
120
|
+
if (!element) continue
|
|
121
|
+
if (mode === 'click' || (mode === 'clicks' && token.type !== 'list')) step(element, ++counter.step)
|
|
122
|
+
out.push(element)
|
|
123
|
+
}
|
|
124
|
+
return out
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** @param {Element} element @param {number} n */
|
|
128
|
+
const step = (element, n) => { element.classList.add('deck-step'); element.setAttribute('data-step', String(n)) }
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {any} token @param {number} start where its raw starts in the source
|
|
132
|
+
* @param {Context} context @param {{ step: number } | null} stepped list items each a step
|
|
133
|
+
* @returns {HTMLElement | null}
|
|
134
|
+
*/
|
|
135
|
+
function block(token, start, context, stepped) {
|
|
136
|
+
switch (token.type) {
|
|
137
|
+
case 'heading': {
|
|
138
|
+
const head = /^\s*#{1,6}[ \t]+/.exec(token.raw)
|
|
139
|
+
const edit = head ? range(start + head[0].length, start + token.raw.replace(/\s*#*\s*$/, '').length) : undefined
|
|
140
|
+
return context.headings === 'real'
|
|
141
|
+
? h(`h${token.depth}`, { dir: 'auto', 'data-edit': edit }, inline(token.tokens, context))
|
|
142
|
+
: 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))
|
|
143
|
+
}
|
|
144
|
+
case 'paragraph': {
|
|
145
|
+
const lone = token.tokens.length === 1 && token.tokens[0].type === 'image'
|
|
146
|
+
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))
|
|
147
|
+
}
|
|
148
|
+
case 'text': return h('p', { dir: 'auto' }, inline(token.tokens ?? [{ type: 'text', text: token.text }], context))
|
|
149
|
+
// No dir=auto here: the browser would skip its paragraphs, which have their own, and find no letter.
|
|
150
|
+
case 'blockquote': return h('blockquote', {}, children(token.tokens, start, token.raw, context))
|
|
151
|
+
case 'list': {
|
|
152
|
+
let from = 0
|
|
153
|
+
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 })
|
|
154
|
+
for (const item of token.items) {
|
|
155
|
+
const offset = token.raw.indexOf(item.raw, from)
|
|
156
|
+
from = offset + item.raw.length
|
|
157
|
+
const itemStart = start + Math.max(0, offset)
|
|
158
|
+
const marker = /^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?/.exec(item.raw)
|
|
159
|
+
const simple = item.tokens.length === 1 && item.tokens[0].type === 'text' && !/\n\s*\S/.test(item.raw.trim().split('\n').slice(1).join('\n'))
|
|
160
|
+
const li = h('li', { dir: 'auto', 'data-edit': simple && marker ? range(itemStart + marker[0].length, itemStart + item.raw.trimEnd().length) : undefined }, [
|
|
161
|
+
item.task ? h('input', { type: 'checkbox', checked: item.checked, disabled: true, 'aria-label': item.text }) : null,
|
|
162
|
+
...item.tokens.flatMap((/** @type {any} */ inner) => inner.type === 'text' ? inline(inner.tokens ?? [{ type: 'text', text: inner.text }], context)
|
|
163
|
+
: [block(inner, itemStart + Math.max(0, item.raw.indexOf(inner.raw)), context, null)]),
|
|
164
|
+
])
|
|
165
|
+
if (stepped) step(li, ++stepped.step)
|
|
166
|
+
list.append(li)
|
|
167
|
+
}
|
|
168
|
+
return list
|
|
169
|
+
}
|
|
170
|
+
case 'code': {
|
|
171
|
+
const info = /^(\S*)\s*(?:\{([^}]*)\})?/.exec(token.lang ?? '') ?? []
|
|
172
|
+
const lang = (info[1] ?? '').toLowerCase()
|
|
173
|
+
const drawer = context.blocks[lang]
|
|
174
|
+
if (drawer) {
|
|
175
|
+
const box = h('div', { class: `deck-block deck-${lang}`, 'data-source': token.text })
|
|
176
|
+
box.append(h('pre', { class: 'deck-block-source', dir: 'ltr' }, [h('code', {}, [token.text])]))
|
|
177
|
+
queueMicrotask(() => Promise.resolve(drawer(box, token.text)).catch(() => box.classList.add('is-failed')))
|
|
178
|
+
return box
|
|
179
|
+
}
|
|
180
|
+
const marked = lines(info[2] ?? '')
|
|
181
|
+
const code = h('code', { class: lang ? `language-${lang}` : undefined })
|
|
182
|
+
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']))
|
|
183
|
+
return h('pre', { dir: 'ltr', class: marked.size ? 'has-marks' : undefined, tabindex: 0 }, [code])
|
|
184
|
+
}
|
|
185
|
+
case 'table': {
|
|
186
|
+
const align = (/** @type {string | null} */ a) => a ?? undefined
|
|
187
|
+
return h('div', { class: 'deck-table' }, [h('table', {}, [
|
|
188
|
+
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))))]),
|
|
189
|
+
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)))))),
|
|
190
|
+
])])
|
|
191
|
+
}
|
|
192
|
+
default: return null
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** @param {any[]} tokens @param {number} start @param {string} raw @param {Context} context */
|
|
197
|
+
function children(tokens, start, raw, context) {
|
|
198
|
+
let from = 0
|
|
199
|
+
return tokens.map((token) => {
|
|
200
|
+
const offset = raw.indexOf(token.raw, from)
|
|
201
|
+
from = Math.max(from, offset + token.raw.length)
|
|
202
|
+
return block(token, start + Math.max(0, offset), context, null)
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Line numbers as Slidev marks them, `{2,4-5}`; `|` steps are read as all their lines at once. @param {string} text */
|
|
207
|
+
function lines(text) {
|
|
208
|
+
const marked = new Set()
|
|
209
|
+
for (const part of text.split(/[,|]/)) {
|
|
210
|
+
const [a, b] = part.split('-').map((n) => parseInt(n, 10))
|
|
211
|
+
if (Number.isFinite(a)) for (let n = a; n <= (Number.isFinite(b) ? b : a); n++) marked.add(n)
|
|
212
|
+
}
|
|
213
|
+
return marked
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** @param {number} from @param {number} to */
|
|
217
|
+
const range = (from, to) => (to > from ? `${from}-${to}` : undefined)
|
|
218
|
+
|
|
219
|
+
/** @param {any[]} tokens @param {Context} context @returns {Node[]} */
|
|
220
|
+
export function inline(tokens = [], context) {
|
|
221
|
+
return tokens.flatMap((token) => {
|
|
222
|
+
switch (token.type) {
|
|
223
|
+
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)])]
|
|
224
|
+
case 'strong': return [h('strong', {}, inline(token.tokens, context))]
|
|
225
|
+
case 'em': return [h('em', {}, inline(token.tokens, context))]
|
|
226
|
+
case 'del': return [h('del', {}, inline(token.tokens, context))]
|
|
227
|
+
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)])])]
|
|
228
|
+
case 'br': return [h('br')]
|
|
229
|
+
case 'link': {
|
|
230
|
+
const href = safeUrl(token.href)
|
|
231
|
+
return href ? [h('a', { href, rel: 'noopener noreferrer', target: href.startsWith('#') ? undefined : '_blank' }, inline(token.tokens, context))] : inline(token.tokens, context)
|
|
232
|
+
}
|
|
233
|
+
case 'image': {
|
|
234
|
+
const src = context.resolve(token.href)
|
|
235
|
+
return src ? [h('img', { src, alt: token.text, loading: 'lazy' })] : [token.text]
|
|
236
|
+
}
|
|
237
|
+
case 'html': return /^<br\s*\/?>$/i.test(token.raw) ? [h('br')] : []
|
|
238
|
+
default: return token.text ? [token.text] : []
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
}
|
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
|
+
}
|