Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# dependencies (bun install)
node_modules
package-lock.json

# output
out
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"probe-check": "bun run scripts/probe-check.ts",
"probe-check:safari": "PROBE_CHECK_BROWSER=safari bun run scripts/probe-check.ts",
"status-dashboard": "bun run scripts/status-dashboard.ts",
"validator": "bun run scripts/validator.ts",
"site:build": "rm -rf site && bun run scripts/build-demo-site.ts",
"start": "HOST=${HOST:-127.0.0.1}; PORT=3000; pids=$(lsof -tiTCP:$PORT -sTCP:LISTEN 2>/dev/null); if [ -n \"$pids\" ]; then echo \"Freeing port $PORT: terminating $pids\"; kill $pids 2>/dev/null || true; sleep 1; pids=$(lsof -tiTCP:$PORT -sTCP:LISTEN 2>/dev/null); if [ -n \"$pids\" ]; then echo \"Port $PORT still busy: killing $pids\"; kill -9 $pids 2>/dev/null || true; fi; fi; bun pages/*.html pages/demos/*.html pages/demos/*/index.html --host=$HOST:$PORT",
"start:lan": "HOST=0.0.0.0 bun run start",
Expand Down
83 changes: 83 additions & 0 deletions scripts/validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env bun
// Measurement validator CLI.
//
// Usage:
// bun run validator # run all built-in fixtures
// bun run validator --language en # run only English fixtures
// bun run validator --report csv # print CSV to stdout
// bun run validator --report markdown # print Markdown to stdout
// bun run validator --report html # print HTML to stdout
// bun run validator --report json # print JSON to stdout
// bun run validator --filter exact # show only matching severity rows
//
// Exit codes: 0 = all passed, 1 = at least one non-exact result

import { compare } from '../src/measurement-validator/comparator.js'
import {
buildReport,
printConsoleReport,
toCSV,
toHTML,
toJSON,
toMarkdown,
} from '../src/measurement-validator/report-generator.js'
import { fixtures } from '../src/measurement-validator/test-suite.js'
import type { DivergenceSeverity } from '../src/measurement-validator/types.js'

// ─── Arg parsing ─────────────────────────────────────────────────────────────

const args = process.argv.slice(2)

function getFlag(name: string): string | undefined {
const idx = args.indexOf(name)
return idx !== -1 ? args[idx + 1] : undefined
}

const language = getFlag('--language')
const reportFormat = getFlag('--report') // csv | markdown | html | json
const filterSeverity = getFlag('--filter') as DivergenceSeverity | undefined

// ─── Collect samples ─────────────────────────────────────────────────────────

const allFixtures = language !== undefined
? (fixtures[language] ?? [])
: Object.values(fixtures).flat()

if (allFixtures.length === 0) {
const available = Object.keys(fixtures).join(', ')
console.error(`No fixtures found for language "${language ?? ''}". Available: ${available}`)
process.exit(1)
}

// ─── Run comparisons ──────────────────────────────────────────────────────────

const results = allFixtures.map(compare)

// Apply optional severity filter
const filtered = filterSeverity !== undefined
? results.filter((r) => r.severity === filterSeverity)
: results

const report = buildReport(filtered)

// ─── Output ───────────────────────────────────────────────────────────────────

switch (reportFormat) {
case 'csv':
process.stdout.write(toCSV(report))
break
case 'markdown':
process.stdout.write(toMarkdown(report))
break
case 'html':
process.stdout.write(toHTML(report))
break
case 'json':
process.stdout.write(toJSON(report) + '\n')
break
default:
printConsoleReport(report)
}

// Exit 1 if any failures
process.exit(report.failed > 0 ? 1 : 0)
83 changes: 83 additions & 0 deletions src/measurement-validator/comparator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Comparator: compare Pretext layout height against a reference DOM height.
//
// In a browser environment the DOM reference height comes from creating a
// temporary element and measuring its offsetHeight. Outside a browser (Node /
// Bun unit tests) the DOM is unavailable, so domHeight is left as NaN and the
// severity is always 'exact' — callers can detect the browser-less path via
// isNaN(result.domHeight).

import { layout, prepare, setLocale, type PrepareOptions } from '../layout.js'
import type { ComparisonResult, DivergenceSeverity, MeasurementSample } from './types.js'

function classifySeverity(diffPx: number): DivergenceSeverity {
if (diffPx <= 1) return 'exact'
if (diffPx <= 4) return 'minor'
if (diffPx <= 20) return 'major'
return 'critical'
}

/**
* Measure the reference DOM height for a sample.
*
* Creates a temporary `<div>` with the same font, width and text as the
* sample, appends it off-screen, reads `offsetHeight`, then removes it.
* Returns NaN when the DOM is not available (non-browser environments).
*/
function measureDomHeight(sample: MeasurementSample): number {
if (typeof document === 'undefined') return Number.NaN

const el = document.createElement('div')
el.style.cssText = [
'position:absolute',
'visibility:hidden',
'pointer-events:none',
'white-space:normal',
'word-break:normal',
'overflow-wrap:break-word',
`font:${sample.font}`,
`width:${sample.maxWidth}px`,
`line-height:${sample.lineHeight}px`,
].join(';')
el.textContent = sample.text
document.body.appendChild(el)
const h = el.offsetHeight
document.body.removeChild(el)
return h
}

/**
* Compare Pretext layout height against a DOM reference height for one sample.
*/
export function compare(sample: MeasurementSample): ComparisonResult {
const start = performance.now()

const options: PrepareOptions = {}

// Apply locale if specified, then restore the default after measuring.
if (sample.language !== undefined) {
setLocale(sample.language)
}

const prepared = prepare(sample.text, sample.font, options)

if (sample.language !== undefined) {
setLocale(undefined)
}

const { height: pretextHeight } = layout(prepared, sample.maxWidth, sample.lineHeight)

const domHeight = measureDomHeight(sample)
const diffPx = Number.isNaN(domHeight) ? 0 : Math.abs(pretextHeight - domHeight)
const severity = classifySeverity(diffPx)

const executionTimeMs = performance.now() - start

return {
sample,
pretextHeight,
domHeight,
diffPx,
severity,
executionTimeMs,
}
}
29 changes: 29 additions & 0 deletions src/measurement-validator/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Public API for the measurement-validator module.
//
// Usage (in a browser or via the CLI):
//
// import { compare, buildReport, toCSV } from '@chenglou/pretext/measurement-validator'
//
// const results = fixtures.en.map(compare)
// const report = buildReport(results)
// console.log(toCSV(report))

export type {
ComparisonResult,
DivergenceSeverity,
MeasurementSample,
ValidatorReport,
} from './types.js'

export { compare } from './comparator.js'

export { fixtures, englishFixtures } from './test-suite.js'

export {
buildReport,
printConsoleReport,
toCSV,
toHTML,
toJSON,
toMarkdown,
} from './report-generator.js'
Loading