feat: add easy-slides CLI
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import path from 'node:path'
|
||||
import { chromium, type Browser, type Page } from 'playwright-chromium'
|
||||
|
||||
export interface OverflowAuditDeck {
|
||||
entry: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface SlideOverflowIssue {
|
||||
deck: OverflowAuditDeck
|
||||
heading: string
|
||||
horizontal: number
|
||||
slide: number
|
||||
vertical: number
|
||||
}
|
||||
|
||||
export interface OverflowAuditResult {
|
||||
errors: Array<{ deck: OverflowAuditDeck, message: string }>
|
||||
issues: SlideOverflowIssue[]
|
||||
}
|
||||
|
||||
interface OverflowAuditOptions {
|
||||
decks: OverflowAuditDeck[]
|
||||
root: string
|
||||
siteBase: string
|
||||
}
|
||||
|
||||
export interface OverflowAuditTarget {
|
||||
deck: OverflowAuditDeck
|
||||
routerMode?: 'hash' | 'history'
|
||||
url: string
|
||||
}
|
||||
|
||||
const overflowTolerance = 2
|
||||
|
||||
export class OverflowAuditor {
|
||||
private browser?: Browser
|
||||
private page?: Page
|
||||
|
||||
async audit(targets: OverflowAuditTarget[]): Promise<OverflowAuditResult> {
|
||||
const page = await this.ensurePage()
|
||||
const result: OverflowAuditResult = { errors: [], issues: [] }
|
||||
for (const target of targets) {
|
||||
try {
|
||||
result.issues.push(...await auditDeck(page, target))
|
||||
}
|
||||
catch (error) {
|
||||
result.errors.push({
|
||||
deck: target.deck,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.browser?.close()
|
||||
this.browser = undefined
|
||||
this.page = undefined
|
||||
}
|
||||
|
||||
private async ensurePage() {
|
||||
if (this.page && !this.page.isClosed())
|
||||
return this.page
|
||||
await this.browser?.close().catch(() => undefined)
|
||||
this.browser = await chromium.launch({ headless: true })
|
||||
const context = await this.browser.newContext({ viewport: { height: 900, width: 1200 } })
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'wakeLock', {
|
||||
configurable: true,
|
||||
value: {
|
||||
async request(type: string) {
|
||||
const sentinel = new EventTarget() as EventTarget & {
|
||||
release: () => Promise<void>
|
||||
released: boolean
|
||||
type: string
|
||||
}
|
||||
sentinel.released = false
|
||||
sentinel.type = type
|
||||
sentinel.release = async () => {
|
||||
if (sentinel.released)
|
||||
return
|
||||
sentinel.released = true
|
||||
sentinel.dispatchEvent(new Event('release'))
|
||||
}
|
||||
return sentinel
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
this.page = await context.newPage()
|
||||
return this.page
|
||||
}
|
||||
}
|
||||
|
||||
export async function auditBuiltDecks(options: OverflowAuditOptions): Promise<OverflowAuditResult> {
|
||||
const server = await startStaticServer(options.root, options.siteBase)
|
||||
const address = server.address() as AddressInfo
|
||||
const origin = `http://127.0.0.1:${address.port}`
|
||||
const auditor = new OverflowAuditor()
|
||||
try {
|
||||
return await auditor.audit(options.decks.map(deck => ({
|
||||
deck,
|
||||
url: new URL(`${options.siteBase}${deck.slug}/`.replace(/\/{2,}/g, '/'), origin).href,
|
||||
})))
|
||||
}
|
||||
finally {
|
||||
await auditor.close()
|
||||
await new Promise<void>(resolve => server.close(() => resolve()))
|
||||
}
|
||||
}
|
||||
|
||||
async function auditDeck(page: Page, target: OverflowAuditTarget) {
|
||||
const issues: SlideOverflowIssue[] = []
|
||||
const overviewUrl = auditRouteUrl(target, 'overview')
|
||||
await page.goto(overviewUrl.href, { timeout: 30_000, waitUntil: 'networkidle' })
|
||||
await page.locator('.slidev-layout').first().waitFor({ state: 'attached', timeout: 15_000 })
|
||||
await waitForPageAssets(page)
|
||||
const slideNumbers = await page.locator('[class*="slidev-page-"]').evaluateAll((pages) => {
|
||||
const numbers = pages.flatMap((page) => {
|
||||
const pageClass = Array.from(page.classList).find(className => /^slidev-page-\d+$/.test(className))
|
||||
const number = Number.parseInt(pageClass?.replace('slidev-page-', '') ?? '', 10)
|
||||
return Number.isFinite(number) ? [number] : []
|
||||
})
|
||||
return Array.from(new Set(numbers)).sort((a, b) => a - b)
|
||||
})
|
||||
if (slideNumbers.length && target.routerMode !== 'history') {
|
||||
await page.evaluate((number) => {
|
||||
window.location.hash = `#/${number}`
|
||||
}, slideNumbers[0])
|
||||
await page.reload({ timeout: 30_000, waitUntil: 'networkidle' })
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts?.ready
|
||||
})
|
||||
}
|
||||
for (const slide of slideNumbers) {
|
||||
if (target.routerMode === 'history')
|
||||
await page.goto(auditRouteUrl(target, String(slide)).href, { timeout: 30_000, waitUntil: 'networkidle' })
|
||||
else
|
||||
await page.evaluate((number) => {
|
||||
window.location.hash = `#/${number}`
|
||||
}, slide)
|
||||
await page.waitForFunction((number) => {
|
||||
const layouts = Array.from(document.querySelectorAll<HTMLElement>(`.slidev-page-${number} .slidev-layout`))
|
||||
return layouts.some(layout => layout.getBoundingClientRect().width > 0)
|
||||
}, slide, { timeout: 10_000 })
|
||||
await waitForSlideAssets(page, slide)
|
||||
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
|
||||
const measurement = await page.evaluate((number) => {
|
||||
const layouts = Array.from(document.querySelectorAll<HTMLElement>(`.slidev-page-${number} .slidev-layout`))
|
||||
const layout = layouts.find(item => item.getBoundingClientRect().width > 0) ?? layouts[0]
|
||||
return {
|
||||
heading: layout?.querySelector('h1, h2, h3')?.textContent?.trim() ?? '',
|
||||
horizontal: layout ? Math.max(0, Math.ceil(layout.scrollWidth - layout.clientWidth)) : 0,
|
||||
slide: number,
|
||||
vertical: layout ? Math.max(0, Math.ceil(layout.scrollHeight - layout.clientHeight)) : 0,
|
||||
}
|
||||
}, slide)
|
||||
if (measurement.vertical <= overflowTolerance && measurement.horizontal <= overflowTolerance)
|
||||
continue
|
||||
issues.push({ deck: target.deck, ...measurement })
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
function auditRouteUrl(target: OverflowAuditTarget, route: string) {
|
||||
const url = target.routerMode === 'history'
|
||||
? new URL(`${route.replace(/^\/+/, '')}${route === 'overview' ? '/' : ''}`, ensureTrailingSlash(target.url))
|
||||
: new URL(target.url)
|
||||
url.searchParams.set('easy-slides-audit', '1')
|
||||
if (target.routerMode !== 'history')
|
||||
url.hash = `#/${route}`
|
||||
return url
|
||||
}
|
||||
|
||||
function ensureTrailingSlash(value: string) {
|
||||
return value.endsWith('/') ? value : `${value}/`
|
||||
}
|
||||
|
||||
async function waitForPageAssets(page: Page) {
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts?.ready
|
||||
const images = Array.from(document.images)
|
||||
await Promise.race([
|
||||
Promise.all(images.map(image => image.complete
|
||||
? Promise.resolve()
|
||||
: new Promise<void>((resolve) => {
|
||||
image.addEventListener('load', () => resolve(), { once: true })
|
||||
image.addEventListener('error', () => resolve(), { once: true })
|
||||
}))),
|
||||
new Promise<void>(resolve => setTimeout(resolve, 5_000)),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForSlideAssets(page: Page, slide: number) {
|
||||
await page.evaluate(async (number) => {
|
||||
const layout = Array.from(document.querySelectorAll<HTMLElement>(`.slidev-page-${number} .slidev-layout`))
|
||||
.find(item => item.getBoundingClientRect().width > 0)
|
||||
const images = Array.from(layout?.querySelectorAll('img') ?? [])
|
||||
await Promise.race([
|
||||
Promise.all(images.map(image => image.complete
|
||||
? Promise.resolve()
|
||||
: new Promise<void>((resolve) => {
|
||||
image.addEventListener('load', () => resolve(), { once: true })
|
||||
image.addEventListener('error', () => resolve(), { once: true })
|
||||
}))),
|
||||
new Promise<void>(resolve => setTimeout(resolve, 5_000)),
|
||||
])
|
||||
}, slide)
|
||||
}
|
||||
|
||||
async function startStaticServer(root: string, siteBase: string) {
|
||||
const server = createServer(async (request, response) => {
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
response.writeHead(405).end()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1')
|
||||
const file = await resolveStaticFile(root, requestUrl.pathname, siteBase)
|
||||
response.statusCode = 200
|
||||
response.setHeader('Content-Type', contentType(file))
|
||||
if (request.method === 'HEAD') {
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
createReadStream(file).pipe(response)
|
||||
}
|
||||
catch {
|
||||
response.writeHead(404).end('Not found')
|
||||
}
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => resolve())
|
||||
})
|
||||
return server
|
||||
}
|
||||
|
||||
async function resolveStaticFile(root: string, requestPath: string, siteBase: string) {
|
||||
let pathname = decodeURIComponent(requestPath)
|
||||
const basePrefix = siteBase === '/' ? '' : siteBase.replace(/\/$/, '')
|
||||
if (basePrefix && pathname.startsWith(`${basePrefix}/`))
|
||||
pathname = pathname.slice(basePrefix.length)
|
||||
const candidate = path.resolve(root, pathname.replace(/^\/+/, ''))
|
||||
const relative = path.relative(root, candidate)
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative))
|
||||
throw new Error('Path outside static root')
|
||||
const details = await stat(candidate)
|
||||
return details.isDirectory() ? path.join(candidate, 'index.html') : candidate
|
||||
}
|
||||
|
||||
function contentType(file: string) {
|
||||
const extension = path.extname(file).toLowerCase()
|
||||
return ({
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.gif': 'image/gif',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ttf': 'font/ttf',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
} as Record<string, string>)[extension] ?? 'application/octet-stream'
|
||||
}
|
||||
Reference in New Issue
Block a user