@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/LICENSE +202 -0
- package/README.md +29 -2
- package/package.json +47 -3
- package/src/audience.ts +486 -0
- package/src/blocks.ts +557 -0
- package/src/compiler.ts +526 -0
- package/src/dependencies.ts +76 -0
- package/src/directives.ts +116 -0
- package/src/environments.ts +148 -0
- package/src/execute.ts +367 -0
- package/src/footnotes.ts +39 -0
- package/src/forbid.ts +82 -0
- package/src/host.ts +56 -0
- package/src/index.ts +20 -0
- package/src/links.ts +203 -0
- package/src/paths.ts +56 -0
- package/src/plugins.ts +178 -0
- package/src/published.ts +105 -0
- package/src/references.ts +42 -0
- package/src/renderers.ts +535 -0
- package/src/sections.ts +98 -0
- package/src/serve.ts +93 -0
- package/src/settings.ts +105 -0
- package/src/source.ts +300 -0
- package/src/steps.ts +66 -0
- package/src/translations.ts +139 -0
- package/src/validate.ts +105 -0
- package/src/workspace.ts +27 -0
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
|
+
}
|