@lincyaw/kernel 0.0.0-stage → 1.0.0-rc.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.
Files changed (128) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +32 -2
  3. package/package.json +41 -3
  4. package/src/access.ts +18 -0
  5. package/src/anchors.ts +58 -0
  6. package/src/authoring.ts +56 -0
  7. package/src/blocks.ts +19 -0
  8. package/src/checks.ts +35 -0
  9. package/src/component-code.ts +6 -0
  10. package/src/contracts/agent.ts +76 -0
  11. package/src/contracts/anchors.ts +46 -0
  12. package/src/contracts/authoring-actions.ts +60 -0
  13. package/src/contracts/authoring-clients.ts +213 -0
  14. package/src/contracts/authoring.ts +180 -0
  15. package/src/contracts/checks.ts +29 -0
  16. package/src/contracts/classroom.ts +49 -0
  17. package/src/contracts/diagnostics.ts +37 -0
  18. package/src/contracts/document-audio.ts +150 -0
  19. package/src/contracts/document-comments.ts +31 -0
  20. package/src/contracts/document-history.ts +63 -0
  21. package/src/contracts/document-image.ts +32 -0
  22. package/src/contracts/document-reads.ts +57 -0
  23. package/src/contracts/document-voice.ts +44 -0
  24. package/src/contracts/document.ts +240 -0
  25. package/src/contracts/execution.ts +118 -0
  26. package/src/contracts/locale.ts +3 -0
  27. package/src/contracts/offline.ts +2 -0
  28. package/src/contracts/operations.ts +8 -0
  29. package/src/contracts/plugin.ts +183 -0
  30. package/src/contracts/presenter-panels.ts +17 -0
  31. package/src/contracts/preview.ts +386 -0
  32. package/src/contracts/production-assets.ts +61 -0
  33. package/src/contracts/production.ts +109 -0
  34. package/src/contracts/projects.ts +59 -0
  35. package/src/contracts/server.ts +108 -0
  36. package/src/contracts/service.ts +8 -0
  37. package/src/contracts/speech-settings.ts +16 -0
  38. package/src/contracts/speech.ts +149 -0
  39. package/src/contracts/steps.ts +15 -0
  40. package/src/contracts/transcription-adoption.ts +79 -0
  41. package/src/contracts/transcription.ts +112 -0
  42. package/src/contracts/voice.ts +97 -0
  43. package/src/contracts/voiceover-execution.ts +68 -0
  44. package/src/contracts/workspace.ts +458 -0
  45. package/src/contracts.ts +35 -0
  46. package/src/diagnostics.ts +7 -0
  47. package/src/document-authoring-actions.ts +47 -0
  48. package/src/document-caption-timings.ts +64 -0
  49. package/src/document-colors.ts +25 -0
  50. package/src/document-comments.ts +118 -0
  51. package/src/document-components.ts +251 -0
  52. package/src/document-operations.ts +132 -0
  53. package/src/document-pages.ts +215 -0
  54. package/src/document-reads.ts +81 -0
  55. package/src/document-schema.ts +85 -0
  56. package/src/document-voices.ts +153 -0
  57. package/src/document.ts +252 -0
  58. package/src/durations.ts +7 -0
  59. package/src/errors.ts +15 -0
  60. package/src/events.ts +32 -0
  61. package/src/execution.ts +25 -0
  62. package/src/fingerprint.ts +31 -0
  63. package/src/follow.ts +33 -0
  64. package/src/git.ts +12 -0
  65. package/src/html.ts +41 -0
  66. package/src/index.ts +82 -0
  67. package/src/ir.ts +367 -0
  68. package/src/jupyter-kernel.ts +9 -0
  69. package/src/kernel.ts +190 -0
  70. package/src/keyboard.ts +43 -0
  71. package/src/locale.ts +13 -0
  72. package/src/lock.ts +81 -0
  73. package/src/logging.ts +11 -0
  74. package/src/lower-myst.ts +312 -0
  75. package/src/node/assets.ts +23 -0
  76. package/src/node/authoring-actions.ts +50 -0
  77. package/src/node/authoring-clients.ts +14 -0
  78. package/src/node/document-audio.ts +22 -0
  79. package/src/node/document-history.ts +11 -0
  80. package/src/node/document-image.ts +8 -0
  81. package/src/node/document-reads.ts +10 -0
  82. package/src/node/document-voice.ts +7 -0
  83. package/src/node/ffmpeg.ts +29 -0
  84. package/src/node/file-version.ts +6 -0
  85. package/src/node/fonts.ts +22 -0
  86. package/src/node/index.ts +29 -0
  87. package/src/node/installed-packages.ts +11 -0
  88. package/src/node/lines.ts +24 -0
  89. package/src/node/locale.ts +7 -0
  90. package/src/node/network.ts +11 -0
  91. package/src/node/operator-speech.ts +91 -0
  92. package/src/node/packages.ts +74 -0
  93. package/src/node/paths.ts +7 -0
  94. package/src/node/projects.ts +7 -0
  95. package/src/node/resources.ts +86 -0
  96. package/src/node/revision.ts +6 -0
  97. package/src/node/run-directory.ts +70 -0
  98. package/src/node/strip-types.mjs +15 -0
  99. package/src/node/transcription-adoption.ts +9 -0
  100. package/src/node/transcription.ts +10 -0
  101. package/src/node/voiceover-routes.ts +73 -0
  102. package/src/node/voiceover.ts +199 -0
  103. package/src/page-source.ts +359 -0
  104. package/src/parameters.ts +81 -0
  105. package/src/preview-edit.ts +18 -0
  106. package/src/preview-events.ts +57 -0
  107. package/src/preview-frame.ts +1220 -0
  108. package/src/props.ts +17 -0
  109. package/src/published.ts +11 -0
  110. package/src/push-stream.ts +67 -0
  111. package/src/reading.ts +96 -0
  112. package/src/recording.ts +38 -0
  113. package/src/renderer-components.ts +30 -0
  114. package/src/requests.ts +28 -0
  115. package/src/responses.ts +49 -0
  116. package/src/run-outputs.ts +15 -0
  117. package/src/runner.py +454 -0
  118. package/src/services.ts +146 -0
  119. package/src/slides.ts +36 -0
  120. package/src/speech-words.ts +76 -0
  121. package/src/speech.ts +13 -0
  122. package/src/step-keys.ts +38 -0
  123. package/src/steps.ts +7 -0
  124. package/src/transcription-adoption.ts +105 -0
  125. package/src/uri-decoding.ts +8 -0
  126. package/src/urls.ts +13 -0
  127. package/src/views.ts +47 -0
  128. package/src/voice.ts +86 -0
