@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.
@@ -0,0 +1,486 @@
1
+ import { lstat, readdir, readFile } from 'node:fs/promises'
2
+ import { basename, extname, join, relative } from 'node:path'
3
+ import { irBlocks, isRecord, nodeMdast } from '@lincyaw/kernel'
4
+ import type { Finding, IRBlock, IRDocument, IRNode, IRSection, MdastNode, SourcePos } from '@lincyaw/kernel'
5
+ import { resolveReferences } from './references.ts'
6
+ import { Parser, sourceOf } from './source.ts'
7
+ import type { ParsedFile } from './source.ts'
8
+
9
+ export interface Removed {
10
+ source: SourcePos
11
+ audience: string[]
12
+ title?: string
13
+ blocks: IRBlock[]
14
+ nested?: boolean
15
+ }
16
+
17
+ export interface AudienceMark {
18
+ source: SourcePos
19
+ audience: string[]
20
+ }
21
+
22
+ export interface Withheld {
23
+ source: SourcePos
24
+ audience: string[]
25
+ sentences: string[]
26
+ }
27
+
28
+ export const AUDIENCE_SCOPE = 'presmiumAudience'
29
+
30
+ const MIN_SENTENCE = 12
31
+
32
+ const BINARY = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.ico', '.bmp', '.mp3', '.mp4', '.m4a', '.webm', '.ogg', '.oga', '.wav', '.flac', '.woff', '.woff2', '.ttf', '.otf', '.eot', '.wasm', '.zip', '.gz', '.pdf'])
33
+
34
+ const HTML_ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: '\'', nbsp: ' ' }
35
+
36
+ export function forAudience(audience: string[] | undefined, name: string): boolean {
37
+ return !audience || audience.includes(name)
38
+ }
39
+
40
+ export function audienceList(text: string): string[] {
41
+ return [...new Set(text.split(/[\s,]+/).filter(Boolean))]
42
+ }
43
+
44
+ export function within(outer: string[] | undefined, inner: string[]): string[] {
45
+ return outer ? outer.filter(name => inner.includes(name)) : inner
46
+ }
47
+
48
+ export function markOf(node: unknown): string[] | undefined {
49
+ const audience = isRecord(node) && isRecord(node.data) ? node.data.audience : undefined
50
+ return Array.isArray(audience) ? audience as string[] : undefined
51
+ }
52
+
53
+ export function markAudience(nodes: MdastNode[], audience: string[]): MdastNode[] {
54
+ return nodes.map(node => ({ ...node, data: { ...isRecord(node.data) ? node.data : {}, audience: within(markOf(node), audience) } }))
55
+ }
56
+
57
+ export class AudienceParser extends Parser {
58
+ readonly #scoped = new WeakMap<ParsedFile, ParsedFile>()
59
+
60
+ override async read(file: string): Promise<ParsedFile | undefined> {
61
+ const parsed = await super.read(file)
62
+ if (!parsed) return undefined
63
+ let scoped = this.#scoped.get(parsed)
64
+ if (!scoped) this.#scoped.set(parsed, scoped = { ...parsed, children: scopeIncludes(parsed.children) })
65
+ return scoped
66
+ }
67
+ }
68
+
69
+ function scopeIncludes(nodes: MdastNode[]): MdastNode[] {
70
+ let changed = false
71
+ const result = nodes.map((node): MdastNode => {
72
+ const option = isRecord(node.options) ? node.options.audience : undefined
73
+ if (node.type === 'mystDirective' && node.name === 'include' && option !== undefined && !node.children?.some(c => c.type === 'include' && c.literal === true)) {
74
+ changed = true
75
+ return { type: AUDIENCE_SCOPE, audience: audienceList(typeof option === 'string' ? option : ''), position: node.position, children: [node] }
76
+ }
77
+ const children = node.children?.length ? scopeIncludes(node.children) : node.children
78
+ if (children === node.children) return node
79
+ changed = true
80
+ return { ...node, children }
81
+ })
82
+ return changed ? result : nodes
83
+ }
84
+
85
+ export function liftScopes(nodes: MdastNode[], sectionDepth: number, trailing?: (node: MdastNode, audience: string[]) => void): MdastNode[] {
86
+ if (!nodes.some(node => lifted(node, sectionDepth))) return nodes
87
+ const out: MdastNode[] = []
88
+ let section: string[] | undefined
89
+ for (const node of nodes) {
90
+ if (lifted(node, sectionDepth)) {
91
+ section = lift(node, node.audience as string[], sectionDepth, out, trailing)
92
+ continue
93
+ }
94
+ if (opens(node, sectionDepth)) section = undefined
95
+ else if (section && wider(node, undefined, section)) {
96
+ trailing?.(node, section)
97
+ section = undefined
98
+ }
99
+ out.push(node)
100
+ }
101
+ return out
102
+ }
103
+
104
+ function lifted(node: MdastNode, depth: number): boolean {
105
+ return node.type === AUDIENCE_SCOPE && (node.audience as string[]).length > 0 && holdsSection(node, depth)
106
+ }
107
+
108
+ function opens(node: MdastNode, depth: number): boolean {
109
+ return node.type === 'heading' && Number(node.depth) <= depth
110
+ }
111
+
112
+ function holdsSection(scope: MdastNode, depth: number): boolean {
113
+ return (scope.children ?? []).some(node => opens(node, depth) || (node.type === AUDIENCE_SCOPE && holdsSection(node, depth)))
114
+ }
115
+
116
+ const SETTINGS = new Set(['hints', 'config', 'audience'])
117
+
118
+ function ownAudience(node: MdastNode): string[] | undefined {
119
+ if (node.type === AUDIENCE_SCOPE) return node.section ? undefined : node.audience as string[]
120
+ const option = node.type === 'mystDirective' && isRecord(node.options) ? node.options.audience : undefined
121
+ return option === undefined ? undefined : audienceList(typeof option === 'string' ? option : '')
122
+ }
123
+
124
+ function wider(node: MdastNode, own: string[] | undefined, section: string[]): boolean {
125
+ if (node.type === 'comment' || node.type === 'mystTarget' || (node.type === 'mystDirective' && SETTINGS.has(String(node.name)))) return false
126
+ const marked = ownAudience(node)
127
+ const audience = marked ? within(own, marked) : own
128
+ return !audience || audience.some(name => !section.includes(name))
129
+ }
130
+
131
+ export function audienceMarks(nodes: MdastNode[], entry: string, outer?: string[], marks: AudienceMark[] = []): AudienceMark[] {
132
+ for (const node of nodes) {
133
+ const own = ownAudience(node)
134
+ const audience = own ? within(outer, own) : outer
135
+ if (own && node.position) marks.push({ source: sourceOf(node, entry), audience: audience! })
136
+ if (node.children?.length) audienceMarks(node.children, entry, audience, marks)
137
+ }
138
+ return marks
139
+ }
140
+
141
+ export function markBlocks(sections: IRSection[], marks: AudienceMark[]): void {
142
+ if (marks.length === 0) return
143
+ for (const section of sections) {
144
+ for (const block of irBlocks(section.blocks)) {
145
+ const { file, line } = block.source
146
+ const end = block.source.endLine ?? line
147
+ for (const mark of marks) {
148
+ if (mark.source.file === file && mark.source.line <= line && end <= (mark.source.endLine ?? mark.source.line)) block.audience = within(block.audience, mark.audience)
149
+ }
150
+ }
151
+ }
152
+ }
153
+
154
+ function lift(scope: MdastNode, audience: string[], depth: number, out: MdastNode[], trailing?: (node: MdastNode, audience: string[]) => void): string[] {
155
+ const before: MdastNode[] = []
156
+ let opened = false
157
+ let last = audience
158
+ const open = () => {
159
+ if (!opened && before.length > 0) out.push(Object.assign(scope, { audience, children: before }))
160
+ opened = true
161
+ }
162
+ for (const node of scope.children ?? []) {
163
+ if (node.type === AUDIENCE_SCOPE && holdsSection(node, depth)) {
164
+ open()
165
+ last = lift(node, within(audience, node.audience as string[]), depth, out, trailing)
166
+ } else if (opens(node, depth)) {
167
+ const label = !opened && before.at(-1)?.type === 'mystTarget' ? before.pop() : undefined
168
+ open()
169
+ out.push(...label ? [label] : [], node, { type: AUDIENCE_SCOPE, audience, section: true })
170
+ last = audience
171
+ } else {
172
+ if (opened && last !== audience && wider(node, audience, last)) {
173
+ trailing?.(node, last)
174
+ last = audience
175
+ }
176
+ (opened ? out : before).push(node)
177
+ }
178
+ }
179
+ return last
180
+ }
181
+
182
+ export function audienceView(doc: IRDocument, name: string, marks: AudienceMark[] = []): { doc: IRDocument, removed: Removed[], keeps: (finding: Finding) => boolean } {
183
+ const removed: Removed[] = []
184
+ const sections: IRSection[] = []
185
+ const compacted = new Set<string>()
186
+ for (const section of doc.sections) {
187
+ if (!forAudience(section.audience, name)) {
188
+ removed.push({ source: section.source, audience: section.audience!, title: section.title, blocks: section.blocks })
189
+ continue
190
+ }
191
+ let dropped = false
192
+ const keep = (blocks: IRBlock[]): IRBlock[] => blocks.flatMap((block) => {
193
+ if (!forAudience(block.audience, name)) {
194
+ dropped = true
195
+ removed.push({ source: block.source, audience: block.audience!, blocks: [block] })
196
+ return []
197
+ }
198
+ const node = nodeFor(block, name, removed, keep)
199
+ return [node === block.node ? block : { ...block, node }]
200
+ })
201
+ const blocks = keep(section.blocks)
202
+ if (dropped) compacted.add(section.id)
203
+ sections.push(dropped ? compactSteps({ ...section, blocks }) : same(blocks, section.blocks) ? section : { ...section, blocks })
204
+ }
205
+ const marked = marks.filter(mark => !forAudience(mark.audience, name)).map(mark => mark.source)
206
+ if (removed.length === 0 && marked.length === 0) return { doc, removed, keeps: () => true }
207
+ const kept = new Set(sections.map(section => section.id))
208
+ sections.forEach((section, i) => sections[i] = withViewKeys(section, kept, compacted.has(section.id)))
209
+ const spans = [...leftOut(doc, name, removed), ...marked]
210
+ const keeps = ({ source: at }: Finding) => !at || !spans.some(span => at.file === span.file && (at.line ?? 0) >= span.line && (at.line ?? 0) <= (span.endLine ?? span.line))
211
+ return {
212
+ doc: {
213
+ ...doc,
214
+ sections: sections.map((section, i) => (section.index === i + 1 ? section : { ...section, index: i + 1 })),
215
+ diagnostics: doc.diagnostics.filter(keeps),
216
+ },
217
+ removed,
218
+ keeps,
219
+ }
220
+ }
221
+
222
+ function leftOut(doc: IRDocument, name: string, removed: Removed[]): SourcePos[] {
223
+ const spans: SourcePos[] = removed.filter(item => !item.nested && item.title === undefined).map(item => item.source)
224
+ doc.sections.forEach((section, i) => {
225
+ if (forAudience(section.audience, name)) return
226
+ const next = doc.sections.slice(i + 1).find(later => later.source.file === section.source.file)
227
+ spans.push({ file: section.source.file, line: section.source.line, endLine: next ? next.source.line - 1 : Number.MAX_SAFE_INTEGER })
228
+ for (const block of irBlocks(section.blocks)) if (block.source.file !== section.source.file) spans.push(block.source)
229
+ })
230
+ return spans
231
+ }
232
+
233
+ export function audienceDocument(doc: IRDocument, name: string, filled: WeakSet<object>, marks: AudienceMark[] = []): IRDocument {
234
+ const { doc: found, removed } = audienceView(doc, name, marks)
235
+ const view = { ...found, meta: { ...found.meta, audience: name } }
236
+ if (removed.length === 0) return view
237
+ const own = detached(view, filled)
238
+ resolveReferences(own, () => {})
239
+ return withoutLeaks(own, removed, name)
240
+ }
241
+
242
+ export function emptyReferences(doc: IRDocument): WeakSet<object> {
243
+ const found = new WeakSet<object>()
244
+ const visit = (node: MdastNode) => {
245
+ if (node.type === 'crossReference' && !node.children?.length) found.add(node)
246
+ node.children?.forEach(visit)
247
+ }
248
+ for (const section of doc.sections) for (const block of irBlocks(section.blocks)) nodeMdast(block.node).forEach(visit)
249
+ return found
250
+ }
251
+
252
+ export function withheld(removed: Removed[], kept: IRDocument): Withheld[] {
253
+ const shown = ownText(kept)
254
+ return removed.map(({ source, audience, title, blocks }) => {
255
+ const texts = [...title ? [title] : [], ...blocks.flatMap(block => [...irBlocks([block])].flatMap(blockTexts))]
256
+ return { source, audience, sentences: [...new Set(texts.flatMap(sentences))].filter(sentence => !shown.includes(sentence)) }
257
+ })
258
+ }
259
+
260
+ export function unshown(left: Withheld[], docs: IRDocument[]): Withheld[] {
261
+ const shown = docs.map(ownText).join('\n')
262
+ return left.map(item => ({ ...item, sentences: item.sentences.filter(sentence => !shown.includes(sentence)) }))
263
+ }
264
+
265
+ function ownText(doc: IRDocument): string {
266
+ return doc.sections.flatMap(section => [section.title, ...[...irBlocks(section.blocks)].flatMap(blockTexts)]).map(squash).join('\n')
267
+ }
268
+
269
+ export async function scanOutputs(left: Withheld[], paths: string[], audience: string, base: string, quote = true): Promise<Finding[]> {
270
+ const wanted = left.filter(item => item.sentences.length > 0)
271
+ const found = new Map<Withheld, { sentence: string, file: string }>()
272
+ const written = wanted.length > 0 ? (await Promise.all(paths.map(files))).flat() : []
273
+ for (const file of written) {
274
+ const buffer = await readFile(file).catch(() => undefined)
275
+ if (!buffer || BINARY.has(extname(file).toLowerCase()) || buffer.subarray(0, 8000).includes(0)) continue
276
+ const text = readable(buffer.toString('utf8'))
277
+ for (const item of wanted) {
278
+ if (found.has(item)) continue
279
+ const sentence = item.sentences.find(s => text.includes(s))
280
+ if (sentence) found.set(item, { sentence, file })
281
+ }
282
+ if (found.size === wanted.length) break
283
+ }
284
+ return [...found].map(([item, { sentence, file }]): Finding => ({
285
+ checkId: 'presmium.audience',
286
+ severity: 'error',
287
+ message: `content only for ${item.audience.join(', ')} is in the build for ${audience}: ${quote ? `"${sentence}" ` : ''}in ${relative(base, file)}`,
288
+ hint: 'it reached the build another way, e.g. through the output of a code block or a file a block reads; take it out there, or give that block the same :audience:',
289
+ source: { file: item.source.file, line: item.source.line },
290
+ }))
291
+ }
292
+
293
+ function same(blocks: readonly IRBlock[], before: readonly IRBlock[]): boolean {
294
+ return blocks.length === before.length && blocks.every((block, i) => block === before[i])
295
+ }
296
+
297
+ function nodeFor(block: IRBlock, name: string, removed: Removed[], keep: (blocks: IRBlock[]) => IRBlock[]): IRNode {
298
+ const { node } = block
299
+ const left: MdastNode[] = []
300
+ let result: IRNode = node
301
+ if (node.type === 'markdown') {
302
+ const mdast = prune(node.mdast, name, left)
303
+ if (mdast !== node.mdast) result = { ...node, mdast }
304
+ } else if (node.type === 'component') {
305
+ const props = prune(node.props, name, left)
306
+ const fallback = node.fallback && prune(node.fallback, name, left)
307
+ const children = node.children && keep(node.children)
308
+ if (props !== node.props || fallback !== node.fallback || (children && !same(children, node.children!))) {
309
+ result = { ...node, props, ...fallback ? { fallback } : {}, ...children ? { children } : {} }
310
+ }
311
+ }
312
+ for (const nested of left) {
313
+ removed.push({ source: block.source, audience: markOf(nested)!, blocks: [{ ...block, node: { type: 'markdown', mdast: { type: 'root', children: [nested] } } }], nested: true })
314
+ }
315
+ return result
316
+ }
317
+
318
+ function prune<T>(value: T, name: string, left: MdastNode[]): T {
319
+ if (Array.isArray(value)) {
320
+ let changed = false
321
+ const kept = value.flatMap((item: unknown) => {
322
+ if (!forAudience(markOf(item), name)) {
323
+ left.push(item as MdastNode)
324
+ changed = true
325
+ return []
326
+ }
327
+ const next = prune(item, name, left)
328
+ if (next !== item) changed = true
329
+ return [next]
330
+ })
331
+ return (changed ? kept : value) as T
332
+ }
333
+ if (!isRecord(value)) return value
334
+ let changed = false
335
+ const entries = Object.entries(value).map(([key, item]) => {
336
+ const next = prune(item, name, left)
337
+ if (next !== item) changed = true
338
+ return [key, next] as const
339
+ })
340
+ return (changed ? Object.fromEntries(entries) : value) as T
341
+ }
342
+
343
+ function detached(view: IRDocument, filled: WeakSet<object>): IRDocument {
344
+ const copy = (node: MdastNode): MdastNode => {
345
+ if (filled.has(node)) {
346
+ const { children: _children, ...written } = node
347
+ return written
348
+ }
349
+ return node.children ? { ...node, children: node.children.map(copy) } : { ...node }
350
+ }
351
+ const blocks = (list: IRBlock[]): IRBlock[] => list.map((block) => {
352
+ const { node } = block
353
+ if (node.type === 'markdown') return { ...block, node: { ...node, mdast: copy(node.mdast) } }
354
+ if (node.type !== 'component') return block
355
+ return { ...block, node: { ...node, ...node.fallback ? { fallback: copy(node.fallback) } : {}, ...node.children ? { children: blocks(node.children) } : {} } }
356
+ })
357
+ return { ...view, sections: view.sections.map(section => ({ ...section, blocks: blocks(section.blocks) })) }
358
+ }
359
+
360
+ function withoutLeaks(view: IRDocument, removed: Removed[], name: string): IRDocument {
361
+ const wanted = withheld(removed, view).filter(item => item.sentences.length > 0)
362
+ if (wanted.length === 0) return view
363
+ const findings: Finding[] = []
364
+ const sections = view.sections.map((section) => {
365
+ const blocks = section.blocks.filter((block) => {
366
+ const text = readable(JSON.stringify(block.node, (key, value: unknown) => (key.startsWith('image/') || key === 'application/pdf' ? undefined : value)))
367
+ for (const item of wanted) {
368
+ if (!item.sentences.some(sentence => text.includes(sentence))) continue
369
+ findings.push({
370
+ checkId: 'presmium.audience',
371
+ severity: 'error',
372
+ message: `the block shows content only for ${item.audience.join(', ')} (written at ${basename(item.source.file)}:${item.source.line}); it is left out of the view for ${name}`,
373
+ hint: 'it reached the block another way, e.g. through the output of a code block; take it out there, or give the block the same :audience:',
374
+ source: { file: block.source.file, line: block.source.line },
375
+ })
376
+ return false
377
+ }
378
+ return true
379
+ })
380
+ return blocks.length === section.blocks.length ? section : compactSteps({ ...section, blocks })
381
+ })
382
+ return findings.length === 0 ? view : { ...view, sections, diagnostics: [...view.diagnostics, ...findings] }
383
+ }
384
+
385
+ const STEPS_KEY = 'presmiumSteps'
386
+ const BACK_KEY = 'presmiumBack'
387
+
388
+ function withViewKeys(section: IRSection, sections: ReadonlySet<string>, compacted: boolean): IRSection {
389
+ const hints = section.hints.slides
390
+ const back = isRecord(hints) && typeof hints[BACK_KEY] === 'string' && !sections.has(hints[BACK_KEY])
391
+ if (!isRecord(hints) || (!(compacted && STEPS_KEY in hints) && !back)) return section
392
+ const paths = new Set<string>()
393
+ const visit = (blocks: readonly IRBlock[], prefix: string) => {
394
+ for (const block of blocks) {
395
+ paths.add(prefix + block.id)
396
+ if (block.node.type === 'component' && block.node.children) visit(block.node.children, `${prefix}${block.id}/`)
397
+ }
398
+ }
399
+ visit(section.blocks, '')
400
+ const has = (key: string) => paths.has(key) || paths.has(key.replace(/\+\d+$/, '')) || (key.endsWith('-') && paths.has(key.slice(0, -1)))
401
+ const own: Record<string, unknown> = { ...hints }
402
+ const steps = hints[STEPS_KEY]
403
+ if (compacted && Array.isArray(steps)) {
404
+ const left = steps.map(keys => (Array.isArray(keys) ? keys.filter(key => typeof key === 'string' && has(key)) : []))
405
+ const closed = left.filter((keys, i) => i === 0 || keys.length > 0)
406
+ if (closed.length > 1) own[STEPS_KEY] = closed
407
+ else delete own[STEPS_KEY]
408
+ }
409
+ if (back) delete own[BACK_KEY]
410
+ return { ...section, hints: { ...section.hints, slides: own } }
411
+ }
412
+
413
+ function compactSteps(section: IRSection): IRSection {
414
+ const used = new Set([0])
415
+ for (const block of irBlocks(section.blocks)) {
416
+ if (block.role !== 'visual') continue
417
+ for (let step = block.at; step <= block.at + (block.span ?? 0); step++) used.add(step)
418
+ if (block.until !== undefined) used.add(block.until)
419
+ }
420
+ if (used.size > section.steps) return section
421
+ const steps: number[] = []
422
+ for (let step = 0, n = -1; step <= section.steps; step++) steps.push(used.has(step) ? ++n : n)
423
+ const map = (step: number) => steps[Math.min(step, section.steps)]!
424
+ const renumber = (blocks: IRBlock[]): IRBlock[] => blocks.map(block => ({
425
+ ...block,
426
+ at: map(block.at),
427
+ ...block.until === undefined ? {} : { until: map(block.until) },
428
+ ...block.node.type === 'component' && block.node.children ? { node: { ...block.node, children: renumber(block.node.children) } } : {},
429
+ }))
430
+ return { ...section, steps: map(section.steps), blocks: renumber(section.blocks) }
431
+ }
432
+
433
+ function blockTexts(block: IRBlock): string[] {
434
+ const { node } = block
435
+ switch (node.type) {
436
+ case 'markdown': return mdastTexts(node.mdast)
437
+ case 'code': return [node.title ?? '', node.value, ...(node.variants ?? []).map(variant => variant.value)]
438
+ case 'component': return [...propTexts(node.props), ...node.fallback ? mdastTexts(node.fallback) : []]
439
+ }
440
+ }
441
+
442
+ function mdastTexts(node: MdastNode): string[] {
443
+ const own: string[] = []
444
+ if (typeof node.value === 'string') own.push(node.type === 'html' ? node.value.replace(/<[^<>]*>/g, ' ') : node.value)
445
+ for (const key of ['alt', 'title'] as const) if (typeof node[key] === 'string') own.push(node[key] as string)
446
+ return [...own, ...(node.children ?? []).flatMap(mdastTexts)]
447
+ }
448
+
449
+ function propTexts(value: unknown): string[] {
450
+ if (typeof value === 'string') return [value]
451
+ if (Array.isArray(value)) return value.flatMap(propTexts)
452
+ if (value && typeof value === 'object') return Object.values(value).flatMap(propTexts)
453
+ return []
454
+ }
455
+
456
+ function sentences(text: string): string[] {
457
+ return text.split(/[。!?!?;;\r\n]+|(?<=\.)\s+/u).map(squash).filter(sentence => [...sentence].length >= MIN_SENTENCE)
458
+ }
459
+
460
+ function squash(text: string): string {
461
+ return text.replace(/\s+/g, ' ').trim()
462
+ }
463
+
464
+ function readable(text: string): string {
465
+ const decoded = text
466
+ .replace(/\\u\{([\da-f]+)\}/gi, (_, hex: string) => String.fromCodePoint(Number.parseInt(hex, 16)))
467
+ .replace(/\\u([\da-f]{4})/gi, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)))
468
+ .replace(/\\x([\da-f]{2})/gi, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)))
469
+ .replace(/\\[nrt]/g, ' ')
470
+ .replace(/\\(["'`\\/])/g, '$1')
471
+ .replace(/&#x([\da-f]+);|&#(\d+);/gi, (_, hex: string | undefined, dec: string | undefined) => String.fromCodePoint(hex ? Number.parseInt(hex, 16) : Number(dec)))
472
+ .replace(/&(amp|lt|gt|quot|apos|nbsp);/g, (_, name: string) => HTML_ENTITIES[name]!)
473
+ return `${squash(decoded)}\n${squash(decoded.replace(/<[^<>]*>/g, ''))}`
474
+ }
475
+
476
+ async function files(path: string): Promise<string[]> {
477
+ const entries = await readdir(path, { withFileTypes: true }).catch(() => undefined)
478
+ if (!entries) return (await lstat(path).catch(() => undefined))?.isFile() ? [path] : []
479
+ const found: string[] = []
480
+ for (const entry of entries) {
481
+ const child = join(path, entry.name)
482
+ if (entry.isDirectory()) found.push(...await files(child))
483
+ else if (entry.isFile()) found.push(child)
484
+ }
485
+ return found
486
+ }