@lincyaw/addon-publish 0.0.0-stage → 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.
@@ -0,0 +1,29 @@
1
+ import { OFFLINE_REQUESTS } from '@lincyaw/kernel'
2
+
3
+ export interface OfflineRuntimes {
4
+ bytes: number
5
+ saved: boolean
6
+ }
7
+
8
+ async function ask<T>(type: string, timeoutMs: number): Promise<T | undefined> {
9
+ if (typeof navigator === 'undefined' || !navigator.serviceWorker) return undefined
10
+ const worker = navigator.serviceWorker.controller ?? (await navigator.serviceWorker.getRegistration())?.active
11
+ if (!worker) return undefined
12
+ return new Promise((resolve) => {
13
+ const channel = new MessageChannel()
14
+ const timer = setTimeout(() => resolve(undefined), timeoutMs)
15
+ channel.port1.onmessage = ({ data }: MessageEvent<T>) => {
16
+ clearTimeout(timer)
17
+ resolve(data)
18
+ }
19
+ worker.postMessage({ type }, [channel.port2])
20
+ })
21
+ }
22
+
23
+ export function offlineRuntimes(): Promise<OfflineRuntimes | undefined> {
24
+ return ask<OfflineRuntimes>(OFFLINE_REQUESTS.runtimes, 3000)
25
+ }
26
+
27
+ export async function saveOffline(): Promise<boolean> {
28
+ return Boolean((await ask<{ ok: boolean }>(OFFLINE_REQUESTS.save, 600_000))?.ok)
29
+ }
@@ -0,0 +1,34 @@
1
+ import { currentLocale, TOOLBAR } from '@lincyaw/host'
2
+ import type { PluginDefinition, Unsubscribe } from '@lincyaw/kernel'
3
+ import { createElement } from 'react'
4
+ import { version } from '../../package.json'
5
+ import PmContrastToggle from '../../components/PmContrastToggle.tsx'
6
+ import PmSaveOffline from '../../components/PmSaveOffline.tsx'
7
+ import { MESSAGES, PUBLISH_ID } from '../domain/index.ts'
8
+ import { initHighContrast } from './contrast.ts'
9
+ import './contrast.css'
10
+
11
+ const ToolbarToggle = () => createElement(PmContrastToggle, { compact: true })
12
+
13
+ let unregister: Unsubscribe[] = []
14
+
15
+ export const publishPlugin: PluginDefinition = {
16
+ manifest: { id: PUBLISH_ID, name: { 'zh-CN': '发布', en: 'Publishing' }, version, kernel: '^1.0.0', requires: [{ token: TOOLBAR, optional: true }] },
17
+ setup(ctx) {
18
+ initHighContrast()
19
+ const toolbar = ctx.tryGet(TOOLBAR)
20
+ const t = MESSAGES[currentLocale()]
21
+ unregister = toolbar
22
+ ? [
23
+ toolbar.register({ id: PUBLISH_ID, title: t.highContrast, component: ToolbarToggle, order: 20 }),
24
+ toolbar.register({ id: `${PUBLISH_ID}.offline`, title: t.savedOffline, component: PmSaveOffline, order: 21 }),
25
+ ]
26
+ : []
27
+ },
28
+ dispose() {
29
+ unregister.forEach(off => off())
30
+ unregister = []
31
+ },
32
+ }
33
+
34
+ export default publishPlugin
@@ -0,0 +1,27 @@
1
+ export const ASSETS_DIR = '.presmium/assets'
2
+
3
+ export const MEDIA_EXTENSIONS = ['svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'ico', 'bmp', 'woff', 'woff2', 'ttf', 'otf', 'mp4', 'webm', 'ogg', 'mp3', 'wav', 'm4a', 'pdf']
4
+
5
+ const TYPES: Record<string, string> = {
6
+ 'image/svg+xml': 'svg',
7
+ 'image/png': 'png',
8
+ 'image/jpeg': 'jpg',
9
+ 'image/gif': 'gif',
10
+ 'image/webp': 'webp',
11
+ 'image/avif': 'avif',
12
+ 'image/x-icon': 'ico',
13
+ 'image/vnd.microsoft.icon': 'ico',
14
+ }
15
+
16
+ export function assetExtension(url: string, contentType: string | null): string | undefined {
17
+ const path = (() => {
18
+ try {
19
+ return new URL(url).pathname
20
+ } catch {
21
+ return ''
22
+ }
23
+ })()
24
+ const ext = /\.([a-z\d]+)$/i.exec(path)?.[1]?.toLowerCase()
25
+ if (ext && MEDIA_EXTENSIONS.includes(ext)) return ext
26
+ return TYPES[(contentType ?? '').split(';')[0]!.trim().toLowerCase()]
27
+ }
@@ -0,0 +1,14 @@
1
+ import { isRecord } from '@lincyaw/kernel'
2
+
3
+ export const PUBLISH_ID = 'presmium.publish'
4
+
5
+ export interface PublishOptions {
6
+ offline: boolean
7
+ }
8
+
9
+ export function toPublishOptions(headmatter: unknown): PublishOptions {
10
+ const presmium = isRecord(headmatter) ? headmatter.presmium : undefined
11
+ const own = isRecord(presmium) ? presmium[PUBLISH_ID] : undefined
12
+ return { offline: !(isRecord(own) && own.offline === false) }
13
+ }
14
+
@@ -0,0 +1,15 @@
1
+ export function exportedSlides(count: number, range?: ReadonlySet<number>): number[] {
2
+ return Array.from({ length: count }, (_, i) => i + 1).filter(slide => !range || range.has(slide))
3
+ }
4
+
5
+ export function parseRange(range: string | undefined): Set<number> | undefined {
6
+ if (range === undefined) return undefined
7
+ const slides = new Set<number>()
8
+ for (const part of range.split(',').map(p => p.trim()).filter(Boolean)) {
9
+ const match = /^(\d+)(?:-(\d+))?$/.exec(part)
10
+ if (!match) throw new Error(`invalid range "${range}"`)
11
+ const from = Number(match[1])
12
+ for (let n = from; n <= Number(match[2] ?? from); n++) slides.add(n)
13
+ }
14
+ return slides
15
+ }
@@ -0,0 +1,10 @@
1
+ export { ASSETS_DIR, assetExtension, MEDIA_EXTENSIONS } from './assets.ts'
2
+ export { PUBLISH_ID, toPublishOptions } from './config.ts'
3
+ export type { PublishOptions } from './config.ts'
4
+ export { exportedSlides, parseRange } from './export.ts'
5
+ export { linkLine, normalizeUrl, slideLinks } from './links.ts'
6
+ export type { DeclaredLink } from './links.ts'
7
+ export { MESSAGES } from './messages.ts'
8
+ export type { PublishMessages } from './messages.ts'
9
+ export { diffReport, pageStatus, pairPages, parsePageFile, parseWebFile, reportMarkdown } from './visual-diff.ts'
10
+ export type { DiffSide, PageRef, PageStatus, VisualDiff, VisualDiffReport } from './visual-diff.ts'
@@ -0,0 +1,70 @@
1
+ import type { IRBlock, IRDocument, MdastNode, SourcePos } from '@lincyaw/kernel'
2
+
3
+ export interface DeclaredLink {
4
+ url: string
5
+ slide: number
6
+ source: SourcePos
7
+ }
8
+
9
+ const ATTRIBUTE = /\s(?:href|to|[\w-]*link|[\w-]*url)\s*=\s*(["'])(https?:\/\/[^"']+)\1/gi
10
+ const JSON_KEY = /"(?:href|to|[\w-]*link|[\w-]*url)"\s*:\s*"(https?:\/\/[^"\\\s]+)"/gi
11
+ const LINK_KEY = /^(?:href|to|.*link|.*url)$/i
12
+
13
+ function mdastLinks(node: MdastNode, found: string[]): void {
14
+ if ((node.type === 'link' || node.type === 'definition') && typeof node.url === 'string') found.push(node.url)
15
+ if (node.type === 'html' && typeof node.value === 'string') {
16
+ const html = node.value.replaceAll('&quot;', '"')
17
+ found.push(...[...html.matchAll(ATTRIBUTE)].map(m => m[2]!), ...[...html.matchAll(JSON_KEY)].map(m => m[1]!))
18
+ }
19
+ node.children?.forEach(child => mdastLinks(child, found))
20
+ }
21
+
22
+ function propLinks(value: unknown, found: string[], key = ''): void {
23
+ if (typeof value === 'string' && LINK_KEY.test(key)) found.push(value)
24
+ else if (Array.isArray(value)) value.forEach(item => propLinks(item, found, key))
25
+ else if (typeof value === 'object' && value !== null) Object.entries(value).forEach(([k, v]) => propLinks(v, found, k))
26
+ }
27
+
28
+ function blockLinks(block: IRBlock): string[] {
29
+ const found: string[] = []
30
+ const { node } = block
31
+ if (node.type === 'markdown') mdastLinks(node.mdast, found)
32
+ else if (node.type === 'code' && node.origin?.permalink) found.push(node.origin.permalink)
33
+ else if (node.type === 'component') {
34
+ propLinks(node.props, found)
35
+ for (const child of node.children ?? []) found.push(...blockLinks(child))
36
+ }
37
+ return found
38
+ }
39
+
40
+ export function slideLinks(doc: IRDocument, range?: Set<number>): DeclaredLink[] {
41
+ const links = new Map<string, DeclaredLink>()
42
+ for (const section of doc.sections) {
43
+ const hints = section.hints.slides ?? {}
44
+ if (hints.hide || hints.disabled || (range && !range.has(section.index))) continue
45
+ for (const block of section.blocks.filter(b => b.role === 'visual')) {
46
+ for (const raw of blockLinks(block)) {
47
+ const url = /^https?:\/\//i.test(raw) ? normalizeUrl(raw.replaceAll('&amp;', '&')) : undefined
48
+ if (url && !links.has(url)) links.set(url, { url, slide: section.index, source: block.source })
49
+ }
50
+ }
51
+ }
52
+ return [...links.values()]
53
+ }
54
+
55
+ export function linkLine(lines: string[], url: string, from: number, to = lines.length + 1): number | undefined {
56
+ const forms = [url, url.replace(/\/$/, ''), url.replaceAll('&', '&amp;')].map(form => form.replace(/[.*+?^$()|[\]{}\\]/g, '\\$&'))
57
+ const written = new RegExp(`(?:${forms.join('|')})(?=$|[\\s"'<>)\\]])`)
58
+ for (let i = Math.max(from, 1); i < Math.min(to, lines.length + 1); i++) {
59
+ if (written.test(lines[i - 1]!)) return i
60
+ }
61
+ return undefined
62
+ }
63
+
64
+ export function normalizeUrl(url: string): string | undefined {
65
+ try {
66
+ return new URL(url).href
67
+ } catch {
68
+ return undefined
69
+ }
70
+ }
@@ -0,0 +1,23 @@
1
+ import type { Locale } from '@lincyaw/kernel'
2
+
3
+ const zhCN = {
4
+ highContrast: '高对比度',
5
+ highContrastState: (on: boolean) => `高对比度:${on ? '开' : '关'}`,
6
+ saveOffline: (megabytes: number) => `保存以离线使用(${megabytes} MB)`,
7
+ savingOffline: '正在保存以离线使用…',
8
+ savedOffline: '已保存,可离线使用',
9
+ saveOfflineFailed: '无法保存以离线使用。请联网后重试。',
10
+ }
11
+
12
+ export type PublishMessages = typeof zhCN
13
+
14
+ const en: PublishMessages = {
15
+ highContrast: 'High contrast',
16
+ highContrastState: on => `High contrast: ${on ? 'on' : 'off'}`,
17
+ saveOffline: megabytes => `Save for Offline Use (${megabytes} MB)`,
18
+ savingOffline: 'Saving for offline use…',
19
+ savedOffline: 'Saved for offline use',
20
+ saveOfflineFailed: 'Couldn’t save for offline use. Please try again when online.',
21
+ }
22
+
23
+ export const MESSAGES: Record<Locale, PublishMessages> = { 'zh-CN': zhCN, en }
@@ -0,0 +1,81 @@
1
+ export interface PageRef {
2
+ id: string
3
+ slide: number
4
+ step: number
5
+ }
6
+
7
+ export type PageStatus = 'same' | 'changed' | 'added' | 'removed'
8
+
9
+ export interface VisualDiff extends PageRef {
10
+ status: PageStatus
11
+ ratio: number
12
+ diffPixels: number
13
+ }
14
+
15
+ export interface DiffSide {
16
+ ref: string
17
+ commit: string
18
+ }
19
+
20
+ export interface VisualDiffReport {
21
+ a: DiffSide
22
+ b: DiffSide
23
+ threshold: number
24
+ pages: VisualDiff[]
25
+ listed: VisualDiff[]
26
+ }
27
+
28
+ export function parsePageFile(name: string): PageRef | undefined {
29
+ const match = /^(\d+)(?:-(\d+))?\.png$/.exec(name)
30
+ if (!match) return undefined
31
+ return { id: name.slice(0, -4), slide: Number(match[1]), step: match[2] === undefined ? 0 : Number(match[2]) - 1 }
32
+ }
33
+
34
+ export function parseWebFile(name: string, sections: ReadonlyMap<string, number>): PageRef | undefined {
35
+ if (!name.endsWith('.png')) return undefined
36
+ const stem = name.slice(0, -4)
37
+ const present = /^present-(.+)$/.exec(stem)?.[1]
38
+ return { id: `web/${stem}`, slide: present === undefined ? 0 : sections.get(present) ?? 0, step: 0 }
39
+ }
40
+
41
+ export function pairPages(a: string[], b: string[], parse: (name: string) => PageRef | undefined = parsePageFile): { page: PageRef, a?: string, b?: string }[] {
42
+ const pages = new Map<string, { page: PageRef, a?: string, b?: string }>()
43
+ for (const [side, files] of [['a', a], ['b', b]] as const) {
44
+ for (const file of files) {
45
+ const page = parse(file)
46
+ if (page) pages.set(page.id, { page, ...pages.get(page.id), [side]: file })
47
+ }
48
+ }
49
+ return [...pages.values()].sort((x, y) => x.page.slide - y.page.slide || x.page.step - y.page.step || x.page.id.localeCompare(y.page.id))
50
+ }
51
+
52
+ export function pageStatus(ratio: number, threshold: number, a: boolean, b: boolean): PageStatus {
53
+ if (!a) return 'added'
54
+ if (!b) return 'removed'
55
+ return ratio > threshold ? 'changed' : 'same'
56
+ }
57
+
58
+ export function diffReport(a: DiffSide, b: DiffSide, threshold: number, pages: VisualDiff[]): VisualDiffReport {
59
+ return { a, b, threshold, pages, listed: pages.filter(page => page.status !== 'same') }
60
+ }
61
+
62
+ const percent = (ratio: number) => `${(ratio * 100).toFixed(2)}%`
63
+
64
+ export function reportMarkdown(report: VisualDiffReport): string {
65
+ const side = (s: DiffSide) => `\`${s.ref}\` (${s.commit.slice(0, 12)})`
66
+ const lines = [
67
+ `# Visual diff: ${side(report.a)} → ${side(report.b)}`,
68
+ '',
69
+ `${report.listed.length} of ${report.pages.length} pages differ (threshold ${percent(report.threshold)}).`,
70
+ ]
71
+ if (report.listed.length > 0) {
72
+ lines.push('', '| Page | Slide | Step | Status | Differing pixels |', '|---|---|---|---|---|')
73
+ for (const p of report.listed) lines.push(`| ${p.id} | ${p.slide} | ${p.step} | ${p.status} | ${percent(p.ratio)} |`)
74
+ for (const p of report.listed) {
75
+ lines.push('', `## ${p.id}`, '')
76
+ const images = [p.status !== 'added' && `![a](a/${p.id}.png)`, p.status !== 'removed' && `![b](b/${p.id}.png)`, p.status === 'changed' && `![diff](diff/${p.id}.png)`]
77
+ lines.push(images.filter(Boolean).join(' '))
78
+ }
79
+ }
80
+ return `${lines.join('\n')}\n`
81
+ }
@@ -0,0 +1,78 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync } from 'node:fs'
3
+ import { mkdir, readdir, rm, writeFile } from 'node:fs/promises'
4
+ import { basename, dirname, join, relative, sep } from 'node:path'
5
+ import { docBlocks, errorMessage, nodeMdast } from '@lincyaw/kernel'
6
+ import type { IRBlock, IRPass, MdastNode } from '@lincyaw/kernel'
7
+ import { ASSETS_DIR, assetExtension, PUBLISH_ID, toPublishOptions } from '../domain/index.ts'
8
+
9
+ const DOWNLOAD_TIMEOUT_MS = 15_000
10
+
11
+ const DOWNLOAD = /^[\da-f]{16}\.[a-z\d]+$/
12
+
13
+ const failed = new Map<string, string>()
14
+
15
+ function images(node: MdastNode, found: MdastNode[] = []): MdastNode[] {
16
+ if (node.type === 'image' && typeof node.url === 'string') found.push(node)
17
+ node.children?.forEach(child => images(child, found))
18
+ return found
19
+ }
20
+
21
+ async function localCopy(url: string, dir: string, existing: readonly string[]): Promise<string> {
22
+ const name = createHash('sha256').update(url).digest('hex').slice(0, 16)
23
+ const found = existing.find(file => file.startsWith(`${name}.`))
24
+ if (found) return join(dir, found)
25
+ const before = failed.get(url)
26
+ if (before !== undefined) throw new Error(before)
27
+ try {
28
+ const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) })
29
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
30
+ const ext = assetExtension(url, response.headers.get('content-type'))
31
+ if (!ext) throw new Error(`not an image (${response.headers.get('content-type') ?? 'no content type'})`)
32
+ const file = join(dir, `${name}.${ext}`)
33
+ await mkdir(dir, { recursive: true })
34
+ await writeFile(file, Buffer.from(await response.arrayBuffer()))
35
+ return file
36
+ } catch (error) {
37
+ failed.set(url, errorMessage(error))
38
+ throw error
39
+ }
40
+ }
41
+
42
+ export const remoteAssetsPass: IRPass = {
43
+ name: 'presmium.publish.remote-assets',
44
+ order: 100,
45
+ async run(doc, ctx) {
46
+ if (!toPublishOptions({ presmium: doc.config.presmium }).offline) return
47
+ const dir = join(ctx.deckDir, ASSETS_DIR)
48
+ const remote: { node: MdastNode, block: IRBlock }[] = []
49
+ for (const { block } of docBlocks(doc)) {
50
+ for (const node of nodeMdast(block.node).flatMap(mdast => images(mdast))) {
51
+ if (/^https?:\/\//i.test(node.url as string)) remote.push({ node, block })
52
+ }
53
+ }
54
+ const existing = existsSync(dir) ? await readdir(dir) : []
55
+ const urls = [...new Set(remote.map(({ node }) => node.url as string))]
56
+ const copies = new Map(urls.map((url, i) => [url, i]))
57
+ const settled = await Promise.allSettled(urls.map(url => localCopy(url, dir, existing)))
58
+ const used = new Set<string>()
59
+ for (const { node, block } of remote) {
60
+ const url = node.url as string
61
+ const copy = settled[copies.get(url)!]!
62
+ if (copy.status === 'fulfilled') {
63
+ used.add(basename(copy.value))
64
+ node.url = relative(dirname(block.source.file), copy.value).split(sep).join('/')
65
+ } else {
66
+ ctx.report({
67
+ checkId: PUBLISH_ID,
68
+ severity: 'warning',
69
+ message: `remote image ${url} was not downloaded (${errorMessage(copy.reason)}); it needs network when presenting`,
70
+ source: { file: block.source.file, line: block.source.line },
71
+ })
72
+ }
73
+ }
74
+ for (const file of existing) {
75
+ if (DOWNLOAD.test(file) && !used.has(file)) await rm(join(dir, file), { force: true })
76
+ }
77
+ },
78
+ }
@@ -0,0 +1,153 @@
1
+ import { isAbsolute, join, relative } from 'node:path'
2
+ import { parseArgs } from 'node:util'
3
+ import type { ParseArgsConfig } from 'node:util'
4
+ import { DECK_ENTRY } from '@lincyaw/compiler'
5
+ import { errorMessage } from '@lincyaw/kernel'
6
+ import { isDeck } from './deck.ts'
7
+ import { EXPORT_TIMEOUT, exportDeck } from './export.ts'
8
+ import { visualDiff } from './visual-diff.ts'
9
+
10
+ const EXPORT_USAGE = `Usage: presmium-export [deckDir] [--format pdf|pptx|png] [--output <file.pdf | file.pptx | dir>] [--with-clicks] [--range <1,3-5>] [--dark] [--timeout <ms>] [--json]
11
+
12
+ Builds the deck's slides and prints them in Chromium (playwright-chromium): to
13
+ PDF (default), then checks that every link the slides show (Markdown links,
14
+ href and *link/*url attributes, code permalinks) is still a link in the PDF,
15
+ and exits 1 when a link was lost; to PowerPoint (a picture of each slide, its
16
+ narration as the speaker notes, no links); or to PNG images, one per slide
17
+ (per click step with --with-clicks), in the --output directory, where links
18
+ cannot be checked. The print page must be ready within --timeout (default
19
+ ${EXPORT_TIMEOUT}); an export that lacks a page fails (exit 2).`
20
+
21
+ const DIFF_USAGE = `Usage: presmium-diff <refA> <refB> [deckDir] [--out <dir>] [--threshold <ratio>] [--wait <ms>] [--install] [--json]
22
+
23
+ Renders the deck of two commits in each of its forms: the slides (every slide
24
+ and click step, exported as PNG) and the web form (every page in the document
25
+ view and every section in the present view, captured with playwright-chromium).
26
+ Compares the images pixel by pixel and writes report.md and report.json
27
+ (default directory <deckDir>/.presmium/diff). Exits 1 when a page differs by
28
+ more than the threshold (a ratio of pixels, default 0). --wait lets each web page
29
+ settle before it is captured (default 500); a slides export that lacks a slide
30
+ fails (exit 2). Both commits render with the host and addons
31
+ installed for the deck; --install renders each commit with the dependencies of
32
+ its own pnpm lockfile instead, e.g. to check a host or addon upgrade.`
33
+
34
+ function shown(file: string): string {
35
+ const rel = relative(process.cwd(), file)
36
+ return rel === '' ? '.' : rel.startsWith('..') || isAbsolute(rel) ? file : rel
37
+ }
38
+
39
+ function parse<T extends ParseArgsConfig['options']>(argv: string[], options: T, usage: string) {
40
+ try {
41
+ return parseArgs({ args: argv, options: { ...options, json: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } }, allowPositionals: true })
42
+ } catch (error) {
43
+ console.error(`${errorMessage(error)}\n\n${usage}`)
44
+ return undefined
45
+ }
46
+ }
47
+
48
+ function deckOrError(dir: string, usage: string): string | undefined {
49
+ if (isDeck(dir)) return dir
50
+ console.error(`no ${DECK_ENTRY} in ${dir}\n\n${usage}`)
51
+ return undefined
52
+ }
53
+
54
+ export async function exportMain(argv: string[]): Promise<number> {
55
+ const args = parse(argv, {
56
+ 'format': { type: 'string' },
57
+ 'output': { type: 'string', short: 'o' },
58
+ 'with-clicks': { type: 'boolean', short: 'c' },
59
+ 'range': { type: 'string' },
60
+ 'dark': { type: 'boolean' },
61
+ 'timeout': { type: 'string' },
62
+ }, EXPORT_USAGE)
63
+ if (!args) return 2
64
+ const { values, positionals } = args
65
+ if (values.help) {
66
+ console.log(EXPORT_USAGE)
67
+ return 0
68
+ }
69
+ const format = values.format ?? 'pdf'
70
+ if (positionals.length > 1 || (format !== 'pdf' && format !== 'png' && format !== 'pptx')) {
71
+ console.error(EXPORT_USAGE)
72
+ return 2
73
+ }
74
+ const timeout = values.timeout === undefined ? undefined : Number(values.timeout)
75
+ if (timeout !== undefined && !(timeout > 0)) {
76
+ console.error(`--timeout takes a number of milliseconds\n\n${EXPORT_USAGE}`)
77
+ return 2
78
+ }
79
+ const deckDir = deckOrError(positionals[0] ?? '.', EXPORT_USAGE)
80
+ if (!deckDir) return 2
81
+ try {
82
+ const result = await exportDeck({
83
+ deckDir,
84
+ format,
85
+ output: values.output as string | undefined,
86
+ withClicks: values['with-clicks'] as boolean | undefined,
87
+ range: values.range as string | undefined,
88
+ dark: values.dark as boolean | undefined,
89
+ timeout,
90
+ })
91
+ const lost = result.links.filter(link => link.pages.length === 0)
92
+ if (values.json) {
93
+ console.log(JSON.stringify({ ...result, lost: lost.length }, null, 2))
94
+ } else {
95
+ result.warnings.forEach(w => console.error(`warning: ${w}`))
96
+ if (format === 'png') {
97
+ console.log(`exported ${shown(result.output)} (${result.pages} images); links are not checked in images`)
98
+ } else if (format === 'pptx') {
99
+ console.log(`exported ${shown(result.output)} (${result.pages} slides, each a picture)`)
100
+ } else {
101
+ console.log(`exported ${shown(result.output)} (${result.pages} pages)`)
102
+ for (const link of lost) console.log(`${shown(link.source.file)}:${link.source.line} link lost ${link.url} (slide ${link.slide})`)
103
+ console.log(`${result.links.length - lost.length} of ${result.links.length} links kept`)
104
+ }
105
+ }
106
+ return lost.length > 0 ? 1 : 0
107
+ } catch (error) {
108
+ console.error(`presmium-export: ${errorMessage(error)}`)
109
+ return 2
110
+ }
111
+ }
112
+
113
+ export async function diffMain(argv: string[]): Promise<number> {
114
+ const args = parse(argv, {
115
+ out: { type: 'string', short: 'o' },
116
+ threshold: { type: 'string' },
117
+ wait: { type: 'string' },
118
+ install: { type: 'boolean' },
119
+ }, DIFF_USAGE)
120
+ if (!args) return 2
121
+ const { values, positionals } = args
122
+ if (values.help) {
123
+ console.log(DIFF_USAGE)
124
+ return 0
125
+ }
126
+ const threshold = values.threshold === undefined ? 0 : Number(values.threshold)
127
+ const wait = values.wait === undefined ? undefined : Number(values.wait)
128
+ if (!(threshold >= 0 && threshold <= 1) || (wait !== undefined && !(wait >= 0))) {
129
+ console.error(`--threshold must be a ratio between 0 and 1, --wait a number of milliseconds\n\n${DIFF_USAGE}`)
130
+ return 2
131
+ }
132
+ const [refA, refB, dir = '.', ...extra] = positionals
133
+ if (!refA || !refB || extra.length > 0) {
134
+ console.error(DIFF_USAGE)
135
+ return 2
136
+ }
137
+ const deckDir = deckOrError(dir, DIFF_USAGE)
138
+ if (!deckDir) return 2
139
+ try {
140
+ const { report, out, warnings } = await visualDiff({ refA, refB, deckDir, out: values.out as string | undefined, threshold, wait, install: values.install as boolean | undefined })
141
+ warnings.forEach(w => console.error(`warning: ${w}`))
142
+ if (values.json) {
143
+ console.log(JSON.stringify(report, null, 2))
144
+ } else {
145
+ for (const page of report.listed) console.log(`${page.id} ${page.status} ${(page.ratio * 100).toFixed(2)}%`)
146
+ console.log(`${report.listed.length} of ${report.pages.length} pages differ; report: ${shown(join(out, 'report.md'))}`)
147
+ }
148
+ return report.listed.length > 0 ? 1 : 0
149
+ } catch (error) {
150
+ console.error(`presmium-diff: ${errorMessage(error)}`)
151
+ return 2
152
+ }
153
+ }
@@ -0,0 +1,14 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { DECK_ENTRY } from '@lincyaw/compiler'
4
+ import type { Compilation } from '@lincyaw/compiler'
5
+
6
+ export function isDeck(dir: string): boolean {
7
+ return existsSync(join(dir, DECK_ENTRY))
8
+ }
9
+
10
+ /** Whether the deck renders to form `id`: to every form when it names no renderer. */
11
+ export function hasForm(compilation: Compilation, id: string): boolean {
12
+ const { renderers } = compilation.doc.config
13
+ return Object.keys(renderers).length === 0 || id in renderers
14
+ }
@@ -0,0 +1,109 @@
1
+ import { cp, mkdir, readdir, readFile, rm } from 'node:fs/promises'
2
+ import { join, resolve } from 'node:path'
3
+ import { Compiler, exportDocument, viewFor } from '@lincyaw/compiler'
4
+ import type { Compilation } from '@lincyaw/compiler'
5
+ import { exportedSlides, linkLine, normalizeUrl, parseRange, slideLinks } from '../domain/index.ts'
6
+ import type { DeclaredLink } from '../domain/index.ts'
7
+ import { pdfLinks } from './pdf.ts'
8
+
9
+ export const EXPORT_TIMEOUT = 120_000
10
+
11
+ // The export's own directory below the deck's: the slides renderer's exporters write there.
12
+ const WORK = '.presmium/export'
13
+ const PAGE_IMAGE = /^\d+(?:-\d+)?\.png$/
14
+
15
+ export interface ExportJob {
16
+ deckDir: string
17
+ format?: 'pdf' | 'png' | 'pptx'
18
+ output?: string
19
+ withClicks?: boolean
20
+ range?: string
21
+ dark?: boolean
22
+ timeout?: number
23
+ log?: (line: string) => void
24
+ signal?: AbortSignal
25
+ }
26
+
27
+ export interface ExportedLink {
28
+ url: string
29
+ slide: number
30
+ source: { file: string, line: number }
31
+ pages: number[]
32
+ }
33
+
34
+ export interface ExportResult {
35
+ format: 'pdf' | 'png' | 'pptx'
36
+ output: string
37
+ pages: number
38
+ links: ExportedLink[]
39
+ warnings: string[]
40
+ }
41
+
42
+ /** The pages an export of the slides has: a slide each, or each of its steps with `withClicks`. */
43
+ function expectedPages(compilation: Compilation, range: Set<number> | undefined, withClicks: boolean): number {
44
+ const sections = viewFor(compilation.doc, 'slides').sections
45
+ return exportedSlides(sections.length, range).reduce((count, slide) => count + (withClicks ? sections[slide - 1]!.steps + 1 : 1), 0)
46
+ }
47
+
48
+ /** Exports the slides with the slides renderer's exporters (`pdf`, `png`, `pptx`) to `output`. */
49
+ export async function exportSlides(compilation: Compilation, job: ExportJob & { format: 'pdf' | 'png' | 'pptx', output: string }): Promise<string[]> {
50
+ const range = parseRange(job.range)
51
+ const work = join(compilation.deckDir, WORK)
52
+ await rm(work, { recursive: true, force: true })
53
+ await mkdir(work, { recursive: true })
54
+ try {
55
+ const options = { steps: Boolean(job.withClicks), ...range ? { slides: [...range] } : {}, ...job.dark ? { dark: true } : {}, timeout: job.timeout ?? EXPORT_TIMEOUT }
56
+ const { files } = await exportDocument(compilation, job.format, { outDir: work, options, ...job.log ? { log: job.log } : {}, ...job.signal ? { signal: job.signal } : {} })
57
+ if (job.format !== 'png') {
58
+ await cp(join(work, files[0]!), job.output)
59
+ return [job.output]
60
+ }
61
+ await mkdir(job.output, { recursive: true })
62
+ await Promise.all((await readdir(job.output)).filter(file => PAGE_IMAGE.test(file)).map(file => rm(join(job.output, file))))
63
+ await Promise.all(files.map(file => cp(join(work, file), join(job.output, file))))
64
+ return files
65
+ } finally {
66
+ await rm(work, { recursive: true, force: true })
67
+ }
68
+ }
69
+
70
+ export async function exportDeck(job: ExportJob): Promise<ExportResult> {
71
+ const format = job.format ?? 'pdf'
72
+ const compilation = await new Compiler(job.deckDir).compile()
73
+ const warnings = compilation.doc.diagnostics.filter(f => f.severity !== 'info').map(f => f.message)
74
+ const filename = compilation.doc.config.renderers.slides?.exportFilename
75
+ const name = job.output ? resolve(job.output) : resolve(compilation.deckDir, String(filename ?? 'slides-export'))
76
+ const output = format === 'png' || name.endsWith(`.${format}`) ? name : `${name}.${format}`
77
+ const range = parseRange(job.range)
78
+ const written = await exportSlides(compilation, { ...job, format, output })
79
+ const expected = expectedPages(compilation, range, Boolean(job.withClicks))
80
+
81
+ if (format === 'png') {
82
+ if (written.length !== expected) throw new Error(`the export wrote ${written.length} pictures for the ${expected} pages of the slides`)
83
+ return { format, output, pages: written.length, links: [], warnings }
84
+ }
85
+ // A PowerPoint file has a picture of each slide: no links to check.
86
+ if (format === 'pptx') return { format, output, pages: expected, links: [], warnings }
87
+ const pages = (await pdfLinks(output)).map(uris => new Set(uris.map(normalizeUrl)))
88
+ if (pages.length !== expected) throw new Error(`the PDF has ${pages.length} pages for the ${expected} pages of the slides`)
89
+ const links = await locateLinks(slideLinks(viewFor(compilation.doc, 'slides'), range))
90
+ return {
91
+ format,
92
+ output,
93
+ pages: pages.length,
94
+ links: links.map(link => ({ ...link, pages: pages.flatMap((uris, i) => (uris.has(link.url) ? [i + 1] : [])) })),
95
+ warnings,
96
+ }
97
+ }
98
+
99
+ async function locateLinks(links: DeclaredLink[]): Promise<Omit<ExportedLink, 'pages'>[]> {
100
+ const sources = new Map<string, Promise<string[]>>()
101
+ const linesOf = (file: string) => {
102
+ if (!sources.has(file)) sources.set(file, readFile(file, 'utf8').then(text => text.split(/\r?\n/), () => []))
103
+ return sources.get(file)!
104
+ }
105
+ return Promise.all(links.map(async ({ url, slide, source }) => {
106
+ const line = linkLine(await linesOf(source.file), url, source.line, (source.endLine ?? source.line) + 1) ?? source.line
107
+ return { url, slide, source: { file: source.file, line } }
108
+ }))
109
+ }