package/src/lock.ts ADDED
@@ -0,0 +1,81 @@
1
+ import { isRenderable } from './anchors.ts'
2
+ import type { AnchorLockFile, RecordedAnchor, Resolution } from './contracts.ts'
3
+ import { errorMessage, isRecord } from './errors.ts'
4
+
5
+ export const ANCHOR_LOCK_PATH = '.presmium/anchors.lock.json'
6
+
7
+ export class AnchorLock {
8
+ #anchors = new Map<string, RecordedAnchor>()
9
+
10
+ private constructor(anchors: Iterable<[string, RecordedAnchor]>) {
11
+ for (const [anchor, recorded] of anchors) this.set(anchor, recorded)
12
+ }
13
+
14
+ static empty(): AnchorLock {
15
+ return new AnchorLock([])
16
+ }
17
+
18
+ static from(file: AnchorLockFile): AnchorLock {
19
+ return new AnchorLock(Object.entries(file.anchors))
20
+ }
21
+
22
+ static parse(text: string | undefined): AnchorLock {
23
+ if (text === undefined || text.trim() === '') return AnchorLock.empty()
24
+ let data: unknown
25
+ try {
26
+ data = JSON.parse(text)
27
+ } catch (error) {
28
+ throw new Error(`invalid anchor lock: ${errorMessage(error)}`)
29
+ }
30
+ return AnchorLock.from(validate(data))
31
+ }
32
+
33
+ get size(): number {
34
+ return this.#anchors.size
35
+ }
36
+
37
+ get(anchor: string): RecordedAnchor | undefined {
38
+ return this.#anchors.get(anchor)
39
+ }
40
+
41
+ set(anchor: string, recorded: RecordedAnchor): void {
42
+ this.#anchors.set(anchor, { fingerprint: recorded.fingerprint, file: recorded.file, startLine: recorded.startLine })
43
+ }
44
+
45
+ record(anchor: string, resolution: Resolution): boolean {
46
+ const { location, fingerprint } = resolution
47
+ if (!isRenderable(resolution) || !location || !fingerprint) return false
48
+ this.set(anchor, { fingerprint, file: location.file, startLine: location.startLine })
49
+ return true
50
+ }
51
+
52
+ retain(anchors: Iterable<string>): void {
53
+ const keep = new Set(anchors)
54
+ for (const anchor of [...this.#anchors.keys()]) {
55
+ if (!keep.has(anchor)) this.#anchors.delete(anchor)
56
+ }
57
+ }
58
+
59
+ toJSON(): AnchorLockFile {
60
+ const keys = [...this.#anchors.keys()].sort()
61
+ return { version: 1, anchors: Object.fromEntries(keys.map(k => [k, this.#anchors.get(k)!])) }
62
+ }
63
+
64
+ serialize(): string {
65
+ return `${JSON.stringify(this.toJSON(), null, 2)}\n`
66
+ }
67
+ }
68
+
69
+ function validate(data: unknown): AnchorLockFile {
70
+ if (!isRecord(data) || data.version !== 1 || !isRecord(data.anchors)) {
71
+ throw new Error('invalid anchor lock: expected { "version": 1, "anchors": { ... } }')
72
+ }
73
+ for (const [anchor, record] of Object.entries(data.anchors)) {
74
+ const valid = isRecord(record)
75
+ && typeof record.fingerprint === 'string' && record.fingerprint !== ''
76
+ && typeof record.file === 'string'
77
+ && Number.isInteger(record.startLine) && (record.startLine as number) >= 1
78
+ if (!valid) throw new Error(`invalid anchor lock: bad record for "${anchor}"`)
79
+ }
80
+ return data as unknown as AnchorLockFile
81
+ }
package/src/logging.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { Logger } from './contracts.ts'
2
+
3
+ export function prefixedLogger(log: Logger, id: string): Logger {
4
+ const tag = `[${id}]`
5
+ return {
6
+ debug: (...args) => log.debug(tag, ...args),
7
+ info: (...args) => log.info(tag, ...args),
8
+ warn: (...args) => log.warn(tag, ...args),
9
+ error: (...args) => log.error(tag, ...args),
10
+ }
11
+ }
@@ -0,0 +1,312 @@
1
+ import { escapeHtml as escape, mapHtmlUrls } from './html.ts'
2
+ import type { MdastNode } from './ir.ts'
3
+
4
+ export type LinkedType = 'image' | 'link' | 'definition'
5
+
6
+ export interface LowerOptions {
7
+ links?: (label: string) => string | undefined
8
+ urls?: (url: string, type: LinkedType) => string | undefined
9
+ htmlUrls?: boolean
10
+ footnotes?: 'gfm' | 'html'
11
+ iframes?: 'element' | 'link'
12
+ roles?: 'children' | 'marks'
13
+ cards?: 'element' | 'headings'
14
+ }
15
+
16
+ export interface LoweredMarkdown {
17
+ mdast: MdastNode
18
+ degraded: string[]
19
+ }
20
+
21
+ const PHRASING = new Set([
22
+ 'text', 'emphasis', 'strong', 'delete', 'inlineCode', 'inlineMath', 'break', 'link', 'linkReference', 'image',
23
+ 'imageReference', 'footnoteReference', 'abbreviation', 'subscript', 'superscript', 'crossReference', 'cite', 'citeGroup',
24
+ 'underline', 'keyboard', 'smallcaps', 'presmiumRole',
25
+ ])
26
+
27
+ const STANDARD: Record<string, string[]> = {
28
+ root: [], paragraph: [], blockquote: [], thematicBreak: [], break: [], text: ['value'], html: ['value'],
29
+ heading: ['depth'], list: ['ordered', 'start', 'spread'], listItem: ['spread', 'checked'],
30
+ code: ['lang', 'value'], inlineCode: ['value'], emphasis: [], strong: [],
31
+ link: ['url', 'title'], image: ['url', 'alt', 'title'], definition: ['identifier', 'label', 'url', 'title'],
32
+ linkReference: ['identifier', 'label', 'referenceType'], imageReference: ['identifier', 'label', 'referenceType', 'alt'],
33
+ }
34
+
35
+ const FOOTNOTES: Record<string, string[]> = { footnoteReference: ['identifier'], footnoteDefinition: ['identifier'] }
36
+
37
+ const LINKED = new Set<string>(['image', 'link', 'definition'] satisfies LinkedType[])
38
+
39
+ const TAGS: Record<string, string> = { subscript: 'sub', superscript: 'sup', delete: 'del', underline: 'u', keyboard: 'kbd' }
40
+
41
+ const CONTENT = new Set(['image', 'table', 'code', 'iframe', 'mermaid'])
42
+
43
+ const text = (value: string): MdastNode => ({ type: 'text', value })
44
+ const html = (value: string): MdastNode => ({ type: 'html', value })
45
+ const paragraph = (children: MdastNode[]): MdastNode => ({ type: 'paragraph', children })
46
+ const capitalized = (value: string) => value.charAt(0).toUpperCase() + value.slice(1)
47
+
48
+ interface Lowering {
49
+ options: LowerOptions
50
+ degraded: Set<string>
51
+ }
52
+
53
+ const SMALL_HEADING = 4
54
+
55
+ export function lowerMyst(mdast: MdastNode, options: LowerOptions = {}): LoweredMarkdown {
56
+ const tree = JSON.parse(JSON.stringify(mdast)) as MdastNode
57
+ const ctx: Lowering = { options, degraded: new Set() }
58
+ const top = tree.type === 'root' ? paragraphs(tree.children ?? []) : [tree]
59
+ return { mdast: { type: 'root', children: top.flatMap(node => lower(node, ctx)) }, degraded: [...ctx.degraded] }
60
+ }
61
+
62
+ function plain(node: MdastNode): string {
63
+ return typeof node.value === 'string' ? node.value : (node.children ?? []).map(plain).join('')
64
+ }
65
+
66
+ function paragraphs(nodes: MdastNode[]): MdastNode[] {
67
+ const out: MdastNode[] = []
68
+ let run: MdastNode[] | undefined
69
+ for (const node of nodes) {
70
+ if (!PHRASING.has(node.type)) {
71
+ out.push(node)
72
+ run = undefined
73
+ continue
74
+ }
75
+ if (!run) out.push(paragraph(run = []))
76
+ run.push(node)
77
+ }
78
+ return out
79
+ }
80
+
81
+ function labelLink(labels: unknown[], children: MdastNode[], ctx: Lowering): MdastNode[] {
82
+ const url = labels.map(label => typeof label === 'string' && label ? ctx.options.links?.(label) : undefined).find(Boolean)
83
+ return url ? [{ type: 'link', url, children }] : children
84
+ }
85
+
86
+ function dollars(value: string): MdastNode[] {
87
+ return value.split('$').flatMap((part, i) => [...i > 0 ? [html('\\$')] : [], ...part ? [text(part)] : []])
88
+ }
89
+
90
+ function lower(node: MdastNode, ctx: Lowering): MdastNode[] {
91
+ const { options, degraded } = ctx
92
+ const all = (nodes: MdastNode[] = []) => nodes.flatMap(child => lower(child, ctx))
93
+ const children = () => all(node.children)
94
+ if (node.type === 'link' && isButton(node) && cardsAs(options) === 'element') {
95
+ return [html('<span class="pm-button">'), ...lower({ ...node, class: undefined }, ctx), html('</span>')]
96
+ }
97
+ if (node.type === 'link' && String(node.url).startsWith('#')) {
98
+ const label = String(node.url).slice(1)
99
+ return labelLink([label], node.children?.length ? children() : [text(label)], ctx)
100
+ }
101
+ if (node.type === 'text' && typeof node.value === 'string' && node.value.includes('$')) return dollars(node.value)
102
+ const fields = STANDARD[node.type] ?? (options.footnotes === 'gfm' ? FOOTNOTES[node.type] : undefined)
103
+ if (fields) {
104
+ const own: Record<string, unknown> = Object.fromEntries(fields.filter(key => node[key] != null).map(key => [key, node[key]]))
105
+ if (LINKED.has(node.type) && typeof own.url === 'string') own.url = options.urls?.(own.url, node.type as LinkedType) ?? own.url
106
+ if (node.type === 'html' && typeof own.value === 'string') own.value = rawHtml(own.value, options)
107
+ return [{ type: node.type, ...own, ...node.children ? { children: children() } : {} }]
108
+ }
109
+ switch (node.type) {
110
+ case 'math':
111
+ return [html(`$$\n${node.value ?? ''}\n$$`)]
112
+ case 'inlineMath':
113
+ return [html(`$${node.value ?? ''}$`)]
114
+ case 'admonition':
115
+ case 'aside': {
116
+ const title = node.children?.find(child => child.type === 'admonitionTitle')
117
+ const heading = title ? all(title.children) : typeof node.kind === 'string' ? [text(capitalized(node.kind))] : []
118
+ const body = all(node.children?.filter(child => child !== title))
119
+ return [{ type: 'blockquote', children: [...heading.length ? [paragraph([{ type: 'strong', children: heading }])] : [], ...body] }]
120
+ }
121
+ case 'container':
122
+ return container(node, ctx)
123
+ case 'table':
124
+ return [table(node, ctx)]
125
+ case 'details': {
126
+ const summary = node.children?.find(child => child.type === 'summary')
127
+ const body = all(node.children?.filter(child => child !== summary))
128
+ return [html(`<details>\n<summary>${escape(summary ? plain(summary) : '')}</summary>`), ...body, html('</details>')]
129
+ }
130
+ case 'definitionList':
131
+ return [definitions(node, ctx)]
132
+ case 'footnoteDefinition': {
133
+ const [first, ...rest] = children()
134
+ const mark = html(`<sup>[${escape(footnoteNumber(node))}]</sup> `)
135
+ return first?.type === 'paragraph' ? [paragraph([mark, ...first.children ?? []]), ...rest] : [paragraph([mark]), ...first ? [first] : [], ...rest]
136
+ }
137
+ case 'footnoteReference':
138
+ return [html(`<sup>[${escape(footnoteNumber(node))}]</sup>`)]
139
+ case 'crossReference':
140
+ return labelLink([node.identifier, node.label], node.children?.length ? children() : [text(String(node.label ?? node.identifier ?? ''))], ctx)
141
+ case 'abbreviation':
142
+ return [html(`<abbr title="${escape(String(node.title ?? ''))}">`), ...children(), html('</abbr>')]
143
+ case 'subscript':
144
+ case 'superscript':
145
+ case 'delete':
146
+ case 'underline':
147
+ case 'keyboard':
148
+ return [html(`<${TAGS[node.type]}>`), ...children(), html(`</${TAGS[node.type]}>`)]
149
+ case 'cite':
150
+ return [text(`[${node.label}]`)]
151
+ case 'citeGroup':
152
+ return [text(`[${(node.children ?? []).map(child => child.label).join(', ')}]`)]
153
+ case 'mermaid':
154
+ return [{ type: 'code', lang: 'mermaid', value: node.value ?? '' }]
155
+ case 'raw':
156
+ if (node.lang === 'html') return [html(rawHtml(String(node.value ?? ''), options))]
157
+ break
158
+ case 'iframe': {
159
+ const src = String(node.src ?? '')
160
+ if (options.iframes === 'element') {
161
+ const attrs = (['width', 'height'] as const).filter(key => node[key] != null && node[key] !== '').map(key => ` ${key}="${escape(String(node[key]))}"`).join('')
162
+ return [html(`<iframe src="${escape(src)}"${attrs} loading="lazy"></iframe>`)]
163
+ }
164
+ degraded.add(node.type)
165
+ return [paragraph([{ type: 'link', url: src, children: [text(src)] }])]
166
+ }
167
+ case 'presmiumRole':
168
+ if (!node.children?.length && options.roles === 'marks') return [text(roleMark(node))]
169
+ return children()
170
+ case 'grid':
171
+ case 'grid-item':
172
+ case 'card':
173
+ case 'tabSet':
174
+ case 'tabItem':
175
+ return layout(node, ctx)
176
+ case 'comment':
177
+ case 'mystTarget':
178
+ return []
179
+ case 'caption':
180
+ case 'legend':
181
+ case 'glossary':
182
+ case 'smallcaps':
183
+ case 'block':
184
+ case 'div':
185
+ case 'span':
186
+ return children()
187
+ }
188
+ degraded.add(node.type)
189
+ return node.children ? children() : typeof node.value === 'string' && node.value ? [text(node.value)] : []
190
+ }
191
+
192
+ function rawHtml(value: string, options: LowerOptions): string {
193
+ const { urls } = options
194
+ return options.htmlUrls && urls ? mapHtmlUrls(value, (url, attribute) => urls(url, attribute === 'href' ? 'link' : 'image')) : value
195
+ }
196
+
197
+ function cardsAs(options: LowerOptions): 'element' | 'headings' {
198
+ return options.cards ?? (options.iframes === 'element' ? 'element' : 'headings')
199
+ }
200
+
201
+ /** Whether a MyST node has the class `button`, which renders a link or span as a button. */
202
+ export function isButton(node: MdastNode): boolean {
203
+ return typeof node.class === 'string' && node.class.split(/\s+/).includes('button')
204
+ }
205
+
206
+ function layout(node: MdastNode, ctx: Lowering): MdastNode[] {
207
+ const all = (nodes: MdastNode[] = []) => nodes.flatMap(child => lower(child, ctx))
208
+ const element = cardsAs(ctx.options) === 'element'
209
+ const div = (className: string, content: MdastNode[], style = '') => [html(`<div class="${className}"${style}>`), ...content, html('</div>')]
210
+ const heading = (content: MdastNode[]): MdastNode => ({ type: 'heading', depth: SMALL_HEADING, children: content })
211
+ const items = node.children ?? []
212
+ switch (node.type) {
213
+ case 'grid': {
214
+ if (!element) {
215
+ ctx.degraded.add(node.type)
216
+ return all(items)
217
+ }
218
+ const columns = Array.isArray(node.columns) ? Number(node.columns.at(-1)) || 1 : 1
219
+ return div('pm-grid', all(items), ` style="--pm-grid-columns: ${columns}"`)
220
+ }
221
+ case 'grid-item':
222
+ return element ? div('pm-grid__item', all(items)) : all(items)
223
+ case 'card': {
224
+ const part = (type: string) => items.find(child => child.type === type)?.children ?? []
225
+ const title = all(part('cardTitle'))
226
+ const url = typeof node.url === 'string' && node.url ? node.url : undefined
227
+ const linked = url ? lower({ type: 'link', url, children: title.length ? title : [text(url)] }, ctx) : title
228
+ const body = all(items.filter(child => !['header', 'cardTitle', 'footer'].includes(child.type)))
229
+ if (!element) {
230
+ ctx.degraded.add(node.type)
231
+ const top = title.length ? [heading(linked)] : url ? [paragraph(linked)] : []
232
+ return [...all(part('header')), ...top, ...body, ...all(part('footer'))]
233
+ }
234
+ const header = part('header').length ? div('pm-card__header', all(part('header'))) : []
235
+ const footer = part('footer').length ? div('pm-card__footer', all(part('footer'))) : []
236
+ const top = title.length || url ? div('pm-card__title', [paragraph(linked)]) : []
237
+ return div(url ? 'pm-card pm-card--link' : 'pm-card', [...header, ...top, ...body, ...footer])
238
+ }
239
+ case 'tabSet':
240
+ if (!element) ctx.degraded.add(node.type)
241
+ return element ? div('pm-tabs', all(items)) : all(items)
242
+ case 'tabItem': {
243
+ const title = typeof node.title === 'string' ? node.title : ''
244
+ if (!element) return [...title ? [heading([text(title)])] : [], ...all(items)]
245
+ return div('pm-tabs__item', [...title ? [html(`<div class="pm-tabs__title">${escape(title)}</div>`)] : [], ...all(items)])
246
+ }
247
+ }
248
+ return all(items)
249
+ }
250
+
251
+ export function roleMark(node: MdastNode): string {
252
+ const options = typeof node.options === 'object' && node.options !== null ? node.options as Record<string, unknown> : {}
253
+ const value = typeof options.value === 'string' ? options.value.trim() : ''
254
+ return `〔${String(node.name ?? '')}${value ? ` ${value}` : ''}〕`
255
+ }
256
+
257
+ function footnoteNumber(node: MdastNode): string {
258
+ return String(node.enumerator ?? node.label ?? node.identifier)
259
+ }
260
+
261
+ function container(node: MdastNode, ctx: Lowering): MdastNode[] {
262
+ const all = (nodes: MdastNode[] = []) => nodes.flatMap(child => lower(child, ctx))
263
+ const items = node.children ?? []
264
+ const given = items.find(child => child.type === 'caption')
265
+ const derived = !given && node.kind !== 'quote'
266
+ const others = items.filter(child => !CONTENT.has(child.type))
267
+ const content = derived ? items.filter(child => CONTENT.has(child.type)) : items.filter(child => child.type !== 'caption' && child.type !== 'legend')
268
+ const caption = derived ? others.slice(0, 1) : given?.children ?? []
269
+ const legend = derived ? others.slice(1) : items.find(child => child.type === 'legend')?.children ?? []
270
+ const words = all(caption).flatMap(child => (child.type === 'paragraph' ? child.children ?? [] : [child]))
271
+ const title = words.length ? [paragraph([{ type: node.kind === 'table' ? 'strong' : 'emphasis', children: words }])] : []
272
+ const shown = all(content).map(child => (child.type === 'image' ? paragraph([child]) : child))
273
+ return node.kind === 'table' ? [...title, ...shown, ...all(legend)] : [...shown, ...title, ...all(legend)]
274
+ }
275
+
276
+ function table(node: MdastNode, ctx: Lowering): MdastNode {
277
+ const inline = (child: MdastNode) => PHRASING.has(child.type) || child.type === 'html'
278
+ const line = (nodes: MdastNode[]) => nodes.map(child => (child.type === 'break' ? html('<br>') : child))
279
+ const rows = (node.children ?? []).map(row => ({
280
+ type: 'tableRow',
281
+ children: (row.children ?? []).map((cell) => {
282
+ const parts: MdastNode[][] = []
283
+ let run: MdastNode[] | undefined
284
+ for (const child of (cell.children ?? []).flatMap(c => lower(c, ctx))) {
285
+ if (inline(child)) {
286
+ if (!run) parts.push(run = [])
287
+ run.push(...line([child]))
288
+ continue
289
+ }
290
+ run = undefined
291
+ parts.push(child.type === 'paragraph' ? line(child.children ?? []) : [text(plain(child))])
292
+ }
293
+ return { type: 'tableCell', children: parts.flatMap((part, i) => (i ? [html('<br>'), ...part] : part)) }
294
+ }),
295
+ }))
296
+ const align = Array.isArray(node.align) ? node.align : node.children?.[0]?.children?.map(cell => cell.align ?? null)
297
+ return { type: 'table', align, children: rows }
298
+ }
299
+
300
+ function definitions(node: MdastNode, ctx: Lowering): MdastNode {
301
+ const items: MdastNode[] = []
302
+ for (const child of node.children ?? []) {
303
+ if (child.type === 'definitionTerm') {
304
+ const term = (child.children ?? []).flatMap(c => lower(c, ctx))
305
+ items.push({ type: 'listItem', spread: true, children: [paragraph([{ type: 'strong', children: term }])] })
306
+ } else {
307
+ if (!items.length) items.push({ type: 'listItem', spread: true, children: [] })
308
+ items.at(-1)!.children!.push(...paragraphs(child.children ?? []).flatMap(c => lower(c, ctx)))
309
+ }
310
+ }
311
+ return { type: 'list', ordered: false, spread: true, children: items }
312
+ }
@@ -0,0 +1,23 @@
1
+ import { cpSync, existsSync, mkdirSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import type { LocalAsset } from '../ir.ts'
4
+
5
+ /** Production assets are local to one document; hidden, remote and shared paths are not admitted. */
6
+ export function productionAssetFile(deckDir: string, location: string): string | undefined {
7
+ if (!location || /^(?:[a-z][a-z\d+.-]*:|\/)/i.test(location) || /[\\\0?#]/.test(location)) return undefined
8
+ let decoded: string
9
+ try { decoded = decodeURIComponent(location) } catch { return undefined }
10
+ if (/[\\\0?#]/.test(decoded)) return undefined
11
+ const parts = decoded.split('/')
12
+ if (parts.some(part => !part || part.startsWith('.') || part === 'node_modules' || part === 'dist' || part.includes(':'))) return undefined
13
+ return resolve(deckDir, ...parts)
14
+ }
15
+
16
+ export function copyPublished(found: LocalAsset, dir: string): string {
17
+ const target = join(dir, found.name)
18
+ if (!existsSync(target)) {
19
+ mkdirSync(dir, { recursive: true })
20
+ cpSync(found.file, target, { recursive: true })
21
+ }
22
+ return target
23
+ }
@@ -0,0 +1,50 @@
1
+ import { Validator } from '@cfworker/json-schema'
2
+ import type { Schema } from '@cfworker/json-schema'
3
+ import { DOCUMENT_JSON_SCHEMA } from '../document-schema.ts'
4
+ import { DOCUMENT_PAGE_DUPLICATION_SCHEMA } from '../contracts/authoring.ts'
5
+ import { DOCUMENT_COMPONENT_IMPACT_COLLECTIONS, DOCUMENT_PARAMETER_MAX_CHANGES, DOCUMENT_PARAMETER_MAX_PATH_DEPTH } from '../contracts/authoring-actions.ts'
6
+ import type { DocumentAuthoringActionRequest, DocumentAuthoringActionReference, DocumentComponentImpactQuery } from '../contracts/authoring-actions.ts'
7
+
8
+ const id: Schema = { type: 'string', minLength: 1, maxLength: 200 }
9
+ const object = (properties: Record<string, Schema>, required = Object.keys(properties)): Schema => ({ type: 'object', additionalProperties: false, properties, required })
10
+ const action = (type: string, properties: Record<string, Schema>, required = Object.keys(properties)) => object({ type: { const: type }, ...properties }, ['type', ...required])
11
+ const content: Schema = { type: 'array', minItems: 1, maxItems: 100, items: { $ref: '#/$defs/RichTextNode' } }
12
+ const metadata: Schema = { $ref: '#/$defs/JsonObject' }
13
+ const scope: Schema = { oneOf: [object({ kind: { const: 'document' } }), object({ kind: { const: 'page' }, sectionId: id }), object({ kind: { const: 'step' }, stepId: id }), object({ kind: { const: 'steps' }, stepIds: { type: 'array', minItems: 1, maxItems: 1000, items: id } })] }
14
+ const voice: Schema = { oneOf: [{ type: 'null' }, { allOf: [{ $ref: '#/$defs/VoiceSource' }, { properties: { type: { enum: ['none', 'tts'] } } }] }] }
15
+ const parameterPath: Schema = { type: 'array', minItems: 1, maxItems: DOCUMENT_PARAMETER_MAX_PATH_DEPTH, items: { type: 'string', maxLength: 200 } }
16
+ const changes: Schema = { type: 'array', minItems: 1, maxItems: DOCUMENT_PARAMETER_MAX_CHANGES, items: { oneOf: [
17
+ object({ op: { const: 'set' }, path: parameterPath, value: { $ref: '#/$defs/JsonValue' } }),
18
+ object({ op: { const: 'remove' }, path: parameterPath }),
19
+ ] } }
20
+
21
+ /** Node-only schema reuses the actual canonical rich-text definitions, not BlockNote data. */
22
+ export const DOCUMENT_AUTHORING_ACTION_SCHEMA: Schema = {
23
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
24
+ ...object({ id, source: DOCUMENT_PAGE_DUPLICATION_SCHEMA.properties.source as unknown as Schema, action: { $ref: '#/$defs/AuthoringAction' } }),
25
+ $defs: { ...(structuredClone(DOCUMENT_JSON_SCHEMA) as Exclude<Schema, boolean>).$defs,
26
+ AuthoringAction: { oneOf: [
27
+ action('comment.create', { content, anchor: { oneOf: [{ type: 'null' }, object({ from: { $ref: '#/$defs/TextPoint' }, to: { $ref: '#/$defs/TextPoint' } })] }, metadata, commentMetadata: metadata }, ['content', 'anchor']),
28
+ action('comment.reply', { threadId: id, content, metadata }, ['threadId', 'content']),
29
+ action('comment.update', { threadId: id, commentId: id, content, metadata }, ['threadId', 'commentId', 'content']),
30
+ action('comment.react', { threadId: id, commentId: id, emoji: { type: 'string', minLength: 1, maxLength: 32 }, active: { type: 'boolean' } }),
31
+ action('comment.resolve', { threadId: id }), action('comment.reopen', { threadId: id }),
32
+ action('comment.delete', { threadId: id, commentId: id }, ['threadId']),
33
+ action('voice.set', { scope, source: voice, replaceOverrides: { type: 'boolean' } }, ['scope', 'source']),
34
+ action('caption.timing.set', { stepId: id, value: { $ref: '#/$defs/CaptionTiming' } }),
35
+ action('caption.timing.reset', { stepId: id }),
36
+ action('component.parameters.patch', { blockId: id, changes }),
37
+ action('component.step-parameters.patch', { blockId: id, stepId: id, changes }),
38
+ action('component.step-parameters.reset', { blockId: id, stepId: id }),
39
+ action('component.definition.use', { blockId: id, definitionId: id, changes: { ...changes, minItems: 0 } }, ['blockId', 'definitionId']),
40
+ ] },
41
+ },
42
+ }
43
+ export const DOCUMENT_AUTHORING_ACTION_APPLY_SCHEMA: Schema = object({ planId: id, source: DOCUMENT_PAGE_DUPLICATION_SCHEMA.properties.source as unknown as Schema, digest: { type: 'string', pattern: '^[a-f0-9]{64}$' }, confirmDeletion: { const: true } }, ['planId', 'source', 'digest'])
44
+ export const DOCUMENT_AUTHORING_ACTION_IMPACT_SCHEMA: Schema = object({ planId: id, digest: { type: 'string', pattern: '^[a-f0-9]{64}$' }, collection: { enum: [...DOCUMENT_COMPONENT_IMPACT_COLLECTIONS] }, offset: { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER } }, ['planId', 'digest', 'collection'])
45
+ const request = new Validator(structuredClone(DOCUMENT_AUTHORING_ACTION_SCHEMA), '2020-12', false)
46
+ const reference = new Validator(structuredClone(DOCUMENT_AUTHORING_ACTION_APPLY_SCHEMA), '2020-12', false)
47
+ const impact = new Validator(structuredClone(DOCUMENT_AUTHORING_ACTION_IMPACT_SCHEMA), '2020-12', false)
48
+ export function validateDocumentAuthoringAction(value: unknown): value is DocumentAuthoringActionRequest { return request.validate(value).valid }
49
+ export function validateDocumentAuthoringActionReference(value: unknown): value is DocumentAuthoringActionReference { return reference.validate(value).valid }
50
+ export function validateDocumentAuthoringActionImpact(value: unknown): value is DocumentComponentImpactQuery { return impact.validate(value).valid }
@@ -0,0 +1,14 @@
1
+ import { Validator } from '@cfworker/json-schema'
2
+ import { AUTHORING_CLIENT_ACK_SCHEMA, AUTHORING_CLIENT_CONNECTION_SCHEMA, AUTHORING_CLIENT_OPEN_SCHEMA, AUTHORING_CLIENT_QUERY_SCHEMA, AUTHORING_CLIENT_REPORT_SCHEMA, AUTHORING_CLIENT_REQUEST_QUERY_SCHEMA, AUTHORING_CLIENT_REQUEST_SCHEMA } from '../contracts/authoring-clients.ts'
3
+ import type { AuthoringClientAcknowledgement, AuthoringClientLease, AuthoringClientOpen, AuthoringClientReport, AuthoringClientRequest } from '../contracts/authoring-clients.ts'
4
+
5
+ const validator = (schema: typeof AUTHORING_CLIENT_REQUEST_SCHEMA) => new Validator(structuredClone(schema), '2020-12', false)
6
+ const request = validator(AUTHORING_CLIENT_REQUEST_SCHEMA), query = validator(AUTHORING_CLIENT_QUERY_SCHEMA), get = validator(AUTHORING_CLIENT_REQUEST_QUERY_SCHEMA)
7
+ const open = validator(AUTHORING_CLIENT_OPEN_SCHEMA), report = validator(AUTHORING_CLIENT_REPORT_SCHEMA), ack = validator(AUTHORING_CLIENT_ACK_SCHEMA), connection = validator(AUTHORING_CLIENT_CONNECTION_SCHEMA)
8
+ export const validateAuthoringClientRequest = (value: unknown): value is AuthoringClientRequest => request.validate(value).valid
9
+ export const validateAuthoringClientQuery = (value: unknown): value is { clientId?: string, after?: string } => query.validate(value).valid
10
+ export const validateAuthoringClientRequestQuery = (value: unknown): value is { clientId: string, epoch: string, requestId: string } => get.validate(value).valid
11
+ export const validateAuthoringClientOpen = (value: unknown): value is AuthoringClientOpen => open.validate(value).valid
12
+ export const validateAuthoringClientReport = (value: unknown): value is AuthoringClientReport => report.validate(value).valid
13
+ export const validateAuthoringClientAcknowledgement = (value: unknown): value is AuthoringClientAcknowledgement => ack.validate(value).valid
14
+ export const validateAuthoringClientLease = (value: unknown): value is AuthoringClientLease => connection.validate(value).valid
@@ -0,0 +1,22 @@
1
+ import { Validator } from '@cfworker/json-schema'
2
+ import type { Schema } from '@cfworker/json-schema'
3
+ import { DOCUMENT_RECORDING_ADOPTION_SCHEMA, DOCUMENT_AUDIO_INSPECTION_SCHEMA, DOCUMENT_AUDIO_WORKSPACE_IMPORT_SCHEMA, DOCUMENT_WORKSPACE_AUDIO_INSPECTION_SCHEMA, DOCUMENT_MEDIA_CLIP_VALIDATION_SCHEMA, DOCUMENT_MEDIA_CLIP_CHANGE_SCHEMA, DOCUMENT_PRODUCTION_PLAN_SCHEMA } from '../contracts/document-audio.ts'
4
+ import type { DocumentRecordingAdoption, DocumentAudioInspect, DocumentWorkspaceAudioImport, DocumentWorkspaceAudioInspect, DocumentMediaClipValidation, DocumentMediaClipEdit, DocProductionPlanQuery } from '../contracts/document-audio.ts'
5
+
6
+ const inspectValidator = new Validator(structuredClone(DOCUMENT_AUDIO_INSPECTION_SCHEMA) as unknown as Schema, '2020-12', false)
7
+ const importValidator = new Validator(structuredClone(DOCUMENT_AUDIO_WORKSPACE_IMPORT_SCHEMA) as unknown as Schema, '2020-12', false)
8
+ export const validateDocumentAudioInspect = (value: unknown): value is DocumentAudioInspect => inspectValidator.validate(value).valid
9
+ export const validateDocumentWorkspaceAudioImport = (value: unknown): value is DocumentWorkspaceAudioImport => importValidator.validate(value).valid
10
+
11
+ const workspaceInspectValidator = new Validator(structuredClone(DOCUMENT_WORKSPACE_AUDIO_INSPECTION_SCHEMA) as unknown as Schema, '2020-12', false)
12
+ export const validateDocumentWorkspaceAudioInspect = (value: unknown): value is DocumentWorkspaceAudioInspect => workspaceInspectValidator.validate(value).valid
13
+
14
+ const clipValidationValidator = new Validator(structuredClone(DOCUMENT_MEDIA_CLIP_VALIDATION_SCHEMA) as unknown as Schema, '2020-12', false)
15
+ const clipChangeValidator = new Validator(structuredClone(DOCUMENT_MEDIA_CLIP_CHANGE_SCHEMA) as unknown as Schema, '2020-12', false)
16
+ export const validateDocumentMediaClipValidation = (value: unknown): value is DocumentMediaClipValidation => clipValidationValidator.validate(value).valid
17
+ export const validateDocumentMediaClipEdit = (value: unknown): value is DocumentMediaClipEdit => clipChangeValidator.validate(value).valid
18
+ const productionPlanValidator = new Validator(structuredClone(DOCUMENT_PRODUCTION_PLAN_SCHEMA) as unknown as Schema, '2020-12', false)
19
+ export const validateDocumentProductionPlanQuery = (value: unknown): value is DocProductionPlanQuery => productionPlanValidator.validate(value).valid
20
+
21
+ const recordingValidator = new Validator(structuredClone(DOCUMENT_RECORDING_ADOPTION_SCHEMA) as unknown as Schema, '2020-12', false)
22
+ export const validateDocumentRecordingAdoption = (value: unknown): value is DocumentRecordingAdoption => recordingValidator.validate(value).valid
@@ -0,0 +1,11 @@
1
+ import { Validator } from '@cfworker/json-schema'
2
+ import type { Schema } from '@cfworker/json-schema'
3
+ import { DOCUMENT_HISTORY_CHANGE_SCHEMA, DOCUMENT_HISTORY_QUERY_SCHEMA, DOCUMENT_HISTORY_READ_SCHEMA } from '../contracts/document-history.ts'
4
+ import type { DocumentHistoryChange, DocumentHistoryQuery, DocumentHistoryRead } from '../contracts/document-history.ts'
5
+
6
+ const query = new Validator(structuredClone(DOCUMENT_HISTORY_QUERY_SCHEMA) as unknown as Schema, '2020-12', false)
7
+ const read = new Validator(structuredClone(DOCUMENT_HISTORY_READ_SCHEMA) as unknown as Schema, '2020-12', false)
8
+ const change = new Validator(structuredClone(DOCUMENT_HISTORY_CHANGE_SCHEMA) as unknown as Schema, '2020-12', false)
9
+ export const validateDocumentHistoryQuery = (value: unknown): value is DocumentHistoryQuery => query.validate(value).valid
10
+ export const validateDocumentHistoryRead = (value: unknown): value is DocumentHistoryRead => read.validate(value).valid
11
+ export const validateDocumentHistoryChange = (value: unknown): value is DocumentHistoryChange => change.validate(value).valid
@@ -0,0 +1,8 @@
1
+ import { Validator } from '@cfworker/json-schema'
2
+ import type { Schema } from '@cfworker/json-schema'
3
+ import { DOCUMENT_WORKSPACE_IMAGE_INSPECTION_SCHEMA, DOCUMENT_WORKSPACE_IMAGE_IMPORT_SCHEMA } from '../contracts/document-image.ts'
4
+ import type { DocumentWorkspaceImageInspect, DocumentWorkspaceImageImport } from '../contracts/document-image.ts'
5
+ const inspect = new Validator(structuredClone(DOCUMENT_WORKSPACE_IMAGE_INSPECTION_SCHEMA) as unknown as Schema, '2020-12', false)
6
+ const importImage = new Validator(structuredClone(DOCUMENT_WORKSPACE_IMAGE_IMPORT_SCHEMA) as unknown as Schema, '2020-12', false)
7
+ export const validateDocumentWorkspaceImageInspect = (value: unknown): value is DocumentWorkspaceImageInspect => inspect.validate(value).valid
8
+ export const validateDocumentWorkspaceImageImport = (value: unknown): value is DocumentWorkspaceImageImport => importImage.validate(value).valid
@@ -0,0 +1,10 @@
1
+ import { Validator } from '@cfworker/json-schema'
2
+ import type { Schema } from '@cfworker/json-schema'
3
+ import { DOCUMENT_OUTLINE_QUERY_SCHEMA, DOCUMENT_PART_QUERY_SCHEMA, DOCUMENT_READ_MAX_REQUEST_CHARS } from '../contracts/document-reads.ts'
4
+ import type { DocumentOutlineQuery, DocumentPartQuery } from '../contracts/document-reads.ts'
5
+
6
+ const outline = new Validator(structuredClone(DOCUMENT_OUTLINE_QUERY_SCHEMA) as unknown as Schema, '2020-12', false)
7
+ const part = new Validator(structuredClone(DOCUMENT_PART_QUERY_SCHEMA) as unknown as Schema, '2020-12', false)
8
+ const bounded = (value: unknown) => { try { return JSON.stringify(value).length <= DOCUMENT_READ_MAX_REQUEST_CHARS } catch { return false } }
9
+ export function validateDocumentOutlineQuery(value: unknown): value is DocumentOutlineQuery { return bounded(value) && outline.validate(value).valid }
10
+ export function validateDocumentPartQuery(value: unknown): value is DocumentPartQuery { return bounded(value) && part.validate(value).valid }
@@ -0,0 +1,7 @@
1
+ import { Validator } from '@cfworker/json-schema'
2
+ import type { Schema } from '@cfworker/json-schema'
3
+ import { DOCUMENT_VOICE_CATALOG_SCHEMA } from '../contracts/document-voice.ts'
4
+ import type { DocumentVoiceCatalogQuery } from '../contracts/document-voice.ts'
5
+
6
+ const validator = new Validator(structuredClone(DOCUMENT_VOICE_CATALOG_SCHEMA) as unknown as Schema, '2020-12', false)
7
+ export const validateDocumentVoiceCatalogQuery = (value: unknown): value is DocumentVoiceCatalogQuery => validator.validate(value).valid
@@ -0,0 +1,29 @@
1
+ import { spawnSync } from 'node:child_process'
2
+
3
+ export interface Ffmpeg {
4
+ /** The program, as it is run. */
5
+ file: string
6
+ /** The names of its encoders (`libx264`, `libvpx-vp9`, `aac`, `libopus`…). */
7
+ encoders: Set<string>
8
+ }
9
+
10
+ let found: Ffmpeg | null | undefined
11
+
12
+ /**
13
+ * FFmpeg as the author installed it: `PRESMIUM_FFMPEG`, else `ffmpeg` on the path; undefined when it does not run.
14
+ * Presmium does not ship it; it encodes the lecture video with it when it is there (`ffmpegInstallHint` says how).
15
+ */
16
+ export function findFfmpeg(): Ffmpeg | undefined {
17
+ if (found !== undefined) return found ?? undefined
18
+ const file = process.env.PRESMIUM_FFMPEG || 'ffmpeg'
19
+ const run = spawnSync(file, ['-hide_banner', '-encoders'], { encoding: 'utf8', timeout: 10_000 })
20
+ found = run.status === 0 ? { file, encoders: new Set([...run.stdout.matchAll(/^ [A-Z.]{6} (\S+)/gm)].map(m => m[1]!)) } : null
21
+ return found ?? undefined
22
+ }
23
+
24
+ /** The one command that installs FFmpeg on this system. */
25
+ export function ffmpegInstallCommand(platform: NodeJS.Platform = process.platform): string {
26
+ if (platform === 'darwin') return 'brew install ffmpeg'
27
+ if (platform === 'win32') return 'winget install --id Gyan.FFmpeg'
28
+ return 'sudo apt install ffmpeg'
29
+ }
@@ -0,0 +1,6 @@
1
+ import type { BigIntStats } from 'node:fs'
2
+
3
+ /** A local file's identity and observed contents, including replacements that preserve its modification time. */
4
+ export function fileVersion(stat: Pick<BigIntStats, 'dev' | 'ino' | 'size' | 'mtimeNs' | 'ctimeNs'>): string {
5
+ return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(':')
6
+ }
@@ -0,0 +1,22 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { createRequire, findPackageJSON } from 'node:module'
3
+
4
+ export const FONT_LICENSE = 'presmium/fonts/LICENSE'
5
+
6
+ const FONT_COPYRIGHTS: Record<string, string> = {
7
+ '@fontsource/ibm-plex-mono': 'IBM Plex Mono: Copyright 2017 IBM Corp. All rights reserved.',
8
+ '@fontsource/ibm-plex-sans': 'IBM Plex Sans: Copyright 2019 IBM Corp. All rights reserved.',
9
+ '@fontsource/source-serif-4': 'Source Serif 4: Copyright 2014 - 2021 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name \'Source\'.',
10
+ }
11
+
12
+ export function fontLicense(from: string | URL): string {
13
+ const manifest = findPackageJSON('@lincyaw/host', from)
14
+ if (!manifest) throw new Error('@lincyaw/host is not installed')
15
+ const { dependencies = {} } = JSON.parse(readFileSync(manifest, 'utf8')) as { dependencies?: Record<string, string> }
16
+ const require = createRequire(manifest)
17
+ const files = Object.keys(dependencies).filter(name => name.startsWith('@fontsource/')).sort().map((name) => {
18
+ const copyright = FONT_COPYRIGHTS[name] ? `${FONT_COPYRIGHTS[name]}\n\n` : ''
19
+ return `${name}\n\n${copyright}${readFileSync(require.resolve(`${name}/LICENSE`), 'utf8').trim()}\n`
20
+ })
21
+ return [`The fonts of this site, from Fontsource, with their copyrights and licenses (SIL Open Font License 1.1).\n`, ...files].join(`\n${'='.repeat(78)}\n\n`)
22
+ }