@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/audience.ts
ADDED
|
@@ -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
|
+
}
|