@lincyaw/compiler 0.0.0-stage → 1.0.0-rc.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/blocks.ts ADDED
@@ -0,0 +1,557 @@
1
+ import { environmentExecutor, errorMessage, isRecord } from '@lincyaw/kernel'
2
+ import type {
3
+ BlockRole,
4
+ CodeNode,
5
+ ComponentNode,
6
+ DirectiveContext,
7
+ DirectiveOptionType,
8
+ DirectiveSpec,
9
+ ExecuteConfig,
10
+ Finding,
11
+ IRNode,
12
+ MdastNode,
13
+ RendererId,
14
+ RoleSpec,
15
+ RunSpec,
16
+ SourcePos,
17
+ } from '@lincyaw/kernel'
18
+ import { htmlTagNames } from 'html-tag-names'
19
+ import { mathmlTagNames } from 'mathml-tag-names'
20
+ import { defaultOptions } from 'myst-parser'
21
+ import { svgTagNames } from 'svg-tag-names'
22
+ import { parse as parseYaml } from 'yaml'
23
+ import { AUDIENCE_SCOPE, audienceList, markAudience, within } from './audience.ts'
24
+ import { BODY, ROLE, sourceOf } from './source.ts'
25
+ import type { Parser } from './source.ts'
26
+
27
+ export interface BlockDraft {
28
+ at?: number
29
+ until?: number
30
+ label?: string
31
+ kind: string
32
+ role: BlockRole
33
+ reveal: boolean
34
+ group?: number
35
+ span: number
36
+ only?: RendererId[]
37
+ audience?: string[]
38
+ source: SourcePos
39
+ node: IRNode
40
+ }
41
+
42
+ export interface PluginDirective {
43
+ plugin: string
44
+ config: Record<string, unknown>
45
+ spec: DirectiveSpec
46
+ }
47
+
48
+ export interface PluginRole {
49
+ plugin: string
50
+ config: Record<string, unknown>
51
+ spec: RoleSpec
52
+ }
53
+
54
+ export interface BlockContext {
55
+ entry: string
56
+ deckDir: string
57
+ workspaceRoot: string
58
+ lang: string
59
+ renderers: Set<string>
60
+ parser: Parser
61
+ directives: Map<string, PluginDirective>
62
+ roles: Map<string, PluginRole>
63
+ components: Map<string, string>
64
+ execute: ExecuteConfig
65
+ report(finding: Finding): void
66
+ watch(...files: string[]): void
67
+ }
68
+
69
+ export type Hints = Record<RendererId, Record<string, unknown>>
70
+
71
+ export type SectionConfig = Record<string, Record<string, unknown>>
72
+
73
+ export const BUILTIN_DIRECTIVES = new Map([['slide', true], ['only', true], ['hints', false], ['config', false], ['component', false], ['audience', false]])
74
+
75
+ const COMMON_OPTIONS = ['reveal', 'label', 'audience']
76
+ const MYST_ERRORS = new Set(['mystDirectiveError', 'mystRoleError'])
77
+ const RUN_OPTIONS = ['env', 'session']
78
+ const BROWSER = 'browser'
79
+ const COMPONENT_OPTIONS = [...COMMON_OPTIONS, 'span', 'plugin']
80
+ const CODE_DIRECTIVES = new Set(['code', 'code-block', 'code-cell', 'sourcecode', 'literalinclude', 'include'])
81
+ const NARRATION = new Set(['paragraph', 'list', 'blockquote', 'heading', 'definitionList', 'footnoteDefinition', 'thematicBreak'])
82
+ const DROPPED = new Set(['comment', 'mystTarget', 'outputs', BODY])
83
+ const HTML_ELEMENTS = new Set(htmlTagNames)
84
+ const FOREIGN_ELEMENTS = { svg: new Set(svgTagNames.map(name => name.toLowerCase())), math: new Set(mathmlTagNames) }
85
+ const TAG = /<(\/?)([A-Za-z][\w:.-]*)([^<>]*)(>?)/g
86
+ const MYST_DIRECTIVES = defaultOptions.directives as unknown as { name: string, alias?: string[], options?: Record<string, unknown> }[]
87
+
88
+ interface Scope {
89
+ at?: number
90
+ until?: number
91
+ visual: boolean
92
+ only?: RendererId[]
93
+ items?: boolean
94
+ group?: number
95
+ audience?: string[]
96
+ }
97
+
98
+ interface Output {
99
+ blocks: BlockDraft[]
100
+ hints: Hints
101
+ config: SectionConfig
102
+ groups: number
103
+ audience?: string[]
104
+ }
105
+
106
+ type Push = (draft: Omit<BlockDraft, 'only' | 'reveal' | 'group' | 'label'> & { label?: string, reveal?: boolean }) => void
107
+
108
+ export async function sectionBlocks(nodes: MdastNode[], ctx: BlockContext): Promise<{ blocks: BlockDraft[], hints: Hints, config: SectionConfig, audience?: string[] }> {
109
+ const out: Output = { blocks: [], hints: {}, config: sectionConfig(nodes, ctx), groups: 0 }
110
+ await convert(nodes, { visual: false }, ctx, out)
111
+ return { blocks: out.blocks, hints: out.hints, config: out.config, ...out.audience ? { audience: out.audience } : {} }
112
+ }
113
+
114
+ function sectionConfig(nodes: MdastNode[], ctx: BlockContext): SectionConfig {
115
+ const config: SectionConfig = {}
116
+ for (const node of nodes) {
117
+ const inner = node.type === AUDIENCE_SCOPE ? node.children : node.type === 'mystDirective' && (node.name === 'slide' || node.name === 'only') ? bodyNodes(node) : undefined
118
+ if (inner) {
119
+ for (const [id, options] of Object.entries(sectionConfig(inner, ctx))) config[id] = { ...config[id], ...options }
120
+ continue
121
+ }
122
+ if (node.type !== 'mystDirective' || node.name !== 'config') continue
123
+ const source = sourceOf(node, ctx.entry)
124
+ const fail = (message: string) =>
125
+ ctx.report({ checkId: 'presmium.directive', severity: 'error', message: `{config}: ${message}`, source: { file: source.file, line: source.line } })
126
+ for (const [id, options] of Object.entries(yamlBody(typeof node.value === 'string' ? node.value : '', fail) ?? {})) {
127
+ if (isRecord(options)) config[id] = { ...config[id], ...options }
128
+ else fail(`the configuration of "${id}" must be a mapping`)
129
+ }
130
+ }
131
+ return config
132
+ }
133
+
134
+ async function convert(nodes: MdastNode[], scope: Scope, ctx: BlockContext, out: Output): Promise<void> {
135
+ let label: string | undefined
136
+ const push: Push = (draft) => {
137
+ const items = draft.node.type === 'markdown' ? listItems(draft.node.mdast) : 0
138
+ if (scope.items && items > 0 && draft.node.type === 'markdown') {
139
+ draft.node.reveal = 'items'
140
+ draft.span = items - 1
141
+ }
142
+ const audience = draft.audience ?? scope.audience
143
+ out.blocks.push({ ...draft, at: draft.at ?? scope.at, until: draft.until ?? scope.until, label: draft.label ?? label, reveal: draft.reveal === true, group: scope.group, only: scope.only, ...audience ? { audience } : {} })
144
+ label = undefined
145
+ }
146
+
147
+ for (const node of nodes) {
148
+ const source = sourceOf(node, ctx.entry)
149
+ if (node.type === 'mystTarget') {
150
+ label = typeof node.label === 'string' ? node.label : undefined
151
+ continue
152
+ }
153
+ if (MYST_ERRORS.has(node.type)) {
154
+ mystError(node, source, ctx)
155
+ continue
156
+ }
157
+ if (node.type === AUDIENCE_SCOPE) {
158
+ const audience = scopeAudience(node, source, ctx)
159
+ if (node.section) {
160
+ out.audience = within(out.audience, audience)
161
+ continue
162
+ }
163
+ const start = out.blocks.length
164
+ await convert(node.children ?? [], { ...scope, audience: within(scope.audience, audience) }, ctx, out)
165
+ const first = out.blocks[start]
166
+ if (first) {
167
+ first.label ??= label
168
+ label = undefined
169
+ }
170
+ continue
171
+ }
172
+ if (DROPPED.has(node.type)) continue
173
+ if (node.type === 'mystDirective') {
174
+ const before = out.blocks.length
175
+ await directive(node, source, scope, label, ctx, push, out)
176
+ if (out.blocks.length > before) label = undefined
177
+ continue
178
+ }
179
+ if (node.type === 'code') {
180
+ push({ kind: 'code', role: 'visual', span: 0, source, node: codeNode(node, {}, source, ctx) })
181
+ continue
182
+ }
183
+ const role: BlockRole = scope.visual || !NARRATION.has(node.type) || onlyImages(node) ? 'visual' : 'narration'
184
+ const block = markdown([node], ctx)
185
+ push({ kind: node.type, role, span: 0, source, node: role === 'narration' ? { ...block, mdast: { ...block.mdast, children: literalTags(block.mdast.children ?? []) } } : block })
186
+ }
187
+ }
188
+
189
+ async function directive(node: MdastNode, source: SourcePos, scope: Scope, target: string | undefined, ctx: BlockContext, push: Push, out: Output): Promise<void> {
190
+ const name = String(node.name)
191
+ const options = isRecord(node.options) ? node.options as Record<string, string | true> : {}
192
+ const arg = typeof node.args === 'string' ? node.args.trim() : ''
193
+ const body = typeof node.value === 'string' ? node.value : ''
194
+ const label = typeof options.label === 'string' ? options.label : undefined
195
+ const reveal = options.reveal === undefined ? undefined : options.reveal === 'items' ? 'items' : 'next'
196
+ const fail = (message: string, severity: Finding['severity'] = 'error') =>
197
+ ctx.report({ checkId: 'presmium.directive', severity, message: `{${name}}: ${message}`, source: { file: source.file, line: source.line } })
198
+ const audience = options.audience === undefined ? scope.audience : audienceOption(options.audience, scope.audience, fail)
199
+
200
+ if (name === 'audience') {
201
+ const names = audienceList(arg)
202
+ if (names.length === 0) fail('needs the audiences its section is for, e.g. {audience} teacher; until it names them, the section is left out of every version for an audience')
203
+ if (body.trim()) fail('takes no body: it marks its whole section; to mark part of a section, give a directive :audience:, e.g. an {only} around the paragraphs', 'warning')
204
+ out.audience = out.audience ? out.audience.filter(a => names.includes(a)) : names
205
+ return
206
+ }
207
+
208
+ if (name === 'slide' || name === 'only') {
209
+ let only = scope.only
210
+ if (name === 'only') {
211
+ const ids = arg.split(/[\s,]+/).filter(Boolean)
212
+ if (ids.length === 0) fail('needs the renderer ids it is limited to, e.g. {only} web, notebook')
213
+ for (const id of ids.filter(id => !ctx.renderers.has(id))) {
214
+ fail(`unknown renderer id "${id}": its content shows in no form; known ids are ${[...ctx.renderers].join(', ')}`, 'warning')
215
+ }
216
+ only = scope.only ? scope.only.filter(id => ids.includes(id)) : ids
217
+ }
218
+ const position = (key: 'at' | 'until') => {
219
+ if (options[key] === undefined) return scope[key]
220
+ const value = Number(options[key])
221
+ if (options[key] === true || options[key] === '' || !Number.isInteger(value) || value < 0) {
222
+ fail(`:${key}: must be a non-negative integer`)
223
+ return scope[key]
224
+ }
225
+ return value
226
+ }
227
+ const at = position('at')
228
+ const until = position('until')
229
+ if (at !== undefined && until !== undefined && until <= at) fail(':until: must be greater than :at:')
230
+ if (at !== undefined && reveal !== undefined) fail(':at: and :reveal: cannot be combined')
231
+ const inner: Scope = {
232
+ at, until,
233
+ visual: scope.visual || name === 'slide',
234
+ only,
235
+ items: reveal === undefined ? scope.items : reveal === 'items',
236
+ group: reveal === 'next' ? ++out.groups : scope.group,
237
+ audience,
238
+ }
239
+ const start = out.blocks.length
240
+ await convert(bodyNodes(node), inner, ctx, out)
241
+ const first = out.blocks[start]
242
+ if (first) first.label ??= label ?? target
243
+ return
244
+ }
245
+
246
+ if (name === 'hints') {
247
+ const data = yamlBody(body, fail)
248
+ for (const [renderer, value] of Object.entries(data ?? {})) {
249
+ if (isRecord(value)) out.hints[renderer] = { ...out.hints[renderer], ...value }
250
+ else fail(`hints for "${renderer}" must be a mapping`)
251
+ }
252
+ return
253
+ }
254
+
255
+ if (name === 'config') return
256
+
257
+ if (name === 'component') {
258
+ if (!arg) return fail('needs a component name, e.g. {component} PmQuiz')
259
+ const props: Record<string, unknown> = {}
260
+ for (const [key, value] of Object.entries(options)) {
261
+ if (!COMPONENT_OPTIONS.includes(key)) props[key] = value === true ? true : yamlValue(value)
262
+ }
263
+ Object.assign(props, yamlBody(body, fail))
264
+ const plugin = typeof options.plugin === 'string' ? options.plugin : ctx.components.get(arg)
265
+ if (plugin === undefined) fail(`no plugin in \`plugins\` registers the component ${arg}`, 'warning')
266
+ const span = options.span === undefined ? 0 : Number(options.span)
267
+ if (!Number.isInteger(span) || span < 0) return fail(`:span: must be a non-negative integer`)
268
+ push({ kind: kebab(arg), label, reveal: reveal !== undefined, role: 'visual', span, source, audience, node: { type: 'component', plugin: plugin ?? '', name: arg, props } })
269
+ return
270
+ }
271
+
272
+ const registered = ctx.directives.get(name)
273
+ if (registered) {
274
+ const { spec, plugin, config } = registered
275
+ const typed: Record<string, unknown> = {}
276
+ const own = (key: string) => spec.options?.[key] !== undefined
277
+ for (const [key, value] of Object.entries(options)) {
278
+ if (COMMON_OPTIONS.includes(key) || (RUN_OPTIONS.includes(key) && !own(key))) continue
279
+ const type = spec.options?.[key]
280
+ if (spec.options && !type) fail(`unknown option :${key}:`, 'warning')
281
+ typed[key] = type ? optionValue(value, type) : value
282
+ }
283
+ const bodyLine = bodyNodeLine(node) ?? source.line + 1
284
+ const dctx: DirectiveContext = {
285
+ file: source.file,
286
+ deckDir: ctx.deckDir,
287
+ workspaceRoot: ctx.workspaceRoot,
288
+ lang: ctx.lang,
289
+ config: { ...config, ...out.config[plugin] },
290
+ source,
291
+ parse: markdown => ({ type: 'root', children: clean(ctx.parser.parse(markdown, bodyLine).children ?? [], ctx, source.file) }),
292
+ report: finding => ctx.report({ ...finding, source: finding.source ?? { file: source.file, line: source.line } }),
293
+ watch: ctx.watch,
294
+ }
295
+ try {
296
+ const result = await spec.toIR({ name, arg: arg || undefined, options: typed, body }, dctx)
297
+ const runs = runnableCode(result.node)
298
+ const given = Object.fromEntries(RUN_OPTIONS.filter(key => !own(key) && options[key] !== undefined).map(key => [key, options[key]!]))
299
+ for (const run of runs) runOptions(run, given, fail, ctx)
300
+ if (runs.length === 0 && Object.keys(given).length > 0) fail(`${Object.keys(given).map(key => `:${key}:`).join(' and ')} has no effect: the directive runs no code`, 'warning')
301
+ const kind = result.node.type === 'component' ? kebab(result.node.name) : result.node.type === 'code' ? 'code' : name
302
+ push({ kind, label, reveal: reveal !== undefined, role: result.role ?? 'visual', span: result.span ?? 0, source, audience, node: result.node })
303
+ } catch (error) {
304
+ fail(`plugin ${plugin} failed: ${errorMessage(error)}`)
305
+ }
306
+ return
307
+ }
308
+
309
+ const children = (node.children ?? []).filter(c => c.type !== BODY)
310
+ const code = CODE_DIRECTIVES.has(name) ? codeOf(children[0]) : undefined
311
+ if (code) {
312
+ push({ kind: 'code', label, reveal: reveal !== undefined, role: 'visual', span: 0, source, audience, node: codeNode(code, options, source, ctx) })
313
+ return
314
+ }
315
+ if (children.length === 0) return fail('unknown directive; is the plugin that provides it listed in `plugins`?')
316
+ push({ kind: name, label, reveal: reveal !== undefined, role: 'visual', span: 0, source, audience, node: markdown(children, ctx) })
317
+ }
318
+
319
+ function audienceOption(value: unknown, outer: string[] | undefined, fail: (message: string) => void): string[] {
320
+ const names = typeof value === 'string' ? audienceList(value) : []
321
+ if (names.length === 0) fail(':audience: needs the audiences it is for, e.g. :audience: teacher')
322
+ return within(outer, names)
323
+ }
324
+
325
+ function scopeAudience(node: MdastNode, source: SourcePos, ctx: BlockContext): string[] {
326
+ const audience = node.audience as string[]
327
+ if (audience.length === 0 && !node.section) {
328
+ ctx.report({ checkId: 'presmium.directive', severity: 'error', message: '{include}: :audience: needs the audiences it is for, e.g. :audience: teacher', source: { file: source.file, line: source.line } })
329
+ }
330
+ return audience
331
+ }
332
+
333
+ function mystError(node: MdastNode, source: SourcePos, ctx: BlockContext): void {
334
+ const name = String(node.name ?? '')
335
+ const message = typeof node.message === 'string' ? node.message : 'invalid'
336
+ const spec = node.type === 'mystDirectiveError' ? MYST_DIRECTIVES.find(d => d.name === name || d.alias?.includes(name)) : undefined
337
+ const unknown = spec && isRecord(node.options) ? Object.keys(node.options).filter(key => !(key in (spec.options ?? {})) && !COMMON_OPTIONS.includes(key)) : []
338
+ const hint = unknown.length > 0 ? `; {${name}} takes no ${unknown.map(key => `:${key}:`).join(', ')}` : ''
339
+ const what = node.type === 'mystDirectiveError' ? `{${name}}` : `role {${name}}`
340
+ ctx.report({ checkId: 'presmium.directive', severity: 'error', message: `${what}: ${message}${hint}`, source: { file: source.file, line: source.line } })
341
+ }
342
+
343
+ function codeOf(node: MdastNode | undefined): MdastNode | undefined {
344
+ if (node?.type === 'code') return node
345
+ return node?.type === 'container' || node?.type === 'block' ? node.children?.find(c => c.type === 'code') : undefined
346
+ }
347
+
348
+ function codeNode(node: MdastNode, options: Record<string, string | true>, source: SourcePos, ctx: BlockContext): CodeNode {
349
+ const lang = typeof node.lang === 'string' ? node.lang : ''
350
+ const code: CodeNode = { type: 'code', lang, value: typeof node.value === 'string' ? node.value : '' }
351
+ const title = options.caption ?? options.filename
352
+ if (typeof title === 'string') code.title = title
353
+ const emphasized: unknown[] = Array.isArray(node.emphasizeLines) ? node.emphasizeLines : []
354
+ const count = code.value.split('\n').length
355
+ const lines = emphasized.filter((n): n is number => Number.isInteger(n) && (n as number) >= 1 && (n as number) <= count)
356
+ if (lines.length < emphasized.length) {
357
+ ctx.report({ checkId: 'presmium.directive', severity: 'warning', message: `:emphasize-lines: lines outside the ${count} lines of the code are ignored`, source: { file: source.file, line: source.line } })
358
+ }
359
+ if (lines.length > 0) code.highlight = [lines]
360
+ const warn = (message: string, severity: Finding['severity'] = 'warning') => ctx.report({ checkId: 'presmium.directive', severity, message, source: { file: source.file, line: source.line } })
361
+ if (options.run !== undefined) code.run = runSpec(lang, options, warn, ctx)
362
+ else if (options.env !== undefined || options.session !== undefined) warn(':env: and :session: need :run:; they are ignored')
363
+ if (options.echo !== undefined) {
364
+ const echo = options.echo === true ? '' : options.echo.trim().toLowerCase()
365
+ if (echo !== 'false' && echo !== 'true') warn(`:echo: ${echo} is not true or false; ignored`)
366
+ else if (echo === 'false' && !code.run) warn(':echo: false needs :run:; the code shows')
367
+ else if (echo === 'false') {
368
+ code.echo = false
369
+ if (code.run!.when === 'live') {
370
+ warn(':echo: false with :run: live: the code has no controls to run it; it runs at compile time')
371
+ code.run!.when = 'compile'
372
+ }
373
+ }
374
+ }
375
+ return code
376
+ }
377
+
378
+ function runnableCode(node: IRNode): RunSpec[] {
379
+ if (node.type === 'code') return node.run ? [node.run] : []
380
+ if (node.type !== 'component') return []
381
+ return (node as ComponentNode).children?.flatMap(child => runnableCode(child.node)) ?? []
382
+ }
383
+
384
+ function runOptions(spec: RunSpec, options: Record<string, string | true>, warn: (message: string, severity?: Finding['severity']) => void, ctx: BlockContext): void {
385
+ const env = typeof options.env === 'string' ? options.env.trim() : undefined
386
+ if (options.env === true || env === '') warn(':env: needs the name of an environment of `execute.environments`, or browser', 'error')
387
+ if (env && spec.executor && spec.executor !== environmentExecutor(env)) {
388
+ warn(`:env: ${env} and :executor: ${spec.executor} both choose where the code runs; write one of them`, 'error')
389
+ } else if (env) {
390
+ if (env === BROWSER) delete spec.executor
391
+ else spec.executor = environmentExecutor(env)
392
+ } else if (!spec.executor && ctx.execute.env && ctx.execute.env !== BROWSER) {
393
+ spec.executor = environmentExecutor(ctx.execute.env)
394
+ }
395
+ const session = typeof options.session === 'string' ? options.session.trim() : ''
396
+ if (options.session !== undefined && !session) warn(':session: needs a name', 'error')
397
+ if (session) spec.session = session
398
+ }
399
+
400
+ function runSpec(lang: string, options: Record<string, string | true>, warn: (message: string, severity?: Finding['severity']) => void, ctx: BlockContext): RunSpec {
401
+ const when = options.run === true || options.run === '' ? 'compile' : options.run
402
+ const spec: RunSpec = { lang, when: 'compile' }
403
+ if (when === 'compile' || when === 'live' || when === 'both') spec.when = when
404
+ else warn(`:run: ${when} is not compile, live or both; using compile`)
405
+ if (typeof options.executor === 'string') spec.executor = options.executor
406
+ const timeout = Number(options.timeout)
407
+ if (options.timeout !== undefined && Number.isFinite(timeout) && timeout > 0) spec.timeoutMs = timeout
408
+ runOptions(spec, options, warn, ctx)
409
+ return spec
410
+ }
411
+
412
+ function markdown(nodes: MdastNode[], ctx: BlockContext): Extract<IRNode, { type: 'markdown' }> {
413
+ return { type: 'markdown', mdast: { type: 'root', children: clean(nodes, ctx) } }
414
+ }
415
+
416
+ type Foreign = Record<keyof typeof FOREIGN_ELEMENTS, number>
417
+
418
+ function literalTags(nodes: MdastNode[], foreign: Foreign = { svg: 0, math: 0 }): MdastNode[] {
419
+ return nodes.map((node) => {
420
+ if (node.type === 'html' && typeof node.value === 'string') {
421
+ const tags = [...node.value.matchAll(TAG)]
422
+ const known = (name: string) => HTML_ELEMENTS.has(name) || (foreign.svg > 0 && FOREIGN_ELEMENTS.svg.has(name)) || (foreign.math > 0 && FOREIGN_ELEMENTS.math.has(name))
423
+ if (tags.length > 0 && tags.every(([, , name]) => !known(name!.toLowerCase()))) return { type: 'text', value: node.value }
424
+ for (const [, close, name, attributes, end] of tags) {
425
+ const ns = name!.toLowerCase()
426
+ if (ns !== 'svg' && ns !== 'math') continue
427
+ if (close) foreign[ns] = Math.max(0, foreign[ns] - 1)
428
+ else if (!(end && attributes!.trimEnd().endsWith('/'))) foreign[ns]++
429
+ }
430
+ return node
431
+ }
432
+ return node.children ? { ...node, children: literalTags(node.children, { ...foreign }) } : node
433
+ })
434
+ }
435
+
436
+ function clean(nodes: MdastNode[], ctx: BlockContext, file = ctx.entry): MdastNode[] {
437
+ return nodes.flatMap((node): MdastNode[] => {
438
+ if (MYST_ERRORS.has(node.type)) {
439
+ mystError(node, sourceOf(node, file), ctx)
440
+ return []
441
+ }
442
+ if (DROPPED.has(node.type)) return []
443
+ if (node.type === AUDIENCE_SCOPE) return markAudience(clean(node.children ?? [], ctx, file), scopeAudience(node, sourceOf(node, file), ctx))
444
+ if (node.type === 'mystRole' && ctx.roles.has(String(node.name))) return [roleNode(node, ctx, file)]
445
+ if (node.type === 'mystDirective' || node.type === 'mystRole') {
446
+ const name = String(node.name)
447
+ const where = sourceOf(node, file)
448
+ const report = (severity: Finding['severity'], message: string) =>
449
+ ctx.report({ checkId: 'presmium.directive', severity, message, source: { file: where.file, line: where.line } })
450
+ if (node.children?.length && !(node.type === 'mystDirective' && (BUILTIN_DIRECTIVES.has(name) || ctx.directives.has(name)))) {
451
+ const nodes = clean(node.children, ctx, file)
452
+ const option = node.type === 'mystDirective' && isRecord(node.options) ? node.options.audience : undefined
453
+ return option === undefined ? nodes : markAudience(nodes, audienceOption(option, undefined, message => report('error', `{${name}}: ${message}`)))
454
+ }
455
+ if (node.type === 'mystRole') {
456
+ report('warning', `unknown role {${name}}; its text is kept as plain text`)
457
+ return typeof node.value === 'string' ? [{ type: 'text', value: node.value }] : []
458
+ }
459
+ if (BUILTIN_DIRECTIVES.has(name) || ctx.directives.has(name)) {
460
+ report('warning', `{${name}} is only supported at the top level of a section or in {slide} and {only}; it is dropped`)
461
+ } else {
462
+ report('error', `{${name}}: unknown directive; is the plugin that provides it listed in \`plugins\`?`)
463
+ }
464
+ return []
465
+ }
466
+ const { position: _position, children, ...rest } = node
467
+ if (!children) return [rest as MdastNode]
468
+ const cleaned = clean(children, ctx, file)
469
+ return [{ ...rest, children: node.type === 'tableCell' ? literalTags(cleaned) : cleaned } as MdastNode]
470
+ })
471
+ }
472
+
473
+ function roleNode(node: MdastNode, ctx: BlockContext, file: string): MdastNode {
474
+ const name = String(node.name)
475
+ const { plugin, config, spec } = ctx.roles.get(name)!
476
+ const value = typeof node.value === 'string' ? node.value : ''
477
+ const where = sourceOf(node, file)
478
+ const report = (finding: Finding) => ctx.report({ ...finding, source: finding.source ?? { file: where.file, line: where.line } })
479
+ const raw = node.children?.find(child => child.type === ROLE)?.options
480
+ const options: Record<string, unknown> = {}
481
+ for (const [key, given] of Object.entries(isRecord(raw) ? raw : {})) {
482
+ const type = spec.options?.[key]
483
+ options[key] = type === 'list' && typeof given === 'string' ? given.split(/[\s,]+/).filter(Boolean) : given
484
+ }
485
+ const dctx: DirectiveContext = {
486
+ file: where.file,
487
+ deckDir: ctx.deckDir,
488
+ workspaceRoot: ctx.workspaceRoot,
489
+ lang: ctx.lang,
490
+ config,
491
+ source: where,
492
+ parse: markdown => ({ type: 'root', children: clean(ctx.parser.parse(markdown, where.line).children ?? [], ctx, where.file) }),
493
+ report,
494
+ watch: ctx.watch,
495
+ }
496
+ let children: MdastNode[] = [{ type: 'text', value }]
497
+ try {
498
+ if (spec.children) children = spec.children({ name, value, options }, dctx)
499
+ } catch (error) {
500
+ report({ checkId: 'presmium.plugins', severity: 'error', message: `role {${name}} of plugin ${plugin} failed: ${errorMessage(error)}` })
501
+ }
502
+ return { type: 'presmiumRole', plugin, name, options: { ...options, value }, children }
503
+ }
504
+
505
+ function bodyNodes(node: MdastNode): MdastNode[] {
506
+ return node.children?.find(c => c.type === BODY)?.children ?? []
507
+ }
508
+
509
+ function bodyNodeLine(node: MdastNode): number | undefined {
510
+ const line = node.children?.find(c => c.type === BODY)?.line
511
+ return typeof line === 'number' ? line : undefined
512
+ }
513
+
514
+ function listItems(mdast: MdastNode): number {
515
+ return (mdast.children ?? []).filter(c => c.type === 'list').reduce((n, list) => n + (list.children?.length ?? 0), 0)
516
+ }
517
+
518
+ function onlyImages(node: MdastNode): boolean {
519
+ const children = node.children ?? []
520
+ return node.type === 'paragraph' && children.some(c => c.type === 'image')
521
+ && children.every(c => c.type === 'image' || (c.type === 'text' && !String(c.value).trim()))
522
+ }
523
+
524
+ function yamlBody(body: string, fail: (message: string) => void): Record<string, unknown> | undefined {
525
+ if (!body.trim()) return undefined
526
+ try {
527
+ const data: unknown = parseYaml(body)
528
+ if (isRecord(data)) return data
529
+ fail('the body must be a YAML mapping')
530
+ } catch (error) {
531
+ fail(`invalid YAML body: ${errorMessage(error)}`)
532
+ }
533
+ return undefined
534
+ }
535
+
536
+ function yamlValue(value: unknown): unknown {
537
+ if (typeof value !== 'string') return value
538
+ try {
539
+ return parseYaml(value) ?? value
540
+ } catch {
541
+ return value
542
+ }
543
+ }
544
+
545
+ function optionValue(value: string | true, type: DirectiveOptionType): unknown {
546
+ const text = value === true ? '' : value
547
+ switch (type) {
548
+ case 'boolean': return value === true || /^(?:true|yes|1|)$/i.test(text.trim())
549
+ case 'number': return Number(text)
550
+ case 'list': return text.split(/[\s,]+/).filter(Boolean)
551
+ default: return text
552
+ }
553
+ }
554
+
555
+ function kebab(name: string): string {
556
+ return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
557
+ }