@lincyaw/compiler 1.0.0-rc.0 → 1.0.0-rc.2

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/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @lincyaw/compiler
2
2
 
3
- Presmium's compiler: MyST source to the IR, plugin loading, compile-time execution and its cache, checks and publication checks, translations and the students' version, and running the renderers.
3
+ Presmium's compiler validates the canonical `lecture.json` teaching document and lowers it to a runtime snapshot. Plugin loading, execution, checks and renderers consume that derived snapshot.
4
4
 
5
- Part of [Presmium](https://github.com/Lincyaw/presmium), which turns lectures written in MyST Markdown into web pages, slides, Jupyter notebooks and narrated lectures.
5
+ Part of [Presmium](https://github.com/Lincyaw/presmium), which publishes structured teaching documents as web pages, slides, Jupyter notebooks and narrated lectures.
6
6
 
7
7
  ## Install
8
8
 
@@ -13,17 +13,21 @@ It comes with `@lincyaw/cli`. Plugins that use its helpers declare it as a peer
13
13
  ```ts
14
14
  import { build, Compiler } from '@lincyaw/compiler'
15
15
 
16
- const compilation = await new Compiler('lecture.md').compile()
16
+ const compilation = await new Compiler('lecture.json').compile()
17
17
  console.log(compilation.doc.sections.map(section => section.title))
18
18
  const findings = await compilation.check()
19
19
  await build(compilation, { outDir: 'dist', to: ['web'] })
20
20
  ```
21
21
 
22
+ `lecture.json` is the only runtime authoring entry. The compiler uses the same `validateDocument` contract as UI and agent changes. MyST files require a one-time offline migration; they are not an alternate entry. Rich text lowers directly from its typed tree, without a Markdown round trip.
23
+
24
+ Stable authoring step IDs and complete visual states are retained in the derived IR. A JSON pointer in diagnostics identifies the source entity; it is not a mutable source-line binding.
25
+
22
26
  `Compiler`, `Compilation`, `build`, `dev` and the options are public; the other exports serve Presmium's own packages.
23
27
 
24
28
  ## Documentation
25
29
 
26
- - [Plugin guide](https://github.com/Lincyaw/presmium/blob/main/site/plugins/lecture.en.md)
30
+ - [Plugin guide](https://github.com/Lincyaw/presmium/blob/main/site/plugins/locales/en/lecture.json)
27
31
 
28
32
  ## License
29
33
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lincyaw/compiler",
3
- "version": "1.0.0-rc.0",
4
- "description": "Presmium's compiler: MyST source to the IR, plugin loading, compile-time execution and its cache, checks and the renderers",
3
+ "version": "1.0.0-rc.2",
4
+ "description": "Presmium's compiler: structured JSON documents to runtime IR, plugin loading, compile-time execution and its cache, checks and the renderers",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -12,7 +12,7 @@
12
12
  "bugs": "https://github.com/Lincyaw/presmium/issues",
13
13
  "keywords": [
14
14
  "presmium",
15
- "myst",
15
+ "json",
16
16
  "compiler"
17
17
  ],
18
18
  "type": "module",
@@ -29,22 +29,21 @@
29
29
  "src"
30
30
  ],
31
31
  "dependencies": {
32
- "html-tag-names": "2.1.0",
32
+ "content-disposition": "1.0.0",
33
+ "cors": "2.8.5",
33
34
  "jiti": "^2.7.0",
34
- "mathml-tag-names": "4.0.0",
35
35
  "myst-common": "1.10.1",
36
- "myst-ext-button": "0.0.2",
37
- "myst-ext-card": "1.0.9",
38
- "myst-ext-grid": "1.1.1",
39
- "myst-ext-tabs": "1.0.10",
40
- "myst-parser": "^1.7.4",
41
36
  "myst-transforms": "1.3.51",
37
+ "resolve.exports": "2.0.4",
42
38
  "semver": "^7.8.5",
43
- "svg-tag-names": "3.0.1",
39
+ "send": "1.2.1",
44
40
  "yaml": "^2.9.1",
45
- "@lincyaw/kernel": "1.0.0-rc.0"
41
+ "@lincyaw/kernel": "1.0.0-rc.2"
46
42
  },
47
43
  "devDependencies": {
48
- "@types/semver": "^7.8.0"
44
+ "@types/content-disposition": "^0.5.9",
45
+ "@types/cors": "^2.8.19",
46
+ "@types/semver": "^7.8.0",
47
+ "@types/send": "^1.2.1"
49
48
  }
50
49
  }
package/src/assets.ts ADDED
@@ -0,0 +1,163 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
3
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
4
+ import { deckLocale, escapeHtml, NON_FILE_URL } from '@lincyaw/kernel'
5
+ import type { DocumentLink, Finding, LocalAsset, SourcePos } from '@lincyaw/kernel'
6
+ import { fileVersion, isInside } from '@lincyaw/kernel/node'
7
+ import type { Compilation } from './compiler.ts'
8
+ import { DECK_ENTRY, WORK_DIR } from './paths.ts'
9
+ import { lineContaining } from './source-lines.ts'
10
+
11
+ const hashes = new Map<string, { stamp: string, hash: string }>()
12
+
13
+ const RACY_MS = 2000
14
+
15
+ export function fileHash(file: string): string {
16
+ const stat = statSync(file, { bigint: true })
17
+ const stamp = fileVersion(stat)
18
+ const known = hashes.get(file)
19
+ if (known?.stamp === stamp) return known.hash
20
+ const hash = createHash('sha256').update(readFileSync(file)).digest('hex')
21
+ if (Date.now() - Number(stat.ctimeMs) > RACY_MS && Date.now() - Number(stat.mtimeMs) > RACY_MS) hashes.set(file, { stamp, hash })
22
+ else hashes.delete(file)
23
+ return hash
24
+ }
25
+
26
+ function unpublished(name: string): boolean {
27
+ return name.startsWith('.') || name === 'node_modules'
28
+ }
29
+
30
+ function directoryFiles(dir: string, prefix = ''): string[] {
31
+ return readdirSync(join(dir, prefix), { withFileTypes: true })
32
+ .filter(entry => !unpublished(entry.name))
33
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
34
+ .flatMap((entry) => {
35
+ const path = prefix ? `${prefix}/${entry.name}` : entry.name
36
+ if (entry.isDirectory()) return existsSync(join(dir, path, DECK_ENTRY)) ? [] : directoryFiles(dir, path)
37
+ return entry.isFile() ? [path] : []
38
+ })
39
+ }
40
+
41
+ const LISTING = {
42
+ 'zh-CN': { count: (n: number) => `${n} 个文件,点击下载`, files: '文件' },
43
+ 'en': { count: (n: number) => `${n} file${n === 1 ? '' : 's'}; select one to download it`, files: 'Files' },
44
+ }
45
+
46
+ function publishedDirectory(dir: string, stage: string, lang?: string): { file: string, name: string } {
47
+ const files = directoryFiles(dir)
48
+ const hash = createHash('sha256')
49
+ for (const file of files) hash.update(file).update('\0').update(fileHash(join(dir, file))).update('\0')
50
+ const name = `${hash.digest('hex').slice(0, 8)}-${basename(dir)}`
51
+ const target = join(stage, name)
52
+ if (existsSync(target)) return { file: target, name }
53
+ const partial = `${target}.${process.pid}`
54
+ rmSync(partial, { recursive: true, force: true })
55
+ mkdirSync(partial, { recursive: true })
56
+ for (const file of files) cpSync(join(dir, file), join(partial, file))
57
+ if (!files.includes('index.html')) {
58
+ const t = LISTING[deckLocale(lang)]
59
+ const href = (file: string) => file.split('/').map(encodeURIComponent).join('/')
60
+ const items = files.map(file => `<li><a href="${escapeHtml(href(file))}" download>${escapeHtml(file)}</a> <small>${statSync(join(dir, file)).size} B</small></li>`)
61
+ writeFileSync(join(partial, 'index.html'), `<!doctype html>
62
+ <html lang="${escapeHtml(lang ?? 'zh-CN')}">
63
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(basename(dir))}</title>
64
+ <style>body{font:16px/1.6 system-ui,sans-serif;max-width:48rem;margin:2rem auto;padding:0 1rem}small{color:#666}</style></head>
65
+ <body><h1>${escapeHtml(basename(dir))}/</h1><p>${escapeHtml(t.count(files.length))}</p>
66
+ <ul aria-label="${escapeHtml(t.files)}">
67
+ ${items.join('\n')}
68
+ </ul></body></html>
69
+ `)
70
+ }
71
+ try {
72
+ renameSync(partial, target)
73
+ } catch {
74
+ rmSync(partial, { recursive: true, force: true })
75
+ }
76
+ return { file: target, name }
77
+ }
78
+
79
+ export function publishesDirectory(target: string, deckDir: string): boolean {
80
+ return Boolean(lstatSync(target, { throwIfNoEntry: false })?.isDirectory()) && target !== deckDir && isInside(target, deckDir) && !existsSync(join(target, DECK_ENTRY))
81
+ }
82
+
83
+ export function unpublishedDirectory(target: string, deckDir: string): string | undefined {
84
+ return relative(deckDir, target).split(sep).some(unpublished) ? 'hidden directories and node_modules are not published' : undefined
85
+ }
86
+
87
+ export function directoryAsset(target: string, deckDir: string, stage: string, lang: string | undefined, warn: (message: string) => void): { file: string, name: string } | undefined {
88
+ const refused = unpublishedDirectory(target, deckDir)
89
+ if (refused) {
90
+ warn(refused)
91
+ return undefined
92
+ }
93
+ return publishedDirectory(target, stage, lang)
94
+ }
95
+
96
+ const FILES_DIR = 'files'
97
+
98
+ export function localAsset(deckDir: string, source: SourcePos, url: string): { file: string, name: string } | undefined {
99
+ if (NON_FILE_URL.test(url)) return undefined
100
+ const path = url.replace(/[?#].*$/s, '')
101
+ if (!path) return undefined
102
+ let name = path
103
+ try {
104
+ name = decodeURIComponent(path)
105
+ } catch {
106
+ }
107
+ const file = name.startsWith('/') ? join(deckDir, 'public', name) : resolve(dirname(source.file), name)
108
+ if (!existsSync(file) || !statSync(file).isFile()) return undefined
109
+ return { file, name: `${fileHash(file).slice(0, 8)}-${basename(file)}` }
110
+ }
111
+
112
+ function localTarget(source: SourcePos, url: string): string | undefined {
113
+ if (NON_FILE_URL.test(url)) return undefined
114
+ const path = url.replace(/[?#].*$/s, '')
115
+ if (!path || path.startsWith('/')) return undefined
116
+ let name = path
117
+ try {
118
+ name = decodeURIComponent(path)
119
+ } catch {
120
+ }
121
+ return resolve(dirname(source.file), name)
122
+ }
123
+
124
+ export function documentLink(compilation: Compilation, source: SourcePos, url: string): DocumentLink | undefined {
125
+ const target = localTarget(source, url)
126
+ if (!target) return undefined
127
+ const dir = basename(target) === DECK_ENTRY && statSync(target, { throwIfNoEntry: false })?.isFile() ? dirname(target)
128
+ : statSync(target, { throwIfNoEntry: false })?.isDirectory() && existsSync(join(target, DECK_ENTRY)) ? target
129
+ : undefined
130
+ if (dir === undefined || !isInside(dir, compilation.workspaceRoot)) return undefined
131
+ const hash = url.indexOf('#')
132
+ let fragment = hash < 0 ? '' : url.slice(hash + 1)
133
+ try {
134
+ fragment = decodeURIComponent(fragment)
135
+ } catch {
136
+ }
137
+ return { dir: relative(compilation.workspaceRoot, dir).split(sep).join('/'), ...fragment ? { fragment } : {} }
138
+ }
139
+
140
+ export function publishedAsset(compilation: Compilation, source: SourcePos, url: string, report: (finding: Finding) => void): LocalAsset | undefined {
141
+ const { deckDir, workspaceRoot } = compilation
142
+ const warn = (message: string) => report({ checkId: 'presmium.links', severity: 'warning', message, source: { file: source.file, line: lineContaining(source, url) } })
143
+ const target = localTarget(source, url)
144
+ const found = localAsset(deckDir, source, url)
145
+ if (found && target && basename(realpathSync(found.file)) === DECK_ENTRY) {
146
+ const linked = documentLink(compilation, source, url)
147
+ warn(`${url} is not published: it is the source of ${linked?.dir ? `the document ${linked.dir}` : 'a document'}, which a workspace site links as its page; this build keeps the link as written`)
148
+ return undefined
149
+ }
150
+ if (found && !isInside(found.file, workspaceRoot) && existsSync(join(workspaceRoot, '.git'))) {
151
+ warn(`${url} is not published: the file lies outside the deck's repository`)
152
+ return undefined
153
+ }
154
+ if (found) return { ...found, directory: false }
155
+ if (!target) return undefined
156
+ if (!existsSync(target)) {
157
+ warn(`${url} is not published: there is no such file`)
158
+ return undefined
159
+ }
160
+ if (!publishesDirectory(target, deckDir)) return undefined
161
+ const directory = directoryAsset(target, deckDir, join(deckDir, WORK_DIR, FILES_DIR), compilation.doc.meta.lang, message => warn(`${url} is not published: ${message}`))
162
+ return directory && { ...directory, directory: true }
163
+ }
package/src/audience.ts CHANGED
@@ -1,10 +1,8 @@
1
1
  import { lstat, readdir, readFile } from 'node:fs/promises'
2
2
  import { basename, extname, join, relative } from 'node:path'
3
- import { irBlocks, isRecord, nodeMdast } from '@lincyaw/kernel'
3
+ import { compactSteps, irBlocks, isRecord, nodeMdast, productionAssetIds, propText } from '@lincyaw/kernel'
4
4
  import type { Finding, IRBlock, IRDocument, IRNode, IRSection, MdastNode, SourcePos } from '@lincyaw/kernel'
5
5
  import { resolveReferences } from './references.ts'
6
- import { Parser, sourceOf } from './source.ts'
7
- import type { ParsedFile } from './source.ts'
8
6
 
9
7
  export interface Removed {
10
8
  source: SourcePos
@@ -25,172 +23,33 @@ export interface Withheld {
25
23
  sentences: string[]
26
24
  }
27
25
 
28
- export const AUDIENCE_SCOPE = 'presmiumAudience'
29
-
30
26
  const MIN_SENTENCE = 12
31
27
 
32
28
  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
29
 
34
30
  const HTML_ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: '\'', nbsp: ' ' }
35
31
 
36
- export function forAudience(audience: string[] | undefined, name: string): boolean {
32
+ export function includesAudience(audience: string[] | undefined, name: string): boolean {
37
33
  return !audience || audience.includes(name)
38
34
  }
39
35
 
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
36
  export function markOf(node: unknown): string[] | undefined {
49
37
  const audience = isRecord(node) && isRecord(node.data) ? node.data.audience : undefined
50
38
  return Array.isArray(audience) ? audience as string[] : undefined
51
39
  }
52
40
 
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 } {
41
+ /** Removes the sections and blocks not for the audience; `keeps` tells whether a finding lies in what is left. */
42
+ export function filterForAudience(doc: IRDocument, name: string, marks: AudienceMark[] = []): { doc: IRDocument, removed: Removed[], keeps: (finding: Finding) => boolean } {
183
43
  const removed: Removed[] = []
184
44
  const sections: IRSection[] = []
185
- const compacted = new Set<string>()
186
45
  for (const section of doc.sections) {
187
- if (!forAudience(section.audience, name)) {
46
+ if (!includesAudience(section.audience, name)) {
188
47
  removed.push({ source: section.source, audience: section.audience!, title: section.title, blocks: section.blocks })
189
48
  continue
190
49
  }
191
50
  let dropped = false
192
51
  const keep = (blocks: IRBlock[]): IRBlock[] => blocks.flatMap((block) => {
193
- if (!forAudience(block.audience, name)) {
52
+ if (!includesAudience(block.audience, name)) {
194
53
  dropped = true
195
54
  removed.push({ source: block.source, audience: block.audience!, blocks: [block] })
196
55
  return []
@@ -199,20 +58,20 @@ export function audienceView(doc: IRDocument, name: string, marks: AudienceMark[
199
58
  return [node === block.node ? block : { ...block, node }]
200
59
  })
201
60
  const blocks = keep(section.blocks)
202
- if (dropped) compacted.add(section.id)
203
61
  sections.push(dropped ? compactSteps({ ...section, blocks }) : same(blocks, section.blocks) ? section : { ...section, blocks })
204
62
  }
205
- const marked = marks.filter(mark => !forAudience(mark.audience, name)).map(mark => mark.source)
63
+ const marked = marks.filter(mark => !includesAudience(mark.audience, name)).map(mark => mark.source)
206
64
  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
65
  const spans = [...leftOut(doc, name, removed), ...marked]
210
66
  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))
67
+ const stepIds = new Set(sections.flatMap(section => section.stepIds ?? []))
68
+ const production = doc.production && { ...doc.production, ...doc.production.captionTimings ? { captionTimings: Object.fromEntries(Object.entries(doc.production.captionTimings).filter(([id]) => stepIds.has(id))) } : {}, mediaClips: doc.production.mediaClips.filter(clip => stepIds.has(clip.attachment.stepId)) }
211
69
  return {
212
70
  doc: {
213
71
  ...doc,
214
72
  sections: sections.map((section, i) => (section.index === i + 1 ? section : { ...section, index: i + 1 })),
215
73
  diagnostics: doc.diagnostics.filter(keeps),
74
+ ...production ? { production, productionAssets: Object.fromEntries(productionAssetIds(production, sections).flatMap(id => doc.productionAssets?.[id] ? [[id, doc.productionAssets[id]!]] : [])) } : {},
216
75
  },
217
76
  removed,
218
77
  keeps,
@@ -222,7 +81,7 @@ export function audienceView(doc: IRDocument, name: string, marks: AudienceMark[
222
81
  function leftOut(doc: IRDocument, name: string, removed: Removed[]): SourcePos[] {
223
82
  const spans: SourcePos[] = removed.filter(item => !item.nested && item.title === undefined).map(item => item.source)
224
83
  doc.sections.forEach((section, i) => {
225
- if (forAudience(section.audience, name)) return
84
+ if (includesAudience(section.audience, name)) return
226
85
  const next = doc.sections.slice(i + 1).find(later => later.source.file === section.source.file)
227
86
  spans.push({ file: section.source.file, line: section.source.line, endLine: next ? next.source.line - 1 : Number.MAX_SAFE_INTEGER })
228
87
  for (const block of irBlocks(section.blocks)) if (block.source.file !== section.source.file) spans.push(block.source)
@@ -231,7 +90,7 @@ function leftOut(doc: IRDocument, name: string, removed: Removed[]): SourcePos[]
231
90
  }
232
91
 
233
92
  export function audienceDocument(doc: IRDocument, name: string, filled: WeakSet<object>, marks: AudienceMark[] = []): IRDocument {
234
- const { doc: found, removed } = audienceView(doc, name, marks)
93
+ const { doc: found, removed } = filterForAudience(doc, name, marks)
235
94
  const view = { ...found, meta: { ...found.meta, audience: name } }
236
95
  if (removed.length === 0) return view
237
96
  const own = detached(view, filled)
@@ -252,7 +111,7 @@ export function emptyReferences(doc: IRDocument): WeakSet<object> {
252
111
  export function withheld(removed: Removed[], kept: IRDocument): Withheld[] {
253
112
  const shown = ownText(kept)
254
113
  return removed.map(({ source, audience, title, blocks }) => {
255
- const texts = [...title ? [title] : [], ...blocks.flatMap(block => [...irBlocks([block])].flatMap(blockTexts))]
114
+ const texts = [...title ? [title] : [], ...blocks.flatMap(block => [...irBlocks([block])].flatMap(sentenceSources))]
256
115
  return { source, audience, sentences: [...new Set(texts.flatMap(sentences))].filter(sentence => !shown.includes(sentence)) }
257
116
  })
258
117
  }
@@ -263,7 +122,7 @@ export function unshown(left: Withheld[], docs: IRDocument[]): Withheld[] {
263
122
  }
264
123
 
265
124
  function ownText(doc: IRDocument): string {
266
- return doc.sections.flatMap(section => [section.title, ...[...irBlocks(section.blocks)].flatMap(blockTexts)]).map(squash).join('\n')
125
+ return doc.sections.flatMap(section => [section.title, ...[...irBlocks(section.blocks)].flatMap(sentenceSources)]).map(squash).join('\n')
267
126
  }
268
127
 
269
128
  export async function scanOutputs(left: Withheld[], paths: string[], audience: string, base: string, quote = true): Promise<Finding[]> {
@@ -319,7 +178,7 @@ function prune<T>(value: T, name: string, left: MdastNode[]): T {
319
178
  if (Array.isArray(value)) {
320
179
  let changed = false
321
180
  const kept = value.flatMap((item: unknown) => {
322
- if (!forAudience(markOf(item), name)) {
181
+ if (!includesAudience(markOf(item), name)) {
323
182
  left.push(item as MdastNode)
324
183
  changed = true
325
184
  return []
@@ -382,75 +241,21 @@ function withoutLeaks(view: IRDocument, removed: Removed[], name: string): IRDoc
382
241
  return findings.length === 0 ? view : { ...view, sections, diagnostics: [...view.diagnostics, ...findings] }
383
242
  }
384
243
 
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[] {
244
+ /** The texts of a block that the leak scan splits into sentences: its own source, without recorded output. */
245
+ function sentenceSources(block: IRBlock): string[] {
434
246
  const { node } = block
435
247
  switch (node.type) {
436
- case 'markdown': return mdastTexts(node.mdast)
248
+ case 'markdown': return mdastSentenceSources(node.mdast)
437
249
  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) : []]
250
+ case 'component': return [...propText(node.props), ...node.fallback ? mdastSentenceSources(node.fallback) : []]
439
251
  }
440
252
  }
441
253
 
442
- function mdastTexts(node: MdastNode): string[] {
254
+ function mdastSentenceSources(node: MdastNode): string[] {
443
255
  const own: string[] = []
444
256
  if (typeof node.value === 'string') own.push(node.type === 'html' ? node.value.replace(/<[^<>]*>/g, ' ') : node.value)
445
257
  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 []
258
+ return [...own, ...(node.children ?? []).flatMap(mdastSentenceSources)]
454
259
  }
455
260
 
456
261
  function sentences(text: string): string[] {