@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.
- package/LICENSE +202 -0
- package/README.md +29 -2
- package/bin/presmium-diff.mjs +12 -0
- package/bin/presmium-export.mjs +12 -0
- package/components/PmContrastToggle.css +14 -0
- package/components/PmContrastToggle.tsx +22 -0
- package/components/PmSaveOffline.css +12 -0
- package/components/PmSaveOffline.tsx +51 -0
- package/package.json +60 -3
- package/src/client/code-lines.ts +18 -0
- package/src/client/contrast.css +67 -0
- package/src/client/contrast.ts +71 -0
- package/src/client/diagrams.ts +45 -0
- package/src/client/env.d.ts +1 -0
- package/src/client/index.ts +5 -0
- package/src/client/offline.ts +29 -0
- package/src/client/plugin.ts +34 -0
- package/src/domain/assets.ts +27 -0
- package/src/domain/config.ts +14 -0
- package/src/domain/export.ts +15 -0
- package/src/domain/index.ts +10 -0
- package/src/domain/links.ts +70 -0
- package/src/domain/messages.ts +23 -0
- package/src/domain/visual-diff.ts +81 -0
- package/src/node/assets.ts +78 -0
- package/src/node/cli.ts +153 -0
- package/src/node/deck.ts +14 -0
- package/src/node/export.ts +109 -0
- package/src/node/pdf.ts +18 -0
- package/src/node/presmium.ts +18 -0
- package/src/node/process.ts +39 -0
- package/src/node/visual-diff.ts +119 -0
- package/src/node/web-capture.ts +51 -0
package/src/node/pdf.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { PDFArray, PDFDict, PDFDocument, PDFHexString, PDFName, PDFString } from 'pdf-lib'
|
|
3
|
+
|
|
4
|
+
export async function pdfLinks(file: string): Promise<string[][]> {
|
|
5
|
+
const doc = await PDFDocument.load(await readFile(file), { updateMetadata: false })
|
|
6
|
+
return doc.getPages().map((page) => {
|
|
7
|
+
const annots = page.node.lookupMaybe(PDFName.of('Annots'), PDFArray)
|
|
8
|
+
const uris: string[] = []
|
|
9
|
+
for (let i = 0; i < (annots?.size() ?? 0); i++) {
|
|
10
|
+
const annot = annots!.lookupMaybe(i, PDFDict)
|
|
11
|
+
if (annot?.get(PDFName.of('Subtype')) !== PDFName.of('Link')) continue
|
|
12
|
+
const action = annot.lookupMaybe(PDFName.of('A'), PDFDict)
|
|
13
|
+
const uri = action?.lookupMaybe(PDFName.of('URI'), PDFString, PDFHexString)
|
|
14
|
+
if (uri) uris.push(uri.decodeText())
|
|
15
|
+
}
|
|
16
|
+
return uris
|
|
17
|
+
})
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import type { PresmiumNodePlugin } from '@lincyaw/kernel'
|
|
3
|
+
import { PUBLISH_ID } from '../domain/index.ts'
|
|
4
|
+
import { remoteAssetsPass } from './assets.ts'
|
|
5
|
+
|
|
6
|
+
const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
|
|
7
|
+
|
|
8
|
+
const plugin: PresmiumNodePlugin = {
|
|
9
|
+
id: PUBLISH_ID,
|
|
10
|
+
version,
|
|
11
|
+
kernel: '^1.0.0',
|
|
12
|
+
passes: [remoteAssetsPass],
|
|
13
|
+
rendererExtensions: {
|
|
14
|
+
notebook: { components: { PmContrastToggle: () => [] } },
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default plugin
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { delimiter, dirname, join } from 'node:path'
|
|
4
|
+
import { promisify } from 'node:util'
|
|
5
|
+
import { forEachLine } from '@lincyaw/kernel/node'
|
|
6
|
+
|
|
7
|
+
const exec = promisify(execFile)
|
|
8
|
+
|
|
9
|
+
export async function git(cwd: string, ...args: string[]): Promise<string> {
|
|
10
|
+
const { stdout } = await exec('git', args, { cwd, maxBuffer: 64 << 20 })
|
|
11
|
+
return stdout.trim()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function binPath(dir: string): string {
|
|
15
|
+
const bins: string[] = []
|
|
16
|
+
for (let d = dir; ; d = dirname(d)) {
|
|
17
|
+
if (existsSync(join(d, 'node_modules', '.bin'))) bins.push(join(d, 'node_modules', '.bin'))
|
|
18
|
+
if (dirname(d) === d) break
|
|
19
|
+
}
|
|
20
|
+
return [...bins, process.env.PATH ?? ''].join(delimiter)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Runs `command`, its output a line at a time to `log` (else to this process's stderr); `signal` stops it. */
|
|
24
|
+
export function run(cwd: string, command: string, args: string[], env: Record<string, string> = {}, options: { log?: (line: string) => void, signal?: AbortSignal } = {}): Promise<void> {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
if (options.signal?.aborted) return reject(options.signal.reason as Error)
|
|
27
|
+
const child = spawn(command, args, { cwd, env: { ...process.env, ...env, PATH: binPath(cwd) }, stdio: options.log ? ['ignore', 'pipe', 'pipe'] : ['ignore', 2, 2] })
|
|
28
|
+
if (options.log) for (const stream of [child.stdout, child.stderr]) forEachLine(stream, options.log)
|
|
29
|
+
const stop = () => child.kill('SIGTERM')
|
|
30
|
+
options.signal?.addEventListener('abort', stop, { once: true })
|
|
31
|
+
child.on('error', error => reject(new Error(`cannot run ${command}: ${error.message}`)))
|
|
32
|
+
child.on('close', (code) => {
|
|
33
|
+
options.signal?.removeEventListener('abort', stop)
|
|
34
|
+
if (options.signal?.aborted) reject(options.signal.reason as Error)
|
|
35
|
+
else if (code === 0) resolve()
|
|
36
|
+
else reject(new Error(`${command} ${args[0] ?? ''} exited with code ${code}`))
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
4
|
+
import { Compiler, DECK_ENTRY, viewFor } from '@lincyaw/compiler'
|
|
5
|
+
import pixelmatch from 'pixelmatch'
|
|
6
|
+
import { PNG } from 'pngjs'
|
|
7
|
+
import { diffReport, pageStatus, pairPages, parsePageFile, parseWebFile, reportMarkdown } from '../domain/index.ts'
|
|
8
|
+
import type { DiffSide, PageRef, VisualDiff, VisualDiffReport } from '../domain/index.ts'
|
|
9
|
+
import { hasForm } from './deck.ts'
|
|
10
|
+
import { git, run } from './process.ts'
|
|
11
|
+
import { exportSlides } from './export.ts'
|
|
12
|
+
import { captureWeb } from './web-capture.ts'
|
|
13
|
+
|
|
14
|
+
export interface DiffJob {
|
|
15
|
+
refA: string
|
|
16
|
+
refB: string
|
|
17
|
+
deckDir: string
|
|
18
|
+
out?: string
|
|
19
|
+
threshold?: number
|
|
20
|
+
wait?: number
|
|
21
|
+
install?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function visualDiff(job: DiffJob): Promise<{ report: VisualDiffReport, out: string, warnings: string[] }> {
|
|
25
|
+
const deckDir = resolve(job.deckDir)
|
|
26
|
+
const root = await git(deckDir, 'rev-parse', '--show-toplevel')
|
|
27
|
+
const sides = await Promise.all([job.refA, job.refB].map(async (ref): Promise<DiffSide> => {
|
|
28
|
+
const commit = await git(deckDir, 'rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`).catch(() => '')
|
|
29
|
+
if (!commit) throw new Error(`unknown revision "${ref}"`)
|
|
30
|
+
return { ref, commit }
|
|
31
|
+
}))
|
|
32
|
+
const warnings = job.install ? [] : await outsideDeckWarnings(root, deckDir, sides[0]!.commit, sides[1]!.commit)
|
|
33
|
+
|
|
34
|
+
const work = join(deckDir, '.presmium', 'diff')
|
|
35
|
+
const out = resolve(job.out ?? work)
|
|
36
|
+
if (out !== work && existsSync(out) && (await readdir(out)).length > 0 && !existsSync(join(out, 'report.json'))) {
|
|
37
|
+
throw new Error(`${out} is not empty and holds no previous report; choose another --out`)
|
|
38
|
+
}
|
|
39
|
+
await mkdir(work, { recursive: true })
|
|
40
|
+
await writeFile(join(work, '.gitignore'), '*\n')
|
|
41
|
+
for (const dir of ['a', 'b', 'diff']) await rm(join(out, dir), { recursive: true, force: true })
|
|
42
|
+
|
|
43
|
+
const sections = new Map<string, number>()
|
|
44
|
+
for (const [label, side] of [['a', sides[0]!], ['b', sides[1]!]] as const) {
|
|
45
|
+
const checkout = join(work, `checkout-${label}`)
|
|
46
|
+
await removeWorktree(root, checkout)
|
|
47
|
+
await git(root, 'worktree', 'add', '--detach', '--force', checkout, side.commit)
|
|
48
|
+
try {
|
|
49
|
+
const deck = join(checkout, relative(root, deckDir))
|
|
50
|
+
if (!existsSync(join(deck, DECK_ENTRY))) throw new Error(`${side.ref} has no ${relative(root, join(deckDir, DECK_ENTRY))}`)
|
|
51
|
+
if (job.install) await run(lockfileDir(deck, checkout), 'pnpm', ['install', '--frozen-lockfile', '--prefer-offline'])
|
|
52
|
+
const compilation = await new Compiler(deck).compile()
|
|
53
|
+
const wait = job.wait ?? 500
|
|
54
|
+
if (hasForm(compilation, 'slides')) {
|
|
55
|
+
await exportSlides(compilation, { deckDir: deck, format: 'png', withClicks: true, output: join(out, label) })
|
|
56
|
+
}
|
|
57
|
+
if (hasForm(compilation, 'web')) {
|
|
58
|
+
for (const section of viewFor(compilation.doc, 'web').sections) sections.set(section.id, section.index)
|
|
59
|
+
warnings.push(...(await captureWeb(compilation, join(work, `web-${label}`), join(out, label, 'web'), wait)).map(w => `${side.ref}: ${w}`))
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
await removeWorktree(root, checkout)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const threshold = job.threshold ?? 0
|
|
67
|
+
const pages = [...await comparePages(out, '', threshold, parsePageFile), ...await comparePages(out, 'web', threshold, name => parseWebFile(name, sections))]
|
|
68
|
+
const report = diffReport(sides[0]!, sides[1]!, threshold, pages)
|
|
69
|
+
await writeFile(join(out, 'report.json'), `${JSON.stringify(report, null, 2)}\n`)
|
|
70
|
+
await writeFile(join(out, 'report.md'), reportMarkdown(report))
|
|
71
|
+
return { report, out, warnings }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Warns of the changes between the commits outside the deck, which render from the installed packages. */
|
|
75
|
+
async function outsideDeckWarnings(root: string, deckDir: string, a: string, b: string): Promise<string[]> {
|
|
76
|
+
const deck = relative(root, deckDir)
|
|
77
|
+
if (!deck) return []
|
|
78
|
+
const files = (await git(root, 'diff', '--name-only', a, b, '--', '.', `:(exclude)${deck}`)).split('\n').filter(Boolean)
|
|
79
|
+
if (files.length === 0) return []
|
|
80
|
+
const shown = files.slice(0, 3).join(', ') + (files.length > 3 ? `, … (${files.length} files)` : '')
|
|
81
|
+
return [`changes outside the deck are not rendered from their commit: ${shown}; --install renders each commit with its own dependencies`]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function comparePages(out: string, dir: string, threshold: number, parse: (name: string) => PageRef | undefined): Promise<VisualDiff[]> {
|
|
85
|
+
const list = async (side: string) => (existsSync(join(out, side, dir)) ? readdir(join(out, side, dir)) : [])
|
|
86
|
+
await mkdir(join(out, 'diff', dir), { recursive: true })
|
|
87
|
+
const pages: VisualDiff[] = []
|
|
88
|
+
for (const { page, a, b } of pairPages(await list('a'), await list('b'), parse)) {
|
|
89
|
+
if (!a || !b) {
|
|
90
|
+
pages.push({ ...page, status: pageStatus(1, threshold, !!a, !!b), ratio: 1, diffPixels: 0 })
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
const [imgA, imgB] = await Promise.all([readFile(join(out, 'a', dir, a)), readFile(join(out, 'b', dir, b))]).then(files => files.map(f => PNG.sync.read(f)))
|
|
94
|
+
const { width, height } = imgA!
|
|
95
|
+
if (width !== imgB!.width || height !== imgB!.height) {
|
|
96
|
+
pages.push({ ...page, status: pageStatus(1, threshold, true, true), ratio: 1, diffPixels: width * height })
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
const diff = new PNG({ width, height })
|
|
100
|
+
const diffPixels = pixelmatch(imgA!.data, imgB!.data, diff.data, width, height, { threshold: 0.1 })
|
|
101
|
+
const ratio = diffPixels / (width * height)
|
|
102
|
+
if (diffPixels > 0) await writeFile(join(out, 'diff', `${page.id}.png`), PNG.sync.write(diff))
|
|
103
|
+
pages.push({ ...page, status: pageStatus(ratio, threshold, true, true), ratio, diffPixels })
|
|
104
|
+
}
|
|
105
|
+
return pages
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function lockfileDir(deck: string, checkout: string): string {
|
|
109
|
+
for (let d = deck; ; d = dirname(d)) {
|
|
110
|
+
if (existsSync(join(d, 'pnpm-lock.yaml'))) return d
|
|
111
|
+
if (d === checkout || dirname(d) === d) throw new Error('--install needs a pnpm-lock.yaml in the checked-out commit')
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function removeWorktree(root: string, dir: string): Promise<void> {
|
|
116
|
+
if (!existsSync(dir)) return
|
|
117
|
+
await git(root, 'worktree', 'remove', '--force', dir).catch(() => undefined)
|
|
118
|
+
await rm(dir, { recursive: true, force: true })
|
|
119
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mkdir, readdir, rm } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { build, playwrightChromium, serveDirectory, viewFor } from '@lincyaw/compiler'
|
|
4
|
+
import type { Compilation } from '@lincyaw/compiler'
|
|
5
|
+
|
|
6
|
+
interface Chromium {
|
|
7
|
+
launch(): Promise<{
|
|
8
|
+
newPage(options: Record<string, unknown>): Promise<{
|
|
9
|
+
goto(url: string, options: { waitUntil: 'networkidle' }): Promise<unknown>
|
|
10
|
+
waitForSelector(selector: string): Promise<unknown>
|
|
11
|
+
waitForTimeout(ms: number): Promise<void>
|
|
12
|
+
screenshot(options: { path: string, fullPage?: boolean }): Promise<unknown>
|
|
13
|
+
}>
|
|
14
|
+
close(): Promise<void>
|
|
15
|
+
}>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function captureWeb(compilation: Compilation, out: string, dir: string, wait: number): Promise<string[]> {
|
|
19
|
+
await rm(out, { recursive: true, force: true })
|
|
20
|
+
const report = await build(compilation, { outDir: out, to: ['web'] })
|
|
21
|
+
const web = report.outputs.find(o => o.renderer === 'web')
|
|
22
|
+
if (!web) return [`the web form was not built: ${report.findings.filter(f => f.severity === 'error').map(f => f.message).join('; ') || 'no web renderer'}`]
|
|
23
|
+
await mkdir(dir, { recursive: true })
|
|
24
|
+
const server = await serveDirectory(web.outDir, { base: web.base })
|
|
25
|
+
try {
|
|
26
|
+
const site = `${server.url}${web.base}`
|
|
27
|
+
const browser = await (await playwrightChromium<Chromium>(compilation.deckDir)).launch()
|
|
28
|
+
try {
|
|
29
|
+
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, reducedMotion: 'reduce', serviceWorkers: 'block' })
|
|
30
|
+
const pages = (await readdir(web.outDir)).filter(file => file.endsWith('.html') && file !== '404.html').sort()
|
|
31
|
+
for (const file of pages) {
|
|
32
|
+
await page.goto(`${site}${file}`, { waitUntil: 'networkidle' })
|
|
33
|
+
await page.waitForSelector('.pm-section')
|
|
34
|
+
await page.waitForTimeout(wait)
|
|
35
|
+
await page.screenshot({ path: join(dir, `${file.slice(0, -'.html'.length)}.png`), fullPage: true })
|
|
36
|
+
}
|
|
37
|
+
for (const section of viewFor(compilation.doc, 'web').sections) {
|
|
38
|
+
await page.goto(`${site}?view=present§ion=${encodeURIComponent(section.id)}`, { waitUntil: 'networkidle' })
|
|
39
|
+
await page.waitForSelector(`.pm-present[data-section=${JSON.stringify(section.id)}]`)
|
|
40
|
+
await page.waitForTimeout(wait)
|
|
41
|
+
await page.screenshot({ path: join(dir, `present-${section.id}.png`) })
|
|
42
|
+
}
|
|
43
|
+
} finally {
|
|
44
|
+
await browser.close()
|
|
45
|
+
}
|
|
46
|
+
} finally {
|
|
47
|
+
try { await server.close() }
|
|
48
|
+
finally { await rm(out, { recursive: true, force: true }) }
|
|
49
|
+
}
|
|
50
|
+
return report.findings.filter(f => f.severity !== 'info').map(f => f.message)
|
|
51
|
+
}
|