@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/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
+ }