@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/src/file.js ADDED
@@ -0,0 +1,74 @@
1
+ // A deck file, `.deck.html`: a web page any browser opens, whose Markdown sits whole in one
2
+ // element, <script type="text/markdown" id="deck">, which no browser runs. Read and written here
3
+ // as text, never parsed as HTML, so a host can read a deck without running anything in it.
4
+ // The one escape: `<script` and `</script`, in any case, gain a backslash after their `<` (and
5
+ // one more for each already there), which reading takes away again: the HTML parser ends the
6
+ // element at `</script`, and a `<script` after `<!--` would keep it from ending. Pure.
7
+
8
+ import { VERSION } from './version.js'
9
+
10
+ export const OPEN = '<script type="text/markdown" id="deck">'
11
+ const CLOSE = '</script>'
12
+
13
+ /** @param {string} markdown */
14
+ export const escape = (markdown) => markdown.replace(/<(\\*)(\/?script)/gi, '<\\$1$2')
15
+ /** @param {string} text */
16
+ export const unescape = (text) => text.replace(/<\\(\\*)(\/?script)/gi, '<$1$2')
17
+
18
+ /** @param {string} text */
19
+ const attribute = (text) => text.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
20
+ /** @param {string} text */
21
+ const content = (text) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
22
+
23
+ /**
24
+ * Code held whole in the file: `</script`, `<script` and `<!--`, which only a string or a regular
25
+ * expression of a bundle holds, written with `\x3C` for their `<`, which reads the same there.
26
+ * @param {string} code
27
+ */
28
+ export function inlineCode(code) {
29
+ if (/\\<(!--|\/?script)/i.test(code)) throw new Error('This code cannot be held in a page as it is.')
30
+ return code.replace(/<(!--|\/?script)/gi, '\\x3C$1')
31
+ }
32
+
33
+ /**
34
+ * A deck's Markdown, from a file's text, or null when it holds none.
35
+ * @param {string} html @returns {string | null}
36
+ */
37
+ export function readDeckFile(html) {
38
+ const at = html.indexOf(OPEN)
39
+ if (at < 0) return null
40
+ const from = at + OPEN.length
41
+ const end = html.slice(from).search(/<\/script/i)
42
+ if (end < 0) return null
43
+ return unescape(html.slice(from, from + end).replace(/^\n/, '').replace(/\n$/, ''))
44
+ }
45
+
46
+ /** Where a linked file finds its renderer: this version of the package, on jsDelivr's npm mirror. @param {string} [version] */
47
+ export const rendererUrl = (version = VERSION) => `https://cdn.jsdelivr.net/npm/@codename/deck@${version}/dist/deck.js`
48
+
49
+ /**
50
+ * A deck file around its Markdown: its title, language and direction in the page's head for
51
+ * whatever reads it before the renderer does, then the Markdown, in the body (the head is never shown), readable as it is when no
52
+ * renderer comes, then the renderer: linked by default, to this version's address, or held whole;
53
+ * `base` tells it where to load the libraries a slide needs (a CDN's npm mirror, or a node_modules).
54
+ * @param {string} markdown
55
+ * @param {{ title?: string, lang?: string, dir?: string, renderer?: { src: string } | { code: string }, base?: string, generator?: string }} [options]
56
+ */
57
+ export function writeDeckFile(markdown, { title = '', lang = 'en', dir = 'auto', renderer = { src: rendererUrl() }, base, generator = `deck ${VERSION}` } = {}) {
58
+ const data = base ? ` data-base="${attribute(base)}"` : ''
59
+ const script = 'src' in renderer ? `<script src="${attribute(renderer.src)}"${data}></script>` : `<script${data}>${inlineCode(renderer.code)}</script>`
60
+ return /* html */ `<!doctype html>
61
+ <html lang="${attribute(lang)}" dir="${attribute(dir)}">
62
+ <meta charset="utf-8">
63
+ <meta name="viewport" content="width=device-width, initial-scale=1">
64
+ <meta name="generator" content="${attribute(generator)}">
65
+ <title>${content(title)}</title>
66
+ <style>#deck{display:block;white-space:pre-wrap;overflow-wrap:anywhere;font:1rem/1.6 ui-monospace,Menlo,Consolas,monospace;max-width:72ch;margin:2rem auto;padding:0 1rem}</style>
67
+ <body>
68
+ ${OPEN}
69
+ ${escape(markdown)}
70
+ ${CLOSE}
71
+ ${script}
72
+ </html>
73
+ `
74
+ }
package/src/index.js ADDED
@@ -0,0 +1,12 @@
1
+ // deck for a host: draw a deck into an element, read and write its file, save it as PowerPoint.
2
+ export { createDeck, markdownOf } from './deck.js'
3
+ export { parseDeck } from './parse.js'
4
+ export { renderSlide } from './render.js'
5
+ export { STANDARD, mergeKits, layoutOf, inferLayout, labelOf, tokensFrom } from './kit.js'
6
+ export { tokensOf, schemeOf, LOOKS, readable, contrast } from './look.js'
7
+ export { readDeckFile, writeDeckFile, rendererUrl, escape, unescape } from './file.js'
8
+ export { toPptx } from './pptx.js'
9
+ export { defaultBlocks, libraryAt } from './blocks.js'
10
+ export { inlinePictures } from './pictures.js'
11
+ export { deckIn } from './repair.js'
12
+ export { VERSION } from './version.js'
package/src/kit.js ADDED
@@ -0,0 +1,185 @@
1
+ // A kit: a design system and the layouts that go with it, as data and never as CSS or code, so a
2
+ // person, an organisation or a brain can make one and no kit can do more than lay out a slide.
3
+ // deck's own kit holds the usual layouts; a host's (an organisation's, a project's) and the deck
4
+ // file's own `kit:` go over it, in that order. A layout is a grid: its slots by name, the areas
5
+ // they take, its tracks, how its words sit, what paper it is on, and the PowerPoint layout it is
6
+ // nearest to. Everything is checked against a small grammar; what fails is dropped, with a reason.
7
+ // Pure.
8
+
9
+ /** @typedef {{ required?: boolean, max_words?: number, max_chars?: number, items?: [number, number], item_max_words?: number, heading_max_words?: number, kind?: string }} Budget */
10
+ /** @typedef {{ label?: Record<string, string> | string, intent?: string, use_when?: string, avoid_when?: string, slots: string[], budgets: Record<string, Budget>, areas: string[][], columns: string[], rows: string[], align: 'start' | 'center' | 'end', justify: 'start' | 'center' | 'end', paper: 'paper' | 'fill' | 'ink' | 'soft', title: 'normal' | 'large' | 'huge', pptx: string, numbered: boolean }} Layout */
11
+ /** @typedef {{ name?: string, tokens: Record<string, any>, layouts: Record<string, Layout> }} Kit */
12
+
13
+ const NAME = /^[a-z][a-z0-9-]{0,31}$/
14
+ const TRACK = /^(auto|min-content|max-content|\d{1,2}(\.\d)?fr|\d{1,3}%)$/
15
+ const PPTX = ['Title Slide', 'Title and Content', 'Section Header', 'Two Content', 'Comparison', 'Title Only', 'Blank', 'Content with Caption', 'Picture with Caption']
16
+ const ENUMS = /** @type {const} */ ({ align: ['start', 'center', 'end'], justify: ['start', 'center', 'end'], paper: ['paper', 'fill', 'ink', 'soft'], title: ['normal', 'large', 'huge'] })
17
+
18
+ /** The names brains give the usual layouts, for the ones deck has. */
19
+ export const ALIASES = /** @type {Record<string, string>} */ ({
20
+ title: 'cover', 'title-slide': 'cover', 'two-columns': 'two-cols', 'two-column': 'two-cols', columns: 'two-cols', comparison: 'two-cols-header',
21
+ 'three-columns': 'three-cols', 'section-header': 'section', divider: 'section', big: 'fact', number: 'fact', 'image-text': 'image-right', closing: 'end', thanks: 'end',
22
+ })
23
+
24
+ /** deck's own kit: the usual layouts, the ones Slidev names and the ones PowerPoint has, as data. */
25
+ export const STANDARD = /** @type {Kit} */ ({
26
+ name: 'standard',
27
+ tokens: {},
28
+ layouts: Object.fromEntries(Object.entries(/** @type {Record<string, any>} */ ({
29
+ default: { label: { en: 'Title and content', fr: 'Titre et contenu' }, pptx: 'Title and Content' },
30
+ cover: { label: { en: 'Cover', fr: 'Couverture' }, justify: 'center', paper: 'fill', title: 'huge', pptx: 'Title Slide', numbered: false },
31
+ intro: { label: { en: 'Introduction', fr: 'Introduction' }, justify: 'center', paper: 'fill', title: 'large', pptx: 'Title Slide', numbered: false },
32
+ section: { label: { en: 'Section', fr: 'Section' }, justify: 'center', paper: 'ink', title: 'huge', pptx: 'Section Header', numbered: false },
33
+ center: { label: { en: 'Centred', fr: 'Centré' }, justify: 'center', align: 'center', pptx: 'Title Only' },
34
+ statement: { label: { en: 'Statement', fr: 'Affirmation' }, justify: 'center', align: 'center', title: 'large', pptx: 'Title Only' },
35
+ fact: { label: { en: 'Big number', fr: 'Grand chiffre' }, justify: 'center', align: 'center', title: 'huge', pptx: 'Title Only' },
36
+ quote: { label: { en: 'Quote', fr: 'Citation' }, justify: 'center', pptx: 'Title Only' },
37
+ end: { label: { en: 'Closing', fr: 'Clôture' }, justify: 'center', align: 'center', paper: 'fill', title: 'large', pptx: 'Title Slide', numbered: false },
38
+ full: { label: { en: 'Full', fr: 'Plein cadre' }, pptx: 'Blank', numbered: false },
39
+ none: { label: { en: 'Blank', fr: 'Vide' }, pptx: 'Blank', numbered: false },
40
+ 'two-cols': { label: { en: 'Two columns', fr: 'Deux colonnes' }, slots: ['default', 'right'], areas: 'default right', columns: '1fr 1fr', pptx: 'Two Content' },
41
+ 'two-cols-header': { label: { en: 'Comparison', fr: 'Comparaison' }, slots: ['default', 'left', 'right', 'bottom'], areas: 'default default | left right | bottom bottom', columns: '1fr 1fr', rows: 'auto 1fr auto', pptx: 'Comparison' },
42
+ 'three-cols': { label: { en: 'Three columns', fr: 'Trois colonnes' }, slots: ['default', 'left', 'middle', 'right'], areas: 'default default default | left middle right', columns: '1fr 1fr 1fr', rows: 'auto 1fr', pptx: 'Comparison' },
43
+ 'image-left': { label: { en: 'Picture left', fr: 'Image à gauche' }, pptx: 'Picture with Caption', justify: 'center' },
44
+ 'image-right': { label: { en: 'Picture right', fr: 'Image à droite' }, pptx: 'Picture with Caption', justify: 'center' },
45
+ image: { label: { en: 'Picture', fr: 'Image' }, pptx: 'Blank', numbered: false },
46
+ })).map(([name, given]) => [name, /** @type {Layout} */ (layoutOf(given).layout)])),
47
+ })
48
+
49
+ /**
50
+ * A layout as given, checked: its slots, areas and tracks in the grammar, its words' place, paper
51
+ * and size among their names. @param {any} given @returns {{ layout: Layout | null, problems: string[] }}
52
+ */
53
+ export function layoutOf(given) {
54
+ const problems = []
55
+ if (!given || typeof given !== 'object') return { layout: null, problems: ['not a layout'] }
56
+ // Slots as names, or as a catalog writes them: each a budget, `title` being the heading the
57
+ // default slot opens with rather than a slot of its own.
58
+ /** @type {Record<string, Budget>} */
59
+ const budgets = {}
60
+ const named = Array.isArray(given.slots) || typeof given.slots === 'string' ? [given.slots].flat().map(String)
61
+ : given.slots && typeof given.slots === 'object' ? Object.entries(given.slots).map(([name, budget]) => {
62
+ if (budget && typeof budget === 'object') budgets[name] = clean(budget)
63
+ return name === 'title' ? 'default' : name
64
+ }) : ['default']
65
+ const slots = [...new Set(named)]
66
+ if (!slots.every((slot) => NAME.test(slot))) return { layout: null, problems: ['a slot name is not a-z, 0-9 and -'] }
67
+ if (!slots.includes('default')) slots.unshift('default')
68
+ // Areas as Slidev's and CSS's grid write them, rows split by | or new lines.
69
+ /** @type {string[]} */
70
+ const lines = Array.isArray(given.areas) ? given.areas.map((/** @type {any} */ row) => [row].flat().join(' ')) : String(given.areas ?? slots.join(' ')).split(/\||\n/)
71
+ const rows = lines.map((row) => row.trim().replace(/^"|"$/g, '').split(/\s+/)).filter((row) => row.length && row[0])
72
+ const width = rows[0]?.length ?? 0
73
+ if (!rows.length || rows.some((row) => row.length !== width) || rows.flat().some((cell) => cell !== '.' && !slots.includes(cell))) return { layout: null, problems: ['areas are not a rectangle of its slots'] }
74
+ for (const slot of slots) {
75
+ const cells = rows.flatMap((row, y) => row.map((cell, x) => /** @type {[string, number, number]} */ ([cell, x, y]))).filter(([cell]) => cell === slot)
76
+ if (!cells.length) { if (slot !== 'default') problems.push(`slot ${slot} has no area`); continue }
77
+ const xs = cells.map((c) => c[1]), ys = cells.map((c) => c[2])
78
+ if (cells.length !== (Math.max(...xs) - Math.min(...xs) + 1) * (Math.max(...ys) - Math.min(...ys) + 1)) return { layout: null, problems: [`slot ${slot} is not a rectangle`] }
79
+ }
80
+ const tracks = (/** @type {any} */ value, /** @type {number} */ count) => {
81
+ const list = value === undefined ? Array(count).fill('1fr') : (Array.isArray(value) ? value : String(value).split(/\s+/)).map(String)
82
+ return list.length === count && list.every((t) => TRACK.test(t)) ? list : null
83
+ }
84
+ const columns = tracks(given.columns, width), rowTracks = tracks(given.rows, rows.length)
85
+ if (!columns || !rowTracks) return { layout: null, problems: ['columns or rows are not tracks deck knows (auto, 1fr, 40%…), one per column or row'] }
86
+ const pick = (/** @type {keyof typeof ENUMS} */ key, /** @type {string} */ fallback) => {
87
+ const value = String(given[key] ?? fallback)
88
+ if (!(/** @type {readonly string[]} */ (ENUMS[key])).includes(value)) { problems.push(`${key} ${value} is not one of ${ENUMS[key].join(', ')}`); return fallback }
89
+ return value
90
+ }
91
+ const label = typeof given.label === 'string' || (given.label && typeof given.label === 'object') ? given.label : undefined
92
+ const words = (/** @type {any} */ v) => (typeof v === 'string' ? v.slice(0, 300) : undefined)
93
+ return {
94
+ layout: /** @type {Layout} */ ({
95
+ label, intent: words(given.intent), use_when: words(given.use_when), avoid_when: words(given.avoid_when), slots, budgets, areas: rows, columns, rows: rowTracks,
96
+ align: pick('align', 'start'), justify: pick('justify', 'start'), paper: pick('paper', 'paper'), title: pick('title', 'normal'),
97
+ pptx: PPTX.includes(given.pptx) ? given.pptx : slots.length > 1 ? 'Comparison' : 'Title and Content', numbered: given.numbered !== false,
98
+ }),
99
+ problems,
100
+ }
101
+ }
102
+
103
+ /** A slot's budget, its numbers only. @param {Record<string, any>} given @returns {Budget} */
104
+ function clean(given) {
105
+ /** @type {Record<string, any>} */
106
+ const kept = {}
107
+ for (const key of ['max_words', 'max_chars', 'item_max_words', 'heading_max_words']) if (Number.isInteger(given[key]) && given[key] > 0) kept[key] = given[key]
108
+ if (Array.isArray(given.items) && given.items.length === 2 && given.items.every(Number.isInteger)) kept.items = given.items
109
+ if (typeof given.required === 'boolean') kept.required = given.required
110
+ if (typeof given.kind === 'string' && /^[a-z-]{1,20}$/.test(given.kind)) kept.kind = given.kind
111
+ return kept
112
+ }
113
+
114
+ /**
115
+ * Design tokens as the W3C's Design Tokens format (DTCG 2025.10) writes them, read into a kit's:
116
+ * `color.light` and `color.dark` groups (bg, surface, fg, muted, accent) or flat colours, `font.sans`
117
+ * and the like; each `$value` a hex string or a colour object with its `hex`. Tokens already flat
118
+ * pass through. @param {any} given @returns {Record<string, any>}
119
+ */
120
+ export function tokensFrom(given) {
121
+ if (!given || typeof given !== 'object') return {}
122
+ if (!JSON.stringify(given).includes('"$value"')) return given
123
+ const value = (/** @type {any} */ t) => (t && typeof t === 'object' && '$value' in t ? t.$value : t)
124
+ const colour = (/** @type {any} */ t) => { const v = value(t); return typeof v === 'string' ? v : typeof v?.hex === 'string' ? v.hex : undefined }
125
+ const face = (/** @type {any} */ t) => { const v = value(t); return Array.isArray(v) ? v.map(String) : typeof v === 'string' ? [v] : undefined }
126
+ const NAMES = /** @type {const} */ ({ bg: 'paper', background: 'paper', paper: 'paper', fg: 'ink', text: 'ink', ink: 'ink', accent: 'primary', primary: 'primary', muted: 'muted', surface: 'soft', soft: 'soft' })
127
+ const group = (/** @type {any} */ g) => Object.fromEntries(Object.entries(g ?? {}).filter(([k]) => k in NAMES).map(([k, t]) => [NAMES[/** @type {keyof typeof NAMES} */ (k)], colour(t)]).filter(([, v]) => v))
128
+ const colours = given.color ?? given.colour ?? {}
129
+ /** @type {Record<string, any>} */
130
+ const tokens = { ...group(colours), ...group(colours.light) }
131
+ const dark = group(colours.dark)
132
+ if (Object.keys(dark).length) tokens.dark = dark
133
+ const fonts = given.font ?? given.fontFamily ?? {}
134
+ const heading = face(fonts.heading ?? fonts.display ?? fonts.sans), body = face(fonts.body ?? fonts.text ?? fonts.sans)
135
+ if (heading || body) tokens.fonts = { heading: heading ?? body, body: body ?? heading }
136
+ return tokens
137
+ }
138
+
139
+ /**
140
+ * Kits merged, the later over the earlier: their tokens, and their layouts by name, each checked.
141
+ * @param {...any} kits @returns {{ kit: Kit, problems: string[] }}
142
+ */
143
+ export function mergeKits(...kits) {
144
+ /** @type {Kit} */
145
+ const kit = { name: 'standard', tokens: {}, layouts: { ...STANDARD.layouts } }
146
+ const problems = []
147
+ for (const given of kits) {
148
+ if (!given || typeof given !== 'object') continue
149
+ if (typeof given.name === 'string') kit.name = given.name
150
+ if (given.tokens && typeof given.tokens === 'object') kit.tokens = { ...kit.tokens, ...tokensFrom(given.tokens) }
151
+ for (const [name, definition] of Object.entries(given.layouts ?? given.types ?? {})) {
152
+ if (!NAME.test(name)) { problems.push(`layout ${name}: not a-z, 0-9 and -`); continue }
153
+ const { layout, problems: found } = layoutOf(definition)
154
+ problems.push(...found.map((p) => `layout ${name}: ${p}`))
155
+ if (layout) kit.layouts[name] = layout
156
+ }
157
+ }
158
+ return { kit, problems }
159
+ }
160
+
161
+ /** A layout's name in a language, else its own name. @param {Layout} layout @param {string} name @param {string} lang */
162
+ export function labelOf(layout, name, lang) {
163
+ if (typeof layout.label === 'string') return layout.label
164
+ return layout.label?.[lang] ?? layout.label?.[lang.split('-')[0]] ?? layout.label?.en ?? name
165
+ }
166
+
167
+ /**
168
+ * The layout a slide that names none is given, from what it holds, as Pandoc's PowerPoint writer
169
+ * chooses: the first slide is the cover; a heading alone, a section; slots beyond the first, the
170
+ * layout of two (or three) columns that has them; a number alone as its heading, a fact; a quote
171
+ * alone, a quote; else the default. @param {{ index: number, slots: { name: string, text: string }[] }} slide
172
+ */
173
+ export function inferLayout(slide) {
174
+ const names = slide.slots.map((slot) => slot.name)
175
+ if (names.includes('middle')) return 'three-cols'
176
+ if (names.includes('left')) return 'two-cols-header'
177
+ if (names.includes('right')) return 'two-cols'
178
+ if (slide.index === 0) return 'cover'
179
+ const text = slide.slots.map((slot) => slot.text).join('\n').trim()
180
+ const lines = text.split('\n').filter((line) => line.trim())
181
+ if (lines.length === 1 && /^#{1,2}\s+\S/.test(lines[0])) return 'section'
182
+ if (/^#\s+[^\s]*\d[\d\s.,%€$£¥]*\S*\s*$/u.test(lines[0] ?? '') && lines.length <= 3 && !/^\s*[-*\d]+[.)]?\s/m.test(lines.slice(1).join('\n'))) return 'fact'
183
+ if (lines.length && lines.every((line, i) => line.startsWith('>') || (i === lines.length - 1 && !line.startsWith('#')))) return lines[0].startsWith('>') ? 'quote' : 'default'
184
+ return 'default'
185
+ }
package/src/look.js ADDED
@@ -0,0 +1,124 @@
1
+ // A deck's look: a design system's few tokens (paper, ink, primary, a voice for its type), from a
2
+ // named look, then the project's, then the deck's own `themeConfig`, as Slidev names a theme's
3
+ // settings. Light and dark are both worked out from them: each colour kept at its hue, deepened or
4
+ // lightened until it reads at AA on the paper it sits on (4.5:1 for words, 3:1 for a chart's
5
+ // marks), so a brain's or a person's colours can't make a slide unreadable. Pure.
6
+
7
+ /** @typedef {{ paper: string, ink: string, primary: string, voice: string, radius: number, muted?: string, soft?: string, dark?: Record<string, string>, fonts?: { heading: string[], body: string[] } }} Tokens */
8
+ /** @typedef {{ paper: string, ink: string, muted: string, line: string, primary: string, onPrimary: string, soft: string, series: string[] }} Scheme */
9
+
10
+ /** Looks a deck can name (`theme:`), each a handful of tokens. */
11
+ export const LOOKS = /** @type {Record<string, Tokens>} */ ({
12
+ default: { paper: '#fffdf8', ink: '#1d1b16', primary: '#f7c531', voice: 'calm', radius: 10 },
13
+ ocean: { paper: '#f6fafb', ink: '#0d2530', primary: '#0f766e', voice: 'calm', radius: 6 },
14
+ ember: { paper: '#fff8f3', ink: '#2a160c', primary: '#d9480f', voice: 'bold', radius: 2 },
15
+ ink: { paper: '#ffffff', ink: '#111111', primary: '#3b5bdb', voice: 'elegant', radius: 0 },
16
+ })
17
+
18
+ /** Type for each voice: the device's faces, so a deck needs nothing to download to look right. */
19
+ export const VOICES = /** @type {Record<string, { heading: string, body: string }>} */ ({
20
+ calm: { heading: 'ui-sans-serif, system-ui, "Segoe UI", Roboto, "Noto Sans", sans-serif', body: 'ui-sans-serif, system-ui, "Segoe UI", Roboto, "Noto Sans", sans-serif' },
21
+ elegant: { heading: 'ui-serif, "Iowan Old Style", Georgia, "Noto Serif", serif', body: 'ui-sans-serif, system-ui, "Segoe UI", Roboto, sans-serif' },
22
+ bold: { heading: '"Avenir Next Condensed", "Arial Narrow", ui-sans-serif, system-ui, sans-serif', body: 'ui-sans-serif, system-ui, "Segoe UI", Roboto, sans-serif' },
23
+ friendly: { heading: 'ui-rounded, "SF Pro Rounded", "Nunito", system-ui, sans-serif', body: 'ui-rounded, "SF Pro Rounded", system-ui, sans-serif' },
24
+ })
25
+
26
+ /**
27
+ * The tokens a deck's settings give, over the project's look and a named one.
28
+ * @param {Record<string, any>} settings @param {Partial<Tokens>} [project]
29
+ * @returns {Tokens}
30
+ */
31
+ export function tokensOf(settings, project = {}) {
32
+ const named = LOOKS[String(settings.theme ?? '')] ?? LOOKS.default
33
+ const own = typeof settings.themeConfig === 'object' && settings.themeConfig ? settings.themeConfig : {}
34
+ /** @type {Tokens} */
35
+ const tokens = { ...named, ...clean(project), ...clean(own) }
36
+ if (!VOICES[tokens.voice]) tokens.voice = 'calm'
37
+ return tokens
38
+ }
39
+
40
+ /** @param {Record<string, any>} given @returns {Partial<Tokens>} */
41
+ function clean(given) {
42
+ /** @type {Partial<Tokens>} */
43
+ const kept = {}
44
+ for (const key of /** @type {const} */ (['paper', 'ink', 'primary', 'muted', 'soft'])) if (typeof given[key] === 'string' && hex(given[key])) kept[key] = given[key]
45
+ if (given.dark && typeof given.dark === 'object') kept.dark = Object.fromEntries(Object.entries(given.dark).filter(([, v]) => typeof v === 'string' && hex(v)))
46
+ if (given.fonts && Array.isArray(given.fonts.heading) && Array.isArray(given.fonts.body)) kept.fonts = { heading: given.fonts.heading.map(String), body: given.fonts.body.map(String) }
47
+ if (typeof given.voice === 'string') kept.voice = given.voice
48
+ if (typeof given.radius === 'number') kept.radius = given.radius
49
+ return kept
50
+ }
51
+
52
+ /**
53
+ * Light or dark, worked out from the tokens: dark swaps paper and ink and lifts the primary.
54
+ * @param {Tokens} tokens @param {'light' | 'dark'} scheme @returns {Scheme}
55
+ */
56
+ export function schemeOf(tokens, scheme) {
57
+ const light = hex(tokens.paper) ?? [255, 253, 248], dark = hex(tokens.ink) ?? [29, 27, 22]
58
+ // A kit's own dark colours where it gives them (a design system's dark mode), else worked out.
59
+ const own = scheme === 'dark' ? tokens.dark ?? {} : {}
60
+ const paper = round(hex(own.paper ?? '') ?? (scheme === 'dark' ? mix(dark, [0, 0, 0], 0.35) : light))
61
+ const ink = round(hex(own.ink ?? '') ?? (scheme === 'dark' ? mix(light, [255, 255, 255], 0.2) : dark))
62
+ const primary = hex(own.primary ?? tokens.primary) ?? [247, 197, 49]
63
+ // The primary as a fill keeps its own colour; as words or marks it is made to read on the paper.
64
+ const text = readable(primary, paper, 4.5)
65
+ const onPrimary = contrast(ink, primary) >= contrast(paper, primary) ? ink : paper
66
+ const series = [0, 1, 2, 3, 4, 5].map((n) => readable(rotate(primary, n * 57), paper, 3))
67
+ return {
68
+ paper: css(paper), ink: css(ink), muted: css(readable(round(hex(own.muted ?? (scheme === 'light' ? tokens.muted ?? '' : '')) ?? mix(ink, paper, 0.38)), paper, 4.5)), line: css(mix(ink, paper, 0.82)),
69
+ primary: css(text), onPrimary: css(onPrimary), soft: css(hex(own.soft ?? (scheme === 'light' ? tokens.soft ?? '' : '')) ?? mix(primary, paper, 0.78)), series: series.map(css),
70
+ }
71
+ }
72
+
73
+ /** @param {string} value @returns {[number, number, number] | null} */
74
+ export function hex(value) {
75
+ const m = /^#?([\da-f]{3}|[\da-f]{6})$/i.exec(String(value).trim())
76
+ if (!m) return null
77
+ const h = m[1].length === 3 ? [...m[1]].map((c) => c + c).join('') : m[1]
78
+ return /** @type {[number, number, number]} */ ([0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)))
79
+ }
80
+
81
+ /** @param {number[]} rgb */
82
+ export const css = (rgb) => `#${rgb.map((c) => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, '0')).join('')}`
83
+
84
+ /** A colour as it will be written: whole numbers, so that it reads at the ratio checked. @param {number[]} c */
85
+ const round = (c) => /** @type {[number, number, number]} */ (c.map((v) => Math.round(Math.max(0, Math.min(255, v)))))
86
+
87
+ /** @param {number[]} a @param {number[]} b @param {number} t */
88
+ const mix = (a, b, t) => /** @type {[number, number, number]} */ (a.map((c, i) => c + (b[i] - c) * t))
89
+
90
+ /** @param {number[]} rgb */
91
+ function luminance(rgb) {
92
+ const [r, g, b] = rgb.map((c) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 })
93
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
94
+ }
95
+
96
+ /** WCAG's contrast ratio. @param {number[]} a @param {number[]} b */
97
+ export function contrast(a, b) {
98
+ const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p)
99
+ return (x + 0.05) / (y + 0.05)
100
+ }
101
+
102
+ /**
103
+ * The colour, its hue kept, deepened or lightened toward black or white until it reads at the
104
+ * ratio on the paper. @param {number[]} colour @param {number[]} paper @param {number} ratio
105
+ */
106
+ export function readable(colour, paper, ratio) {
107
+ const toward = luminance(paper) > 0.18 ? [0, 0, 0] : [255, 255, 255]
108
+ let t = 0, out = round(colour)
109
+ while (contrast(out, paper) < ratio && t < 1) out = round(mix(colour, toward, (t = Math.min(1, t + 0.02))))
110
+ return out
111
+ }
112
+
113
+ /** The colour turned round the colour wheel. @param {number[]} rgb @param {number} degrees */
114
+ function rotate(rgb, degrees) {
115
+ const [r, g, b] = rgb.map((c) => c / 255)
116
+ const max = Math.max(r, g, b), min = Math.min(r, g, b), l = (max + min) / 2
117
+ const d = max - min
118
+ const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1))
119
+ let h = d === 0 ? 0 : max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4
120
+ h = (h * 60 + degrees + 360) % 360
121
+ const c = (1 - Math.abs(2 * l - 1)) * Math.max(s, 0.55), x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = l - c / 2
122
+ const [p, q, u] = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x] : h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x]
123
+ return [p + m, q + m, u + m].map((v) => v * 255)
124
+ }
package/src/parse.js ADDED
@@ -0,0 +1,180 @@
1
+ // A deck's Markdown read as Slidev reads it (@slidev/parser's rules): slides between `---` lines;
2
+ // a `---` line followed by a line that isn't empty opens that slide's settings, YAML up to the next
3
+ // `---` line (or a ```yaml block at the slide's top); the first slide's settings are the deck's
4
+ // too (its headmatter); the last HTML comment at a slide's end is the speaker's notes; `::name::`
5
+ // on its own line starts a layout's slot. Fenced code and comments never split a slide. Every slide
6
+ // and slot keeps where it starts in the source, so an edit made on a slide goes back to its words.
7
+ // Read too, as brains write them even when asked for Slidev: Pandoc's and Quarto's fenced divs,
8
+ // `::: notes` as the notes, and `:::: columns` holding `::: column`s as slots (what is above them
9
+ // the heading, each column a slot, what is under them the foot), never rewritten.
10
+ // Pure: no DOM, no Markdown parser.
11
+ import { parseYaml } from './yaml.js'
12
+
13
+ /** @typedef {{ name: string, text: string, start: number }} Slot */
14
+ /** @typedef {{ index: number, start: number, end: number, settings: Record<string, any>, slots: Slot[], notes: string, title: string }} Slide */
15
+ /** @typedef {{ settings: Record<string, any>, slides: Slide[] }} Deck */
16
+
17
+ const SLOT = /^::([\w-]+)::\s*$/
18
+
19
+ /** @param {string} source @returns {Deck} */
20
+ export function parseDeck(source) {
21
+ const text = source.replace(/\r\n?/g, '\n')
22
+ const lines = text.split('\n')
23
+ const offsets = [0]
24
+ for (const line of lines) offsets.push(/** @type {number} */ (offsets.at(-1)) + line.length + 1)
25
+ /** @type {{ start: number, end: number, matter: [number, number] | null }[]} */
26
+ const ranges = []
27
+ let start = 0, matter = /** @type {[number, number] | null} */ (null), comment = false
28
+ for (let i = 0; i < lines.length; i++) {
29
+ const line = lines[i].trimEnd()
30
+ if (comment) { comment = commentGoesOn(line, true); continue }
31
+ if (line.startsWith('---') && !/^---\s*-/.test(line)) {
32
+ if (i > start || matter) ranges.push({ start, end: i, matter })
33
+ matter = null
34
+ start = i + 1
35
+ // Settings only when settings follow (a `key:` line), not words: Slidev would take a heading
36
+ // there for settings and lose the slide, as decks brains write often start `---`, `# Title`.
37
+ if (line[3] !== '-' && lines[i + 1]?.trim() && /^\s*([\w-]+\s*:|#\s)/.test(lines[i + 1]) && !/^#{1,6}\s/.test(lines[i + 1])) {
38
+ let j = i + 1
39
+ while (j < lines.length && lines[j].trimEnd() !== '---') j++
40
+ matter = [i + 1, j]
41
+ i = j
42
+ start = j + 1
43
+ }
44
+ } else if (/^\s*(`{3,}|~{3,})/.test(line)) {
45
+ const fence = /** @type {RegExpExecArray} */ (/^\s*(`{3,}|~{3,})/.exec(line))[1]
46
+ let j = i + 1
47
+ while (j < lines.length && !lines[j].trimStart().startsWith(fence)) j++
48
+ if (j < lines.length) i = j
49
+ } else comment = commentGoesOn(line, false)
50
+ }
51
+ if (start < lines.length || matter) ranges.push({ start, end: lines.length, matter })
52
+ // No `---` at all, as a document or a Pandoc deck is written: a slide at each heading of the first
53
+ // two levels, as Pandoc's slide level does, the headmatter staying with the first.
54
+ const last = /** @type {{ start: number, end: number, matter: [number, number] | null }} */ (ranges.at(-1))
55
+ if (ranges.length === 1 && last) {
56
+ let fence = ''
57
+ const heads = []
58
+ for (let i = last.start; i < last.end; i++) {
59
+ const mark = /^\s*(`{3,}|~{3,})/.exec(lines[i])?.[1]
60
+ if (fence) { if (mark && lines[i].trimStart().startsWith(fence)) fence = '' }
61
+ else if (mark) fence = mark
62
+ else if (/^#{1,2}\s+\S/.test(lines[i])) heads.push(i)
63
+ }
64
+ if (heads.length >= 2) {
65
+ ranges.pop()
66
+ const cuts = heads[0] > last.start && !lines.slice(last.start, heads[0]).some((line) => line.trim()) ? heads : [last.start, ...heads.slice(heads[0] === last.start ? 1 : 0)]
67
+ cuts.forEach((at, k) => ranges.push({ start: k === 0 ? last.start : at, end: cuts[k + 1] ?? last.end, matter: k === 0 ? last.matter : null }))
68
+ }
69
+ }
70
+ const slides = ranges.map(({ start, end, matter }, index) => slide(lines, offsets, start, end, matter, index))
71
+ .filter((one, i) => i === 0 || one.slots.some((slot) => slot.text.trim()) || Object.keys(one.settings).length)
72
+ slides.forEach((one, i) => { one.index = i })
73
+ return { settings: slides[0]?.settings ?? {}, slides }
74
+ }
75
+
76
+ /**
77
+ * Pandoc's columns, as the lines where each slot starts: the first column `left`, the next `right`
78
+ * (or `middle`, then `right`, for three), the line after the columns `bottom`. The fences' own lines
79
+ * are left as they are and drawn as nothing. @param {string[]} lines @returns {Map<number, string>}
80
+ */
81
+ function columnsOf(lines) {
82
+ /** @type {Map<number, string>} */
83
+ const starts = new Map()
84
+ const open = lines.findIndex((line) => /^:{3,}[ \t]*\{?[ \t]*\.?columns\b/.test(line))
85
+ if (open < 0) return starts
86
+ const fence = /** @type {RegExpExecArray} */ (/^:+/.exec(lines[open]))[0]
87
+ const close = lines.findIndex((line, i) => i > open && line.trim() === fence)
88
+ const cols = lines.map((line, i) => [line, i]).filter(([line, i]) => Number(i) > open && (close < 0 || Number(i) < close) && /^:{3,}[ \t]*\{?[ \t]*\.?col(umn)?\b/.test(String(line))).map(([, i]) => Number(i))
89
+ if (cols.length < 2) return starts
90
+ const names = cols.length === 2 ? ['left', 'right'] : ['left', 'middle', 'right', ...cols.slice(3).map((_, k) => `col-${k + 4}`)]
91
+ cols.forEach((at, k) => starts.set(at, names[k]))
92
+ if (close > 0 && lines.slice(close + 1).some((line) => line.trim())) starts.set(close, 'bottom')
93
+ return starts
94
+ }
95
+
96
+ /** Whether a comment is still open at this line's end. @param {string} line @param {boolean} open */
97
+ function commentGoesOn(line, open) {
98
+ let at = 0
99
+ for (;;) {
100
+ if (open) {
101
+ const end = line.indexOf('-->', at)
102
+ if (end < 0) return true
103
+ open = false
104
+ at = end + 3
105
+ } else {
106
+ const begin = line.indexOf('<!--', at)
107
+ if (begin < 0) return false
108
+ const end = line.indexOf('-->', begin + 4)
109
+ if (end < 0) return true
110
+ at = end + 3
111
+ }
112
+ }
113
+ }
114
+
115
+ /**
116
+ * @param {string[]} lines @param {number[]} offsets @param {number} from @param {number} to
117
+ * @param {[number, number] | null} matter @param {number} index @returns {Slide}
118
+ */
119
+ function slide(lines, offsets, from, to, matter, index) {
120
+ let settings = matter ? parseYaml(lines.slice(matter[0], matter[1]).join('\n')) : {}
121
+ let top = from
122
+ // Slidev's other way to write settings: a ```yaml block at the slide's top.
123
+ while (top < to && !lines[top].trim()) top++
124
+ if (!matter && /^```ya?ml\s*$/.test(lines[top] ?? '')) {
125
+ let j = top + 1
126
+ while (j < to && !lines[j].startsWith('```')) j++
127
+ settings = parseYaml(lines.slice(top + 1, j).join('\n'))
128
+ from = j + 1
129
+ }
130
+ let body = lines.slice(from, to).join('\n')
131
+ let notes = ''
132
+ // remark's `???` and reveal.js's `Note:`: what follows, to the slide's end, is the notes.
133
+ const after = /^(\?\?\?|Notes?:)[ \t]*(.*)$/m.exec(body)
134
+ if (after && after.index !== undefined && !/^```/m.test(body.slice(after.index))) {
135
+ notes = [after[2], body.slice(after.index + after[0].length)].join('\n').trim()
136
+ body = body.slice(0, after.index)
137
+ }
138
+ // reveal.js's <aside class="notes">, wherever on the slide, as Pandoc's below.
139
+ body = body.replace(/<aside\b[^>]*class=["']?notes["']?[^>]*>([\s\S]*?)<\/aside>/gi, (all, text) => {
140
+ notes += (notes ? '\n' : '') + text.replace(/<[^>]*>/g, ' ').replace(/[ \t]+/g, ' ').trim()
141
+ return all.replace(/[^\n]/g, ' ')
142
+ })
143
+ // Pandoc's notes, wherever on the slide: kept as the notes, blanked in place so offsets stay.
144
+ body = body.replace(/^(:{3,})[ \t]*\{?[ \t]*\.?notes[ \t]*\}?[ \t]*\n([\s\S]*?)\n\1[ \t]*$/gm, (all, _, text) => {
145
+ notes += (notes ? '\n' : '') + text.trim()
146
+ return all.replace(/[^\n]/g, ' ')
147
+ })
148
+ const comments = [...body.matchAll(/<!--([\s\S]*?)-->/g)]
149
+ const last = comments.at(-1)
150
+ if (last && last.index !== undefined && !body.slice(last.index + last[0].length).trim()) {
151
+ notes = [last[1].trim(), notes].filter(Boolean).join('\n')
152
+ body = body.slice(0, last.index)
153
+ }
154
+ // Each slot's text is the source's own, from where it starts, so offsets in it are the source's.
155
+ /** @type {Slot[]} */
156
+ const slots = []
157
+ let name = 'default', first = from, fence = ''
158
+ /** @type {string[]} */
159
+ let taken = []
160
+ const push = () => slots.push({ name, text: taken.join('\n'), start: offsets[first] })
161
+ const columns = columnsOf(body.split('\n'))
162
+ for (const [i, line] of body.split('\n').entries()) {
163
+ const mark = /^\s*(`{3,}|~{3,})/.exec(line)?.[1]
164
+ const marker = SLOT.exec(line) ?? (columns.has(i) ? [line, /** @type {string} */ (columns.get(i))] : null)
165
+ if (fence) { if (mark && line.trimStart().startsWith(fence)) fence = '' }
166
+ else if (mark) fence = mark
167
+ else if (marker) {
168
+ push()
169
+ name = marker[1]
170
+ taken = []
171
+ first = from + i + 1
172
+ continue
173
+ }
174
+ taken.push(line)
175
+ }
176
+ push()
177
+ const kept = slots.filter((slot, i) => i > 0 || slot.text.trim() || slots.length === 1)
178
+ const heading = /^#{1,6}\s+(.+)$/m.exec(body.replace(/^```[\s\S]*?^```/gm, ''))
179
+ return { index, start: offsets[matter ? matter[0] - 1 : from], end: offsets[to] - 1, settings, slots: kept, notes, title: String(settings.title ?? heading?.[1] ?? '').trim() }
180
+ }
@@ -0,0 +1,47 @@
1
+ // Pictures held inside a deck, for a file that leaves its host: each picture the deck names by an
2
+ // address of the host's own (a project's file, a relative path) becomes a data: address holding the
3
+ // picture itself, in the Markdown's images (`![words](place)`) and in a slide's settings (`image`,
4
+ // `background`, `backgroundImage`). Pictures on the web stay links unless asked for, and nothing else
5
+ // in the text moves. Pure but for the host's `read`, which gives a picture's bytes and type.
6
+
7
+ const SETTINGS = /^(\s*(?:image|background|backgroundImage)\s*:\s*)(['"]?)([^'"\n#]+?)\2(\s*(?:#.*)?)$/gm
8
+ const IMAGE = /(!\[[^\]\n]*\]\()(<[^>\n]+>|[^\s)\n]+)((?:\s+(?:"[^"\n]*"|'[^'\n]*'))?\))/g
9
+
10
+ /** @param {string} place */
11
+ const held = (place) => /^data:/i.test(place)
12
+ /** @param {string} place */
13
+ const web = (place) => /^(https?:)?\/\//i.test(place)
14
+
15
+ /** @param {Uint8Array} bytes */
16
+ function base64(bytes) {
17
+ let text = ''
18
+ for (let i = 0; i < bytes.length; i += 0x8000) text += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
19
+ return btoa(text)
20
+ }
21
+
22
+ /**
23
+ * The deck with its pictures inside it.
24
+ * @param {string} source
25
+ * @param {(place: string) => Promise<{ type: string, bytes: Uint8Array } | null>} read
26
+ * @param {{ web?: boolean, largest?: number }} [options] `web`: pictures on the web too; `largest`: bytes past which a picture stays a link
27
+ * @returns {Promise<{ source: string, inlined: string[], missing: string[], kept: string[] }>}
28
+ */
29
+ export async function inlinePictures(source, read, { web: fromWeb = false, largest = 8 * 1024 * 1024 } = {}) {
30
+ /** @type {Map<string, string | null>} */
31
+ const found = new Map()
32
+ const places = [...source.matchAll(IMAGE)].map((m) => m[2].replace(/^<|>$/g, '')).concat([...source.matchAll(SETTINGS)].map((m) => m[3].trim()))
33
+ const inlined = [], missing = [], kept = []
34
+ for (const place of new Set(places)) {
35
+ if (held(place) || (web(place) && !fromWeb) || /^(#|mailto:|javascript:)/i.test(place)) continue
36
+ const picture = await read(place).catch(() => null)
37
+ if (!picture || !/^image\/(png|jpeg|gif|webp|avif|svg\+xml)$/.test(picture.type)) { missing.push(place); found.set(place, null); continue }
38
+ if (picture.bytes.length > largest) { kept.push(place); found.set(place, null); continue }
39
+ found.set(place, `data:${picture.type};base64,${base64(picture.bytes)}`)
40
+ inlined.push(place)
41
+ }
42
+ const swap = (/** @type {string} */ place) => found.get(place) ?? null
43
+ const out = source
44
+ .replace(IMAGE, (all, open, place, close) => { const data = swap(place.replace(/^<|>$/g, '')); return data ? `${open}${data}${close}` : all })
45
+ .replace(SETTINGS, (all, key, quote, place, rest) => { const data = swap(place.trim()); return data ? `${key}${quote}${data}${quote}${rest}` : all })
46
+ return { source: out, inlined, missing, kept }
47
+ }