@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 +8 -4
- package/package.json +12 -13
- package/src/assets.ts +163 -0
- package/src/audience.ts +21 -216
- package/src/build.ts +101 -0
- package/src/{host.ts → compilation-actions.ts} +14 -7
- package/src/compile-queue.ts +28 -0
- package/src/compiler.ts +269 -195
- package/src/dev.ts +222 -0
- package/src/directives.ts +1 -0
- package/src/document-lowering.ts +176 -0
- package/src/environments.ts +6 -4
- package/src/execute.ts +50 -36
- package/src/export.ts +49 -0
- package/src/forbid.ts +6 -12
- package/src/frontmatter.ts +16 -0
- package/src/index.ts +21 -11
- package/src/{dependencies.ts → inputs.ts} +22 -13
- package/src/links.ts +21 -45
- package/src/logging.ts +16 -0
- package/src/node-services.ts +33 -0
- package/src/paths.ts +12 -26
- package/src/plugins.ts +53 -10
- package/src/references.ts +31 -20
- package/src/renderers.ts +61 -401
- package/src/sections.ts +6 -80
- package/src/serve.ts +58 -56
- package/src/source-lines.ts +47 -0
- package/src/translations.ts +51 -57
- package/src/validate.ts +3 -1
- package/src/views.ts +2 -0
- package/src/workspace-settings.ts +59 -0
- package/src/workspace.ts +17 -7
- package/src/blocks.ts +0 -557
- package/src/published.ts +0 -105
- package/src/settings.ts +0 -105
- package/src/source.ts +0 -300
- package/src/steps.ts +0 -66
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @lincyaw/compiler
|
|
2
2
|
|
|
3
|
-
Presmium's compiler
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
4
|
-
"description": "Presmium's compiler:
|
|
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
|
-
"
|
|
15
|
+
"json",
|
|
16
16
|
"compiler"
|
|
17
17
|
],
|
|
18
18
|
"type": "module",
|
|
@@ -29,22 +29,21 @@
|
|
|
29
29
|
"src"
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"
|
|
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
|
-
"
|
|
39
|
+
"send": "1.2.1",
|
|
44
40
|
"yaml": "^2.9.1",
|
|
45
|
-
"@lincyaw/kernel": "1.0.0-rc.
|
|
41
|
+
"@lincyaw/kernel": "1.0.0-rc.2"
|
|
46
42
|
},
|
|
47
43
|
"devDependencies": {
|
|
48
|
-
"@types/
|
|
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
|
|
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
|
-
|
|
54
|
-
|
|
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 (!
|
|
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 (!
|
|
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 => !
|
|
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 (
|
|
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 } =
|
|
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(
|
|
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(
|
|
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 (!
|
|
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
|
-
|
|
386
|
-
|
|
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
|
|
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 [...
|
|
250
|
+
case 'component': return [...propText(node.props), ...node.fallback ? mdastSentenceSources(node.fallback) : []]
|
|
439
251
|
}
|
|
440
252
|
}
|
|
441
253
|
|
|
442
|
-
function
|
|
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(
|
|
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[] {
|