feat(desktop): embed BoosAPI Desktop client as a subproject

Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,192 @@
import { execFileSync } from 'node:child_process'
import process from 'node:process'
function run(command, args, options = {}) {
return execFileSync(command, args, {
cwd: options.cwd,
encoding: 'utf8',
stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'],
})
}
function runGit(args, options) {
return run('git', args, options)
}
function parseArgs(argv) {
const args = {
legacyPrefix: 'v',
remote: 'origin',
dryRun: true,
push: false,
rename: {
// Historical package rename: keep tags stable under the current package name.
'vue-renderer-markdown': 'markstream-vue',
},
}
for (let i = 0; i < argv.length; i++) {
const cur = argv[i]
if (cur === '--legacy-prefix') {
args.legacyPrefix = argv[++i]
}
else if (cur === '--remote') {
args.remote = argv[++i]
}
else if (cur === '--apply') {
args.dryRun = false
}
else if (cur === '--dry-run') {
args.dryRun = true
}
else if (cur === '--push') {
args.push = true
}
else if (cur === '--rename') {
const raw = String(argv[++i] ?? '')
const [from, to] = raw.split('=')
if (!from || !to)
throw new Error(`Invalid --rename value (expected from=to): ${raw}`)
args.rename[from.trim()] = to.trim()
}
else if (cur === '--help' || cur === '-h') {
console.log(`
Usage:
node scripts/backfill-legacy-tags.mjs [options]
This script creates namespaced package tags (e.g. stream-markdown-parser@0.0.53)
from legacy tags (e.g. v0.0.53) when it can uniquely map a legacy tag to exactly
one package's version at that commit.
Options:
--legacy-prefix <p> Legacy tag prefix (default: v)
--dry-run Print actions only (default)
--apply Actually create tags
--push Push created tags to remote
--remote <name> Remote name (default: origin)
--rename <from=to> Rewrite package name when creating tags (repeatable)
`)
process.exit(0)
}
else {
throw new Error(`Unknown argument: ${cur}`)
}
}
return args
}
const RELEASE_PACKAGE_JSON_PATHS = [
'package.json',
'packages/markdown-parser/package.json',
'packages/markstream-react/package.json',
'packages/markstream-vue2/package.json',
]
async function getPackageMetaAtTag(legacyTag, packageJsonPath) {
try {
const raw = runGit(['show', `${legacyTag}:${packageJsonPath}`])
const parsed = JSON.parse(raw)
const name = String(parsed?.name ?? '').trim()
const version = String(parsed?.version ?? '').trim()
if (!name || !version)
return null
return { name, version, packageJsonPath }
}
catch {
return null
}
}
function getCommitForTag(tag) {
try {
return runGit(['rev-parse', `${tag}^{}`]).trim()
}
catch {
return null
}
}
async function main() {
const args = parseArgs(process.argv.slice(2))
const cwd = process.cwd()
const legacyPattern = `${args.legacyPrefix}*`
const legacyTags = runGit(['tag', '--list', legacyPattern])
.split('\n')
.map(s => s.trim())
.filter(Boolean)
if (!legacyTags.length) {
console.log(`[backfill] No legacy tags found for pattern: ${legacyPattern}`)
return
}
const created = []
const skipped = []
for (const legacyTag of legacyTags) {
if (!legacyTag.startsWith(args.legacyPrefix)) {
skipped.push({ legacyTag, reason: 'prefix-mismatch' })
continue
}
const version = legacyTag.slice(args.legacyPrefix.length)
if (!version) {
skipped.push({ legacyTag, reason: 'empty-version' })
continue
}
const matches = []
for (const pj of RELEASE_PACKAGE_JSON_PATHS) {
const meta = await getPackageMetaAtTag(legacyTag, pj)
if (meta && meta.version === version)
matches.push(meta)
}
if (matches.length !== 1) {
skipped.push({ legacyTag, reason: `non-unique(${matches.length})` })
continue
}
const target = matches[0]
const canonicalName = args.rename[target.name] ?? target.name
const newTag = `${canonicalName}@${target.version}`
const existingCommit = getCommitForTag(newTag)
if (existingCommit) {
skipped.push({ legacyTag, reason: `already-exists(${newTag})` })
continue
}
const legacyCommit = getCommitForTag(legacyTag)
if (!legacyCommit) {
skipped.push({ legacyTag, reason: 'legacy-tag-missing-commit' })
continue
}
const tagArgs = ['tag', '-a', newTag, legacyTag, '-m', `Backfill from ${legacyTag}`]
if (args.dryRun) {
console.log(`[backfill][dry-run] git ${tagArgs.join(' ')}`)
}
else {
runGit(tagArgs, { stdio: 'inherit', cwd })
}
if (args.push) {
const pushArgs = ['push', args.remote, newTag]
if (args.dryRun) {
console.log(`[backfill][dry-run] git ${pushArgs.join(' ')}`)
}
else {
runGit(pushArgs, { stdio: 'inherit', cwd })
}
}
created.push({ legacyTag, newTag })
}
console.log(`[backfill] Done. created=${created.length} skipped=${skipped.length}`)
}
main().catch((err) => {
console.error(err?.message || err)
process.exit(1)
})
@@ -0,0 +1,217 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright-core'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const playgroundDir = path.join(repoRoot, 'playground')
const host = '127.0.0.1'
function isPortOpen(port) {
return new Promise((resolve) => {
const socket = net.createConnection({ host, port })
socket.on('connect', () => {
socket.end()
resolve(true)
})
socket.on('error', () => {
socket.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 4241, end = 4270) {
for (let port = start; port <= end; port++) {
if (!await isPortOpen(port))
return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, timeoutMs = 60000) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (await isPortOpen(port))
return
await new Promise(resolve => setTimeout(resolve, 150))
}
throw new Error(`Timed out waiting for ${host}:${port}`)
}
function killProcessTree(child) {
if (!child || child.killed)
return
try {
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (!child.killed)
child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
function resolveChromeLaunchOptions() {
const candidates = [
process.env.PLAYWRIGHT_CHROME_PATH,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
].filter(Boolean)
for (const candidate of candidates) {
if (existsSync(candidate)) {
return {
executablePath: candidate,
headless: true,
}
}
}
return {
channel: 'chrome',
headless: true,
}
}
function startDevServer(port) {
const logs = []
const child = spawn(
'pnpm',
['-C', playgroundDir, 'exec', 'vite', '--host', host, '--port', String(port), '--strictPort'],
{
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
CI: '1',
},
},
)
child.stdout.on('data', chunk => logs.push(String(chunk)))
child.stderr.on('data', chunk => logs.push(String(chunk)))
return {
child,
getLogs() {
return logs.join('')
},
}
}
async function runScenario(page, codeRendererMode, width) {
return page.evaluate(async ({ codeRendererMode, width }) => {
const api = window.__heightEstimationExperiment
if (!api)
throw new Error('Missing __heightEstimationExperiment API.')
await api.setSourceMode('nodes')
await api.setCodeRendererMode(codeRendererMode)
await api.setLoadingPhase(false)
await api.setPaneWidth(width)
await api.waitUntilReady()
const summary = api.getSummary()
const allNodes = await api.runEstimatorBenchmark({
width,
passes: 5,
resetCachesBeforeFirstPass: true,
mode: 'all',
})
const skipMeasuredSimpleText = await api.runEstimatorBenchmark({
width,
passes: 5,
resetCachesBeforeFirstPass: true,
mode: 'skip-measured-simple-text',
})
return {
codeRendererMode,
width,
summary,
benchmarks: {
allNodes,
skipMeasuredSimpleText,
},
}
}, { codeRendererMode, width })
}
function assertScenario(result) {
const id = `${result.codeRendererMode}/${result.width}`
const { allNodes, skipMeasuredSimpleText } = result.benchmarks ?? {}
if (!(allNodes?.nodeCount >= 1000))
throw new Error(`[${id}] Benchmark node count is too low.`)
if (!(allNodes?.estimableCount >= 900))
throw new Error(`[${id}] Estimable node coverage is too low.`)
if (!(Number.isFinite(allNodes?.coldPassMs) && allNodes.coldPassMs > 0))
throw new Error(`[${id}] Missing cold benchmark pass.`)
if (!(Number.isFinite(allNodes?.warmAverageMs) && allNodes.warmAverageMs > 0))
throw new Error(`[${id}] Missing warm benchmark average.`)
if (!(Number.isFinite(skipMeasuredSimpleText?.warmAverageMs) && skipMeasuredSimpleText.warmAverageMs > 0))
throw new Error(`[${id}] Missing skip-measured-simple-text warm benchmark average.`)
if (!(skipMeasuredSimpleText?.skippedMeasuredSimpleTextCount > 0))
throw new Error(`[${id}] Expected some measured simple-text nodes to be skipped.`)
if (!(skipMeasuredSimpleText?.warmAverageMs < allNodes.warmAverageMs))
throw new Error(`[${id}] Skip-measured-simple-text warm pass should be cheaper than all-nodes.`)
}
async function run() {
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
const server = startDevServer(port)
const cleanup = () => killProcessTree(server.child)
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
process.on('exit', cleanup)
try {
await waitForPort(port)
const browser = await chromium.launch(resolveChromeLaunchOptions())
const page = await browser.newPage({ viewport: { width: 1600, height: 1200 } })
await page.goto(`http://${host}:${port}/height-estimation-experiment`, { waitUntil: 'load' })
const scenarios = []
for (const codeRendererMode of ['markdown', 'monaco']) {
for (const width of [520, 1280])
scenarios.push(await runScenario(page, codeRendererMode, width))
}
for (const scenario of scenarios)
assertScenario(scenario)
const report = {
markdown: {},
monaco: {},
}
for (const scenario of scenarios)
report[scenario.codeRendererMode][scenario.width] = scenario
console.log(JSON.stringify(report, null, 2))
await browser.close()
}
catch (error) {
console.error('[benchmark-height-estimation-experiment] failed')
console.error(error)
console.error(server.getLogs())
process.exitCode = 1
}
finally {
cleanup()
}
}
run()
@@ -0,0 +1,47 @@
#!/usr/bin/env node
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import typescript from '@rollup/plugin-typescript'
import { rollup } from 'rollup'
const root = path.resolve(new URL(import.meta.url).pathname, '..', '..')
const outdir = path.resolve(root, 'dist', 'workers')
async function buildWorker(entry, outName) {
const bundle = await rollup({
input: entry,
external: ['mermaid', 'katex'],
plugins: [
typescript({ tsconfig: path.resolve(root, 'tsconfig.json'), sourceMap: false }),
],
})
await bundle.write({
dir: outdir,
format: 'es',
entryFileNames: outName,
chunkFileNames: '[name].js',
sourcemap: false,
})
await bundle.close()
}
async function run() {
try {
if (!fs.existsSync(outdir))
fs.mkdirSync(outdir, { recursive: true })
await buildWorker(path.resolve(root, 'src/workers/katexRenderer.worker.ts'), 'katexRenderer.worker.js')
await buildWorker(path.resolve(root, 'src/workers/mermaidParser.worker.ts'), 'mermaidParser.worker.js')
console.log('Workers written to', outdir)
}
catch (err) {
console.error('Failed to build workers with rollup', err)
process.exit(1)
}
}
run()
@@ -0,0 +1,36 @@
#!/usr/bin/env node
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { build } from 'esbuild'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const outdir = path.resolve(__dirname, '../dist/workers')
async function run() {
try {
await build({
entryPoints: [
path.resolve(__dirname, '../src/workers/katexRenderer.worker.ts'),
path.resolve(__dirname, '../src/workers/mermaidParser.worker.ts'),
],
bundle: true,
format: 'esm',
platform: 'browser',
outdir,
sourcemap: false,
minify: true,
external: ['mermaid', 'katex'],
target: ['es2017'],
})
console.log('Workers built to', outdir)
}
catch (err) {
console.error('Failed to build workers', err)
process.exit(1)
}
}
run()
@@ -0,0 +1,83 @@
import { execFileSync } from 'node:child_process'
import { readdirSync, statSync } from 'node:fs'
import { join, relative } from 'node:path'
import process from 'node:process'
const root = process.cwd()
const distDir = join(root, 'dist')
const budgets = {
maxDistBytes: Number(process.env.MAX_DIST_BYTES || 800 * 1024),
maxJsChunkBytes: Number(process.env.MAX_JS_CHUNK_BYTES || 300 * 1024),
maxPackSizeBytes: Number(process.env.MAX_PACK_TGZ_BYTES || 250 * 1024),
maxPackUnpackedBytes: Number(process.env.MAX_PACK_UNPACKED_BYTES || 740 * 1024),
}
function formatBytes(bytes) {
if (bytes < 1024)
return `${bytes} B`
if (bytes < 1024 * 1024)
return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
}
function collectFiles(dir, files = []) {
for (const name of readdirSync(dir)) {
const fullPath = join(dir, name)
const stat = statSync(fullPath)
if (stat.isDirectory()) {
collectFiles(fullPath, files)
}
else {
files.push({
fullPath,
relPath: relative(root, fullPath),
size: stat.size,
})
}
}
return files
}
const allFiles = collectFiles(distDir)
const distBytes = allFiles.reduce((sum, f) => sum + f.size, 0)
const jsFiles = allFiles.filter(f => f.relPath.endsWith('.js'))
const largestJs = jsFiles.sort((a, b) => b.size - a.size)[0]
const packJson = execFileSync('npm', ['pack', '--dry-run', '--json'], {
cwd: root,
encoding: 'utf8',
})
const pack = JSON.parse(packJson)?.[0]
if (!pack)
throw new Error('Failed to read npm pack --dry-run --json output.')
const failures = []
if (distBytes > budgets.maxDistBytes) {
failures.push(`dist total is ${formatBytes(distBytes)}, exceeds budget ${formatBytes(budgets.maxDistBytes)}`)
}
if (largestJs && largestJs.size > budgets.maxJsChunkBytes) {
failures.push(`${largestJs.relPath} is ${formatBytes(largestJs.size)}, exceeds JS chunk budget ${formatBytes(budgets.maxJsChunkBytes)}`)
}
if (pack.size > budgets.maxPackSizeBytes) {
failures.push(`npm tarball is ${formatBytes(pack.size)}, exceeds budget ${formatBytes(budgets.maxPackSizeBytes)}`)
}
if (pack.unpackedSize > budgets.maxPackUnpackedBytes) {
failures.push(`npm unpacked size is ${formatBytes(pack.unpackedSize)}, exceeds budget ${formatBytes(budgets.maxPackUnpackedBytes)}`)
}
console.log(`[size-check] dist total: ${formatBytes(distBytes)}`)
if (largestJs)
console.log(`[size-check] largest JS: ${largestJs.relPath} (${formatBytes(largestJs.size)})`)
console.log(`[size-check] pack tarball: ${formatBytes(pack.size)}`)
console.log(`[size-check] pack unpacked: ${formatBytes(pack.unpackedSize)}`)
if (failures.length > 0) {
console.error('[size-check] Budget check failed:')
for (const line of failures)
console.error(`- ${line}`)
process.exit(1)
}
console.log('[size-check] Budget check passed.')
@@ -0,0 +1,56 @@
const fs = require('node:fs')
const { createRequire } = require('node:module')
const path = require('node:path')
const process = require('node:process')
const repoRoot = process.cwd()
const packageJsonPaths = [
path.join(repoRoot, 'package.json'),
...fs.readdirSync(path.join(repoRoot, 'packages'), { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => path.join(repoRoot, 'packages', entry.name, 'package.json'))
.filter(file => fs.existsSync(file)),
]
let hasError = false
for (const packageJsonPath of packageJsonPaths) {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
const peerDependencies = pkg.peerDependencies || {}
const peerDependenciesMeta = pkg.peerDependenciesMeta || {}
const peerNames = Object.keys(peerDependencies)
if (peerNames.length === 0)
continue
const packageDir = path.dirname(packageJsonPath)
const requireFromPackage = createRequire(path.join(packageDir, 'package.json'))
console.log(`\n${pkg.name}`)
for (const peerName of peerNames) {
const isOptional = !!peerDependenciesMeta[peerName]?.optional
try {
const resolvedPackageJson = requireFromPackage.resolve(`${peerName}/package.json`)
const installedPackageJson = JSON.parse(fs.readFileSync(resolvedPackageJson, 'utf8'))
console.log(` OK ${peerName}@${installedPackageJson.version}${isOptional ? ' (optional)' : ''}`)
}
catch {
if (isOptional) {
console.log(` MISS ${peerName} (optional)`)
continue
}
hasError = true
console.error(` FAIL ${peerName} is required but not installed`)
}
}
}
if (hasError) {
console.error('\nPeer dependency check failed.')
process.exit(1)
}
console.log('\nPeer dependency check passed.')
@@ -0,0 +1,37 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
const root = process.cwd()
const enDir = path.join(root, 'docs', 'guide')
const zhDir = path.join(root, 'docs', 'zh', 'guide')
async function main() {
const files = await fs.readdir(enDir)
const mdFiles = files.filter(f => f.endsWith('.md'))
const missing = []
for (const f of mdFiles) {
const zhPath = path.join(zhDir, f)
try {
await fs.access(zhPath)
}
catch {
missing.push(f)
}
}
if (missing.length) {
console.error('Missing zh translations for the following docs:')
missing.forEach(m => console.error(' -', m))
console.error('\nHint: run `pnpm docs:sync-zh` to create placeholders.')
process.exit(1)
}
else {
console.log('All docs/guide pages have a corresponding docs/zh/guide placeholder.')
}
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
@@ -0,0 +1,35 @@
const fs = require('node:fs')
const path = require('node:path')
const process = require('node:process')
const distDir = path.resolve(__dirname, '..', 'dist')
const typesDir = path.join(distDir, 'types')
function rewriteTypesEntry(content) {
return content
.replace(/from\s+(['"])\.\//g, 'from $1./types/')
.replace(/import\((['"])\.\//g, 'import($1./types/')
}
function referencesTypesDir(content) {
return /from\s+['"]\.\/types\/|import\(['"]\.\/types\//.test(content)
}
const sourcePath = path.join(typesDir, 'exports.d.ts')
const targetPath = path.join(distDir, 'index.d.ts')
if (fs.existsSync(targetPath)) {
const bundledContent = fs.readFileSync(targetPath, 'utf8')
if (!referencesTypesDir(bundledContent)) {
if (fs.existsSync(typesDir))
fs.rmSync(typesDir, { recursive: true, force: true })
console.log('Keeping bundled', targetPath)
process.exit(0)
}
}
if (fs.existsSync(sourcePath)) {
const content = rewriteTypesEntry(fs.readFileSync(sourcePath, 'utf8'))
fs.writeFileSync(targetPath, content)
console.log('Rewrote', targetPath, 'from', sourcePath)
}
@@ -0,0 +1,28 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
const fromDir = path.resolve('dist-tw')
const toDir = path.resolve('dist')
const fromCss = path.join(fromDir, 'index.tailwind.css')
const toCss = path.join(toDir, 'index.tailwind.css')
async function main() {
await fs.mkdir(toDir, { recursive: true })
try {
await fs.copyFile(fromCss, toCss)
}
catch (err) {
const e = err
throw new Error(`[copy-tailwind-css] Missing ${fromCss}. Did the tailwind build run? (${e?.message || e})`)
}
await fs.rm(fromDir, { recursive: true, force: true })
}
main().catch((err) => {
console.error(err)
process.exitCode = 1
})
@@ -0,0 +1,253 @@
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
function run(command, args, options = {}) {
return execFileSync(command, args, {
cwd: options.cwd,
encoding: 'utf8',
stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'],
})
}
function runGit(args, options) {
return run('git', args, options)
}
function parseArgs(argv) {
const args = {
remote: 'origin',
push: false,
dryRun: false,
date: null,
all: false,
}
for (let i = 0; i < argv.length; i++) {
const cur = argv[i]
if (cur === '--remote') {
args.remote = argv[++i]
}
else if (cur === '--push') {
args.push = true
}
else if (cur === '--dry-run') {
args.dryRun = true
}
else if (cur === '--date') {
args.date = argv[++i]
}
else if (cur === '--all') {
args.all = true
}
else if (cur === '--help' || cur === '-h') {
console.log(`
Usage:
node scripts/create-nightly-tags.mjs [options]
Options:
--push Push created tags to remote
--remote <name> Git remote name (default: origin)
--dry-run Print actions only
--date <YYYYMMDD> Override date segment (default: UTC today)
--all Tag all packages even if unchanged
`)
process.exit(0)
}
else {
throw new Error(`Unknown argument: ${cur}`)
}
}
return args
}
function utcDateYYYYMMDD() {
const d = new Date()
const y = String(d.getUTCFullYear())
const m = String(d.getUTCMonth() + 1).padStart(2, '0')
const day = String(d.getUTCDate()).padStart(2, '0')
return `${y}${m}${day}`
}
function readJson(relativePath) {
const abs = path.resolve(process.cwd(), relativePath)
return JSON.parse(readFileSync(abs, 'utf8'))
}
function resolvePackage(relativePackageJson, paths) {
const pkg = readJson(relativePackageJson)
const name = String(pkg?.name ?? '').trim()
if (!name)
throw new Error(`Invalid package.json (missing name): ${relativePackageJson}`)
return { name, packageJson: relativePackageJson, paths }
}
function listTags(glob) {
return runGit(['tag', '--list', glob])
.split('\n')
.map(s => s.trim())
.filter(Boolean)
}
function latestTag(glob) {
const tags = listTags(glob)
if (!tags.length)
return null
// Sort by taggerdate / creatordate (annotated tags); fallback by refname.
const sorted = runGit([
'for-each-ref',
'--sort=-creatordate',
'--format=%(refname:strip=2)',
'refs/tags',
])
.split('\n')
.map(s => s.trim())
.filter(Boolean)
const set = new Set(tags)
for (const t of sorted) {
if (set.has(t))
return t
}
return tags[tags.length - 1]
}
function shortSha(ref = 'HEAD') {
return runGit(['rev-parse', '--short=7', ref]).trim()
}
function hasChangesSince(baseRef, paths) {
if (!baseRef)
return true
const baseSha = runGit(['rev-parse', `${baseRef}^{}`]).trim()
const headSha = runGit(['rev-parse', 'HEAD^{}']).trim()
if (baseSha === headSha)
return false
const files = runGit(['diff', '--name-only', `${baseRef}..HEAD`, '--', ...paths]).trim()
return Boolean(files)
}
function tagExists(tag) {
try {
runGit(['rev-parse', `${tag}^{}`])
return true
}
catch {
return false
}
}
function createAnnotatedTag(tag, message) {
const args = ['tag', '-a', tag, '-m', message]
return ['git', ...args]
}
function pushTag(remote, tag) {
return ['git', 'push', remote, tag]
}
async function main() {
const args = parseArgs(process.argv.slice(2))
const date = args.date ?? utcDateYYYYMMDD()
const sha = shortSha('HEAD')
const packages = [
resolvePackage('package.json', [
'package.json',
'src',
'scripts',
]),
resolvePackage('packages/markstream-vue2/package.json', [
'packages/markstream-vue2',
]),
resolvePackage('packages/markstream-react/package.json', [
'packages/markstream-react',
]),
resolvePackage('packages/markdown-parser/package.json', [
'packages/markdown-parser',
]),
]
const packageByName = new Map(packages.map(p => [p.name, p]))
const parserName = 'stream-markdown-parser'
const parserPropagatesTo = new Set([
'markstream-vue',
'markstream-vue2',
'markstream-react',
])
if (!packageByName.has(parserName)) {
throw new Error(`[nightly] Expected parser package "${parserName}" not found in package list.`)
}
const created = []
const directChangedByName = new Map()
const baseRefByName = new Map()
for (const pkg of packages) {
const baseNightly = latestTag(`${pkg.name}@nightly-*`)
const baseStable = latestTag(`${pkg.name}@[0-9]*`)
const base = baseNightly ?? baseStable
baseRefByName.set(pkg.name, base)
const changed = args.all ? true : hasChangesSince(base, pkg.paths)
directChangedByName.set(pkg.name, changed)
}
const parserChanged = Boolean(directChangedByName.get(parserName))
const effectiveChangedByName = new Map(directChangedByName)
const nightlyReasonByName = new Map()
for (const [name, changed] of directChangedByName.entries()) {
if (changed) {
nightlyReasonByName.set(name, 'direct')
continue
}
if (parserChanged && parserPropagatesTo.has(name)) {
effectiveChangedByName.set(name, true)
nightlyReasonByName.set(name, `dependency:${parserName}`)
}
}
for (const pkg of packages) {
const changed = Boolean(effectiveChangedByName.get(pkg.name))
if (!changed)
continue
const tag = `${pkg.name}@nightly-${date}-${sha}`
if (tagExists(tag))
continue
const reason = nightlyReasonByName.get(pkg.name) ?? 'direct'
const message = reason === 'direct'
? `${pkg.name} nightly ${date} ${sha}`
: `${pkg.name} nightly ${date} ${sha} (${reason})`
const cmd = createAnnotatedTag(tag, message)
if (args.dryRun) {
console.log(`[nightly][dry-run] ${cmd.join(' ')}`)
}
else {
runGit(cmd.slice(1), { stdio: 'inherit' })
}
if (args.push) {
const pushCmd = pushTag(args.remote, tag)
if (args.dryRun) {
console.log(`[nightly][dry-run] ${pushCmd.join(' ')}`)
}
else {
runGit(pushCmd.slice(1), { stdio: 'inherit' })
}
}
created.push(tag)
}
// Machine-readable output for workflows.
console.log(JSON.stringify({ created }, null, 2))
}
main().catch((err) => {
console.error(err?.message || err)
process.exit(1)
})
@@ -0,0 +1,10 @@
import { getMarkdown } from '../src/utils/markdown/getMarkdown.js'
const md = getMarkdown({})
const content = '**Figure: Vue Markdown Icon (served from /vue-markdown-icon.svg)**'
const tokens = md.parse(content, {})
console.log('parsed tokens count', tokens.length)
if (tokens[1] && tokens[1].children) {
console.log('inline children length', tokens[1].children.length)
console.log(JSON.stringify(tokens[1].children, null, 2))
}
@@ -0,0 +1,19 @@
import { getMarkdown } from '../src/utils/markdown/getMarkdown'
const md = getMarkdown({})
// The content above is intentionally escaped wrong to mimic test; instead
// use the original test string:
const test = '可以看作 \\(' + '\\boldsymbol{\\beta}' + '\\) 与 \\(' + 'W' + '\\) 正交的一个特例(当 \\(' + 'W' + '\\) 只由 \\(' + '\\boldsymbol{\\alpha}' + '\\) 张成时)。'
console.log('INPUT:', test)
const tokens = md.parse(test, {})
console.log('TOKENS:')
for (const t of tokens) {
console.log('type:', t.type, 'tag:', t.tag, 'content:', t.content ? t.content : '')
if (t.children) {
for (const c of t.children) {
console.log(' child:', c.type, c.tag, c.content ? c.content : '')
}
}
}
@@ -0,0 +1,820 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright-core'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const playgroundDir = path.join(repoRoot, 'playground-angular')
const host = '127.0.0.1'
function isPortOpen(port, listenHost = host) {
return new Promise((resolve) => {
const socket = net.createConnection({ host: listenHost, port })
socket.on('connect', () => {
socket.end()
resolve(true)
})
socket.on('error', () => {
socket.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 4175, end = 4205) {
for (let port = start; port <= end; port++) {
if (!await isPortOpen(port))
return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, timeout = 60000) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeout) {
if (await isPortOpen(port))
return
await new Promise(resolve => setTimeout(resolve, 150))
}
throw new Error(`Timed out waiting for ${host}:${port}`)
}
function killProcessTree(child) {
if (!child || child.killed)
return
try {
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (!child.killed)
child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
function resolveChromeLaunchOptions() {
const candidates = [
process.env.PLAYWRIGHT_CHROME_PATH,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
].filter(Boolean)
for (const candidate of candidates) {
if (existsSync(candidate)) {
return {
executablePath: candidate,
headless: true,
}
}
}
return {
channel: 'chrome',
headless: true,
}
}
function startDevServer(port) {
const logBuffer = []
const child = spawn(
'pnpm',
['exec', 'vite', 'dev', '--host', host, '--port', String(port)],
{
cwd: playgroundDir,
env: {
...process.env,
CI: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
},
)
const appendLogs = (chunk) => {
const text = String(chunk)
logBuffer.push(text)
if (logBuffer.length > 160)
logBuffer.splice(0, logBuffer.length - 160)
}
child.stdout.on('data', appendLogs)
child.stderr.on('data', appendLogs)
return {
child,
getLogs() {
return logBuffer.join('')
},
}
}
function summarizeErrors(errors) {
return errors
.map(error => error.trim())
.filter(Boolean)
}
function isExecutionContextDestroyed(error) {
const message = String(error?.message ?? error ?? '')
return message.includes('Execution context was destroyed')
|| message.includes('most likely because of a navigation')
}
async function installHoverProbe(page) {
await page.addInitScript(() => {
const globalKey = '__MARKSTREAM_HOVER_PROBE__'
if (globalThis[globalKey])
return
const state = {
mouseoverCount: 0,
mouseoutCount: 0,
lastTitle: '',
frameCount: 0,
maxFrameGap: 0,
maxTimerDrift: 0,
}
let lastFrameAt = performance.now()
const tick = (now) => {
state.frameCount += 1
state.maxFrameGap = Math.max(state.maxFrameGap, now - lastFrameAt)
lastFrameAt = now
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
let expectedTimerAt = performance.now() + 100
setInterval(() => {
const now = performance.now()
state.maxTimerDrift = Math.max(state.maxTimerDrift, Math.max(0, now - expectedTimerAt))
expectedTimerAt = now + 100
}, 100)
const resolveTarget = (value) => {
if (!(value instanceof Element))
return null
return value.closest('[title], a[href]')
}
document.addEventListener('mouseover', (event) => {
const target = resolveTarget(event.target)
if (!target)
return
state.mouseoverCount += 1
state.lastTitle = target.getAttribute('title') || target.getAttribute('href') || target.textContent || ''
}, true)
document.addEventListener('mouseout', (event) => {
const target = resolveTarget(event.target)
if (!target)
return
state.mouseoutCount += 1
}, true)
globalThis[globalKey] = state
})
}
async function readHoverProbe(page) {
return page.evaluate(() => {
const state = globalThis.__MARKSTREAM_HOVER_PROBE__ || {}
return {
mouseoverCount: Number(state.mouseoverCount || 0),
mouseoutCount: Number(state.mouseoutCount || 0),
lastTitle: String(state.lastTitle || ''),
frameCount: Number(state.frameCount || 0),
maxFrameGap: Number(state.maxFrameGap || 0),
maxTimerDrift: Number(state.maxTimerDrift || 0),
}
})
}
async function readHomeProgress(page) {
return page.evaluate(() => {
const text = document.querySelector('.meta')?.textContent?.trim() || ''
const match = text.match(/^(\d+)\s*\/\s*(\d+)\s*\((\d+)%\)$/)
const current = match ? Number.parseInt(match[1], 10) : 0
const total = match ? Number.parseInt(match[2], 10) : 0
const percent = match ? Number.parseInt(match[3], 10) : 0
return {
text,
current,
total,
percent,
done: total > 0 && current >= total && percent === 100,
}
})
}
async function readHomePlaceholderCount(page) {
return page.evaluate(() => {
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0
return Array.from(document.querySelectorAll('.panel.preview .markstream-angular .node-placeholder'))
.filter((node) => {
const rect = node.getBoundingClientRect()
return rect.width > 0
&& rect.height > 0
&& rect.bottom > 0
&& rect.right > 0
&& rect.top < viewportHeight
&& rect.left < viewportWidth
})
.length
})
}
async function waitForProbeIncrement(page, key, previousValue, timeout = 1500) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeout) {
const current = await readHoverProbe(page)
if (current[key] > previousValue)
return Date.now() - startedAt
await page.waitForTimeout(50)
}
throw new Error(`Timed out waiting for hover probe increment: ${key}`)
}
async function waitForHomeHoverTarget(page, timeout = 90000) {
const selector = '.panel.preview .markstream-angular [title], .panel.preview .markstream-angular a[href]'
const startedAt = Date.now()
while (Date.now() - startedAt < timeout) {
const locator = page.locator(selector).first()
if (await locator.count()) {
try {
if (await locator.isVisible())
return selector
}
catch {
// The preview re-renders while streaming; retry with a fresh locator.
}
}
await page.waitForTimeout(250)
}
throw new Error('Timed out waiting for a hoverable home preview target.')
}
async function exerciseHomeHoverDuringStreaming(page, selector) {
const samples = []
const startedAt = Date.now()
let maxPlaceholderCount = 0
while (Date.now() - startedAt < 180000) {
const progress = await readHomeProgress(page)
if (progress.done)
break
const before = await readHoverProbe(page)
const target = page.locator(selector).first()
await target.hover({ timeout: 3000 })
const overLatencyMs = await waitForProbeIncrement(page, 'mouseoverCount', before.mouseoverCount, 1500)
await page.mouse.move(8, 8)
const outLatencyMs = await waitForProbeIncrement(page, 'mouseoutCount', before.mouseoutCount, 1500)
const placeholderCount = await readHomePlaceholderCount(page)
maxPlaceholderCount = Math.max(maxPlaceholderCount, placeholderCount)
samples.push({
current: progress.current,
percent: progress.percent,
overLatencyMs,
outLatencyMs,
placeholderCount,
})
await page.waitForTimeout(1200)
}
const finalProbe = await readHoverProbe(page)
return {
interactionCount: samples.length,
maxOverLatencyMs: Math.max(0, ...samples.map(sample => sample.overLatencyMs)),
maxOutLatencyMs: Math.max(0, ...samples.map(sample => sample.outLatencyMs)),
maxPlaceholderCount,
finalProbe,
samples,
}
}
async function waitForHomeCompletion(page, timeout = 180000) {
await page.waitForFunction(() => {
const text = document.querySelector('.meta')?.textContent?.trim() || ''
const match = text.match(/^(\d+)\s*\/\s*(\d+)\s*\((\d+)%\)$/)
if (!match)
return false
const current = Number.parseInt(match[1], 10)
const total = Number.parseInt(match[2], 10)
const percent = Number.parseInt(match[3], 10)
return total > 0 && current >= total && percent === 100
}, { timeout })
}
async function collectHomeCompletionMetrics(page) {
return page.evaluate(() => {
const preview = document.querySelector('.panel.preview .markstream-angular')
const text = preview?.textContent || ''
const metaText = document.querySelector('.meta')?.textContent?.trim() || ''
return {
metaText,
textLength: text.length,
containsTaylor: text.includes('泰勒公式'),
containsOrthogonalComplement: text.includes('正交补空间'),
containsHelloWorldTail: text.includes('hello world'),
linkCount: document.querySelectorAll('.panel.preview .markstream-angular a').length,
katexCount: document.querySelectorAll('.panel.preview .markstream-angular .katex').length,
}
})
}
async function waitForHomeStreamingCodeHighlight(page, timeout = 180000) {
const startedAt = Date.now()
let lastSnapshot = null
while (Date.now() - startedAt < timeout) {
lastSnapshot = await page.evaluate(() => {
const text = document.querySelector('.meta')?.textContent?.trim() || ''
const match = text.match(/^(\d+)\s*\/\s*(\d+)\s*\((\d+)%\)$/)
const current = match ? Number.parseInt(match[1], 10) : 0
const total = match ? Number.parseInt(match[2], 10) : 0
const percent = match ? Number.parseInt(match[3], 10) : 0
return {
metaText: text,
current,
total,
percent,
done: total > 0 && current >= total && percent === 100,
monacoCount: document.querySelectorAll('.panel.preview .markstream-angular [data-markstream-monaco="1"]').length,
fallbackPreCount: document.querySelectorAll('.panel.preview .markstream-angular .code-fallback-plain').length,
}
})
if (lastSnapshot.monacoCount > 0)
return lastSnapshot
if (lastSnapshot.done)
return lastSnapshot
await page.waitForTimeout(400)
}
throw new Error(`Timed out waiting for a highlighted home code block: ${JSON.stringify(lastSnapshot)}`)
}
async function collectHomeThinkingMetrics(page) {
await page.waitForFunction(() => {
const text = document.querySelector('.meta')?.textContent?.trim() || ''
const match = text.match(/(\d+)\s*\/\s*(\d+)/)
return !!match && Number.parseInt(match[1], 10) >= 1000
}, { timeout: 30000 })
return page.evaluate(() => {
const content = document.querySelector('.thinking-node__content')
const text = content?.textContent || ''
return {
textLength: text.trim().length,
containsComprehensiveAnswer: text.includes('全面的回答'),
paragraphCount: content?.querySelectorAll('p').length || 0,
listCount: content?.querySelectorAll('li').length || 0,
}
})
}
async function collectHomeLinkTooltip(page) {
const link = page
.locator('.panel.preview .markstream-angular a.link-node[href="https://github.com/Simon-He95/markstream-vue"]')
.filter({ hasText: 'Star on GitHub' })
.first()
await link.waitFor({ state: 'visible', timeout: 30000 })
await link.hover()
await page.waitForFunction(() => {
const tooltip = document.querySelector('.ms-tooltip')
if (!tooltip)
return false
const style = window.getComputedStyle(tooltip)
const text = tooltip.textContent?.trim() || ''
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number(style.opacity || '1') > 0.05
&& text.includes('https://github.com/Simon-He95/markstream-vue')
}, { timeout: 10000 })
return page.evaluate(() => {
const tooltip = document.querySelector('.ms-tooltip')
const style = tooltip ? window.getComputedStyle(tooltip) : null
return {
text: tooltip?.textContent?.trim() || '',
visible: Boolean(
tooltip
&& style
&& style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number(style.opacity || '1') > 0.05,
),
}
})
}
async function waitForStableEnhancements(page) {
await page.waitForSelector('.preview-surface .katex', { timeout: 30000 })
await page.waitForSelector('.preview-surface .markstream-angular-mermaid svg', { timeout: 30000 })
await page.waitForSelector('.preview-surface [data-markstream-infographic="1"] svg', { timeout: 30000 })
await page.waitForSelector('.preview-surface [data-markstream-d2="1"] svg', { timeout: 30000 })
}
async function collectBaselineMetrics(page) {
return page.evaluate(() => ({
katexCount: document.querySelectorAll('.preview-surface .katex').length,
mermaidSvgCount: document.querySelectorAll('.preview-surface .markstream-angular-mermaid svg').length,
infographicSvgCount: document.querySelectorAll('.preview-surface [data-markstream-infographic="1"] svg').length,
d2SvgCount: document.querySelectorAll('.preview-surface [data-markstream-d2="1"] svg').length,
}))
}
async function verifyBaselineInteractions(page) {
const mermaidBlock = page.locator('.preview-surface .mermaid-block').first()
await mermaidBlock.getByRole('button', { name: 'Source' }).click()
await page.waitForSelector('.preview-surface .mermaid-block .mermaid-source', { timeout: 10000 })
await mermaidBlock.getByRole('button', { name: 'Preview' }).click()
await page.waitForSelector('.preview-surface .mermaid-block .markstream-angular-mermaid svg', { timeout: 10000 })
await mermaidBlock.getByRole('button', { name: 'Fullscreen' }).click()
await page.waitForSelector('.mermaid-modal-overlay', { timeout: 10000 })
await page.getByRole('button', { name: 'Close' }).click()
await page.waitForSelector('.mermaid-modal-overlay', { state: 'hidden', timeout: 10000 })
const d2Block = page.locator('.preview-surface [data-markstream-d2="1"]').first()
await d2Block.getByRole('button', { name: 'Source' }).click()
await page.waitForFunction(() => {
const block = document.querySelector('.preview-surface [data-markstream-d2="1"]')
return block?.getAttribute('data-mode') === 'source'
}, { timeout: 10000 })
await d2Block.getByRole('button', { name: 'Preview' }).click()
await page.waitForFunction(() => {
const block = document.querySelector('.preview-surface [data-markstream-d2="1"]')
return block?.getAttribute('data-mode') === 'preview'
}, { timeout: 10000 })
const infographicBlock = page.locator('.preview-surface [data-markstream-infographic="1"]').first()
await infographicBlock.getByRole('button', { name: 'Source' }).click()
await page.waitForFunction(() => {
const block = document.querySelector('.preview-surface [data-markstream-infographic="1"]')
return block?.getAttribute('data-mode') === 'source'
}, { timeout: 10000 })
await infographicBlock.getByRole('button', { name: 'Preview' }).click()
await page.waitForFunction(() => {
const block = document.querySelector('.preview-surface [data-markstream-infographic="1"]')
return block?.getAttribute('data-mode') === 'preview'
}, { timeout: 10000 })
}
async function collectDiffMetrics(page) {
return page.evaluate(() => ({
wrapperCount: document.querySelectorAll('.preview-surface [data-markstream-monaco="1"]').length,
monacoCount: document.querySelectorAll('.preview-surface [data-markstream-monaco="1"] .monaco-editor').length,
monacoDiffCount: document.querySelectorAll('.preview-surface [data-markstream-monaco="1"] .monaco-diff-editor').length,
badgeTexts: Array.from(document.querySelectorAll('.preview-surface .markstream-angular-enhanced-block__badge'))
.map(node => node.textContent?.trim())
.filter(Boolean),
}))
}
async function verifyDiffInteractions(page) {
const diffBlock = page.locator('.preview-surface [data-markstream-monaco-diff="1"]').first()
await diffBlock.locator('button[title="Collapse"]').first().click()
await page.waitForFunction(() => {
const body = document.querySelector('.preview-surface [data-markstream-monaco-diff="1"] .code-block-body')
return body?.classList.contains('code-block-body--collapsed') === true
}, { timeout: 10000 })
await diffBlock.locator('button[title="Expand"]').first().click()
await page.waitForFunction(() => {
const body = document.querySelector('.preview-surface [data-markstream-monaco-diff="1"] .code-block-body')
return body?.classList.contains('code-block-body--collapsed') === false
}, { timeout: 10000 })
await diffBlock.locator('button[title="Copy"]').first().click()
await page.waitForFunction(() => {
const live = document.querySelector('.preview-surface [data-markstream-monaco-diff="1"] [role="status"]')
return live?.textContent?.includes('Copied')
}, { timeout: 10000 })
}
async function collectThinkingMetrics(page) {
return page.evaluate(() => ({
thinkingCount: document.querySelectorAll('.preview-surface .thinking-node').length,
mermaidSvgCount: document.querySelectorAll('.preview-surface .thinking-node .markstream-angular-mermaid svg').length,
monacoCount: document.querySelectorAll('.preview-surface .thinking-node [data-markstream-monaco="1"]').length,
listCount: document.querySelectorAll('.preview-surface .thinking-node li').length,
previewExcerpt: (document.querySelector('.preview-surface')?.textContent || '').slice(0, 500),
}))
}
async function collectThinkingDebug(page) {
return page.evaluate(() => ({
thinkingHtml: (document.querySelector('.preview-surface .thinking-node')?.innerHTML || '').slice(0, 8000),
blockCount: document.querySelectorAll('.preview-surface .thinking-node .mermaid-block').length,
blockHtml: (document.querySelector('.preview-surface .thinking-node .mermaid-block')?.outerHTML || '').slice(0, 4000),
sourceCount: document.querySelectorAll('.preview-surface .thinking-node .mermaid-source').length,
previewCount: document.querySelectorAll('.preview-surface .thinking-node .mermaid-preview').length,
hostCount: document.querySelectorAll('.preview-surface .thinking-node .markstream-angular-mermaid').length,
stage: document.querySelector('.preview-surface .thinking-node .mermaid-block')?.getAttribute('data-stage'),
errorTexts: Array.from(document.querySelectorAll('.preview-surface .thinking-node .mermaid-error'))
.map(node => node.textContent?.trim())
.filter(Boolean),
}))
}
async function waitForDiffEditors(page, timeout = 60000) {
const startedAt = Date.now()
let lastSnapshot = null
while (Date.now() - startedAt < timeout) {
lastSnapshot = await page.evaluate(() => ({
wrapperCount: document.querySelectorAll('.preview-surface [data-markstream-monaco="1"]').length,
monacoCount: document.querySelectorAll('.preview-surface [data-markstream-monaco="1"] .monaco-editor').length,
badgeTexts: Array.from(document.querySelectorAll('.preview-surface .markstream-angular-enhanced-block__badge'))
.map(node => node.textContent?.trim())
.filter(Boolean),
previewExcerpt: (document.querySelector('.preview-surface')?.textContent || '').slice(0, 500),
textareaExcerpt: (((document.querySelector('textarea') && 'value' in document.querySelector('textarea'))
? document.querySelector('textarea').value
: '') || '').slice(0, 160),
}))
if (lastSnapshot.wrapperCount >= 2 && lastSnapshot.monacoCount >= 2)
return lastSnapshot
await page.waitForTimeout(1000)
}
throw new Error(`Timed out waiting for diff Monaco blocks: ${JSON.stringify(lastSnapshot)}`)
}
async function collectStressMetrics(page) {
return page.evaluate(() => ({
tableCount: document.querySelectorAll('.preview-surface table').length,
detailsCount: document.querySelectorAll('.preview-surface details').length,
summaryCount: document.querySelectorAll('.preview-surface summary').length,
blockquoteCount: document.querySelectorAll('.preview-surface blockquote').length,
previewExcerpt: (document.querySelector('.preview-surface')?.textContent || '').slice(0, 500),
}))
}
async function collectStreamingMetrics(page) {
const progressNode = page.locator('.hero-panel__metrics .metric-card').nth(3).locator('strong')
const readProgress = async () => {
const text = (await progressNode.textContent()).trim()
return Number.parseInt(text.replace(/\D/g, ''), 10)
}
await page.getByRole('button', { name: '开始流式渲染' }).click()
await page.waitForFunction(() => {
const pill = document.querySelectorAll('.workspace-card .mini-pill')[1]
const foot = document.querySelectorAll('.workspace-card__foot span')[3]
return pill?.textContent?.trim() === 'Streaming'
&& foot?.textContent?.includes('正在逐步追加中')
}, { timeout: 10000 })
const earlyProgress = await readProgress()
await page.waitForFunction(() => {
const progress = document.querySelectorAll('.hero-panel__metrics .metric-card strong')[3]
const pill = document.querySelectorAll('.workspace-card .mini-pill')[1]
const foot = document.querySelectorAll('.workspace-card__foot span')[3]
return progress?.textContent?.trim() === '100%'
&& pill?.textContent?.trim() === 'Ready'
&& foot?.textContent?.includes('已显示完整输入')
}, { timeout: 30000 })
return {
earlyProgress,
finalProgress: await readProgress(),
}
}
async function collectHomeStreamingHealth(page, initialMetaText) {
let previousMetaText = initialMetaText
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
await page.waitForFunction((previous) => {
const meta = document.querySelector('.meta')
return !!meta && meta.textContent?.trim() !== previous
}, previousMetaText, { timeout: 20000 })
return await page.evaluate(() => new Promise((resolve) => {
let frameCount = 0
const startedAt = performance.now()
const tick = (now) => {
frameCount += 1
if (now - startedAt >= 800) {
resolve({
frameCount,
metaText: document.querySelector('.meta')?.textContent?.trim() || '',
})
return
}
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}))
}
catch (error) {
if (!isExecutionContextDestroyed(error) || attempt === 2)
throw error
await page.waitForLoadState('domcontentloaded', { timeout: 30000 }).catch(() => {})
await page.waitForSelector('.meta', { timeout: 30000 })
previousMetaText = (await page.locator('.meta').textContent().catch(() => initialMetaText)).trim()
}
}
throw new Error('Unable to collect home streaming health after navigation retries')
}
async function main() {
const port = await findFreePort()
const server = startDevServer(port)
try {
await waitForPort(port)
const browser = await chromium.launch(resolveChromeLaunchOptions())
const page = await browser.newPage({ viewport: { width: 1440, height: 1600 } })
await installHoverProbe(page)
const consoleErrors = []
const pageErrors = []
page.on('console', (msg) => {
if (msg.type() === 'error')
consoleErrors.push(msg.text())
})
page.on('pageerror', error => pageErrors.push(String(error)))
const homeUrl = `http://${host}:${port}/`
await page.goto(homeUrl, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('text=markstream-angular playground', { timeout: 30000 })
await page.waitForSelector('text=Open /test', { timeout: 30000 })
const homeProgressText = (await page.locator('.meta').textContent()).trim()
const homeStreamingHealth = await collectHomeStreamingHealth(page, homeProgressText)
const homeHoverSelector = await waitForHomeHoverTarget(page)
const homeStreamingCodeHighlight = await waitForHomeStreamingCodeHighlight(page)
const homeHoverDuringStreaming = await exerciseHomeHoverDuringStreaming(page, homeHoverSelector)
const homeThinking = await collectHomeThinkingMetrics(page)
const homeLinkTooltip = await collectHomeLinkTooltip(page)
await waitForHomeCompletion(page)
const homeCompletion = await collectHomeCompletionMetrics(page)
await page.getByRole('button', { name: 'Open /test' }).click()
await page.waitForURL(new RegExp(`http://${host}:${port}/test$`), { timeout: 15000 })
await page.waitForSelector('text=markstream-angular /test', { timeout: 30000 })
await waitForStableEnhancements(page)
const baseline = await collectBaselineMetrics(page)
await verifyBaselineInteractions(page)
await page.getByRole('button', { name: 'Thinking 嵌套重节点' }).click()
await page.waitForSelector('.preview-surface .thinking-node', { timeout: 20000 })
try {
await page.waitForSelector('.preview-surface .thinking-node .markstream-angular-mermaid svg', { timeout: 30000 })
}
catch (error) {
const thinkingDebug = await collectThinkingDebug(page)
throw new Error(`Timed out waiting for thinking Mermaid render: ${JSON.stringify(thinkingDebug)}`, { cause: error })
}
await page.waitForSelector('.preview-surface .thinking-node [data-markstream-monaco="1"]', { timeout: 30000 })
const thinking = await collectThinkingMetrics(page)
await page.getByRole('button', { name: 'Diff 与代码流' }).click()
await waitForDiffEditors(page)
const diff = await collectDiffMetrics(page)
await verifyDiffInteractions(page)
await page.getByRole('button', { name: '结构压力' }).click()
await page.waitForSelector('.preview-surface table', { timeout: 20000 })
await page.waitForFunction(() => {
return document.querySelectorAll('.preview-surface details').length >= 1
&& document.querySelectorAll('.preview-surface summary').length >= 1
}, { timeout: 20000 })
const stress = await collectStressMetrics(page)
const streaming = await collectStreamingMetrics(page)
await page.getByRole('button', { name: '返回主 demo' }).click()
await page.waitForURL(new RegExp(`http://${host}:${port}/$`), { timeout: 15000 })
await page.waitForSelector('text=markstream-angular playground', { timeout: 30000 })
const screenshot = '/tmp/markstream-angular-playground-e2e.png'
await page.screenshot({ path: screenshot, fullPage: true })
await browser.close()
const result = {
homeUrl,
homeProgressText,
homeStreamingHealth,
homeHoverDuringStreaming,
homeThinking,
homeLinkTooltip,
homeStreamingCodeHighlight,
homeCompletion,
baseline,
thinking,
diff,
stress,
streaming,
consoleErrors: summarizeErrors(consoleErrors),
pageErrors: summarizeErrors(pageErrors),
screenshot,
}
console.log(JSON.stringify(result, null, 2))
if (result.consoleErrors.length > 0 || result.pageErrors.length > 0) {
throw new Error(`Unexpected browser errors: console=${result.consoleErrors.length}, page=${result.pageErrors.length}`)
}
if (result.homeStreamingHealth.frameCount < 20 || result.homeStreamingHealth.metaText === result.homeProgressText) {
throw new Error(`Home page became unresponsive while streaming: ${JSON.stringify(result.homeStreamingHealth)}`)
}
if (result.homeCompletion.metaText !== '11268 / 11268 (100%)') {
throw new Error(`Home page did not reach full completion: ${JSON.stringify(result.homeCompletion)}`)
}
if (!result.homeCompletion.containsTaylor || !result.homeCompletion.containsOrthogonalComplement || !result.homeCompletion.containsHelloWorldTail) {
throw new Error(`Home page final content is missing expected sections: ${JSON.stringify(result.homeCompletion)}`)
}
if (result.homeCompletion.katexCount < 10) {
throw new Error(`Home page did not keep KaTeX refreshed through the full stream: ${JSON.stringify(result.homeCompletion)}`)
}
if (
result.homeHoverDuringStreaming.interactionCount < 3
|| result.homeHoverDuringStreaming.maxOverLatencyMs > 1200
|| result.homeHoverDuringStreaming.maxOutLatencyMs > 1200
|| result.homeHoverDuringStreaming.maxPlaceholderCount > 0
|| result.homeHoverDuringStreaming.finalProbe.mouseoverCount < result.homeHoverDuringStreaming.interactionCount
|| result.homeHoverDuringStreaming.finalProbe.mouseoutCount < result.homeHoverDuringStreaming.interactionCount
) {
throw new Error(`Home hover probe was sluggish while streaming: ${JSON.stringify(result.homeHoverDuringStreaming)}`)
}
if (
result.homeThinking.textLength < 200
|| !result.homeThinking.containsComprehensiveAnswer
|| result.homeThinking.paragraphCount < 2
) {
throw new Error(`Home thinking stream did not keep nested content expanded: ${JSON.stringify(result.homeThinking)}`)
}
if (!result.homeLinkTooltip.visible || !result.homeLinkTooltip.text.includes('https://github.com/Simon-He95/markstream-vue')) {
throw new Error(`Home link tooltip did not match the shared singleton behavior: ${JSON.stringify(result.homeLinkTooltip)}`)
}
if (result.homeStreamingCodeHighlight.monacoCount < 1 || result.homeStreamingCodeHighlight.done) {
throw new Error(`Home code blocks did not enter highlighted mode before stream completion: ${JSON.stringify(result.homeStreamingCodeHighlight)}`)
}
if (baseline.katexCount < 1 || baseline.mermaidSvgCount < 1 || baseline.infographicSvgCount < 1 || baseline.d2SvgCount < 1) {
throw new Error(`Baseline enhancements did not all render: ${JSON.stringify(baseline)}`)
}
if (thinking.thinkingCount < 1 || thinking.mermaidSvgCount < 1 || thinking.monacoCount < 1 || thinking.listCount < 2) {
throw new Error(`Thinking sample did not render nested heavy nodes as expected: ${JSON.stringify(thinking)}`)
}
if (diff.wrapperCount < 2 || diff.monacoCount < 2) {
throw new Error(`Diff sample did not mount Monaco editors as expected: ${JSON.stringify(diff)}`)
}
if (stress.tableCount < 1 || stress.detailsCount < 1 || stress.summaryCount < 1) {
throw new Error(`Stress sample missed expected structural nodes: ${JSON.stringify(stress)}`)
}
if (!(streaming.earlyProgress > 0 && streaming.earlyProgress < 100) || streaming.finalProgress !== 100) {
throw new Error(`Streaming progress did not behave as expected: ${JSON.stringify(streaming)}`)
}
}
catch (error) {
const recentLogs = server.getLogs().trim()
if (recentLogs) {
console.error('--- playground-angular recent logs ---')
console.error(recentLogs)
console.error('--- end playground-angular recent logs ---')
}
throw error
}
finally {
killProcessTree(server.child)
}
}
main()
.then(() => {
process.exit(0)
})
.catch((error) => {
console.error(error)
process.exit(1)
})
@@ -0,0 +1,281 @@
import { spawn } from 'node:child_process'
import net from 'node:net'
import process from 'node:process'
import { chromium } from 'playwright-core'
const host = '127.0.0.1'
const port = 4230
const playground = '/Users/Simon/Github/markstream-vue/playground'
function isOpen(portNumber) {
return new Promise((resolve) => {
const socket = net.createConnection({ host, port: portNumber })
socket.on('connect', () => {
socket.end()
resolve(true)
})
socket.on('error', () => {
socket.destroy()
resolve(false)
})
})
}
async function waitForPort(portNumber, timeout = 30000) {
const start = Date.now()
while (Date.now() - start < timeout) {
if (await isOpen(portNumber))
return
await new Promise(resolve => setTimeout(resolve, 150))
}
throw new Error(`timeout waiting for ${portNumber}`)
}
async function captureStaticState(page) {
await page.waitForSelector('.stream-monaco-diff-root', { timeout: 120000 })
await page.waitForSelector('.stream-monaco-diff-unchanged-bridge', { timeout: 120000 })
return page.evaluate(() => {
function pickStyle(node) {
if (!(node instanceof HTMLElement))
return null
const style = window.getComputedStyle(node)
return {
background: style.background,
backgroundColor: style.backgroundColor,
borderColor: style.borderColor,
boxShadow: style.boxShadow,
color: style.color,
}
}
const bridge = document.querySelector('.stream-monaco-diff-unchanged-bridge')
const rail = bridge?.querySelector('.stream-monaco-unchanged-rail')
const summary = bridge?.querySelector('.stream-monaco-unchanged-summary')
const count = bridge?.querySelector('.stream-monaco-unchanged-count, .stream-monaco-unchanged-metadata-label')
const reveal = bridge?.querySelector('.stream-monaco-unchanged-reveal')
const diffOverview = document.querySelector('.monaco-diff-editor .diffOverview')
const decorationsOverviewRuler = document.querySelector('.decorationsOverviewRuler')
const firstLineNumber = document.querySelector('.monaco-diff-editor .editor.modified .line-numbers')
const firstViewLine = document.querySelector('.monaco-diff-editor .editor.modified .view-line')
const visibleRevealDirections = Array.from(
bridge?.querySelectorAll('.stream-monaco-unchanged-reveal') ?? [],
)
.filter(node => node instanceof HTMLElement && !node.hidden)
.map(node => node.dataset.direction ?? null)
return {
bridge: pickStyle(bridge),
rail: pickStyle(rail),
railClasses: rail instanceof HTMLElement ? Array.from(rail.classList) : [],
summary: pickStyle(summary),
summaryPadding: summary instanceof HTMLElement
? {
left: window.getComputedStyle(summary).paddingLeft,
right: window.getComputedStyle(summary).paddingRight,
}
: null,
count: count ? window.getComputedStyle(count).color : null,
reveal: pickStyle(reveal),
visibleRevealDirections,
overview: pickStyle(diffOverview),
decorationsOverviewRuler: pickStyle(decorationsOverviewRuler),
overviewMetrics: diffOverview instanceof HTMLElement
? {
width: Math.round(diffOverview.getBoundingClientRect().width),
display: window.getComputedStyle(diffOverview).display,
}
: null,
decorationsOverviewRulerMetrics: decorationsOverviewRuler instanceof HTMLElement
? {
width: Math.round(decorationsOverviewRuler.getBoundingClientRect().width),
display: window.getComputedStyle(decorationsOverviewRuler).display,
}
: null,
modifiedColumn: (
firstLineNumber instanceof HTMLElement
&& firstViewLine instanceof HTMLElement
&& rail instanceof HTMLElement
)
? {
lineNumberLeft: Math.round(firstLineNumber.getBoundingClientRect().left),
lineNumberWidth: Math.round(firstLineNumber.getBoundingClientRect().width),
codeLeft: Math.round(firstViewLine.getBoundingClientRect().left),
railLeft: Math.round(rail.getBoundingClientRect().left),
railWidth: Math.round(rail.getBoundingClientRect().width),
}
: null,
text: summary?.textContent?.trim() ?? null,
rootVars: bridge instanceof HTMLElement
? {
unchangedBg: bridge.style.getPropertyValue('--stream-monaco-unchanged-bg'),
unchangedFg: bridge.style.getPropertyValue('--stream-monaco-unchanged-fg'),
}
: null,
}
})
}
async function captureExpandedScrollState(page) {
await page.click('.stream-monaco-diff-unchanged-bridge .stream-monaco-unchanged-summary')
await page.waitForTimeout(300)
await page.evaluate(() => {
const scrollable = document.querySelector(
'.monaco-diff-editor .editor.modified .monaco-scrollable-element',
)
if (scrollable instanceof HTMLElement)
scrollable.scrollTop = 220
})
await page.waitForTimeout(300)
return page.evaluate(() => {
const bridges = Array.from(
document.querySelectorAll('.stream-monaco-diff-unchanged-bridge'),
)
.filter(node => node instanceof HTMLElement)
.map((node) => {
const style = window.getComputedStyle(node)
const rect = node.getBoundingClientRect()
return {
hiddenAttr: node.hasAttribute('hidden'),
ariaHidden: node.getAttribute('aria-hidden'),
display: style.display,
visibility: style.visibility,
opacity: style.opacity,
height: Math.round(rect.height),
width: Math.round(rect.width),
text: node.textContent?.trim() ?? '',
}
})
const visibleBridges = bridges.filter(bridge =>
!bridge.hiddenAttr
&& bridge.ariaHidden !== 'true'
&& bridge.display !== 'none'
&& bridge.visibility !== 'hidden'
&& bridge.opacity !== '0'
&& bridge.height > 0
&& bridge.width > 0,
)
const modifiedScrollable = document.querySelector(
'.monaco-diff-editor .editor.modified .monaco-scrollable-element',
)
return {
bridgeCount: bridges.length,
visibleBridgeCount: visibleBridges.length,
visibleBridges,
modifiedScrollTop:
modifiedScrollable instanceof HTMLElement ? modifiedScrollable.scrollTop : null,
}
})
}
async function captureRevealStepState(page) {
const parseCount = (text) => {
const match = text.match(/\d+/)
return match ? Number.parseInt(match[0], 10) : Number.NaN
}
const before = await page.evaluate(() => {
const bridge = document.querySelector('.stream-monaco-diff-unchanged-bridge:not([hidden])')
const summary = bridge?.querySelector('.stream-monaco-unchanged-summary')
const button = bridge?.querySelector('.stream-monaco-unchanged-reveal:not([hidden])')
return {
label: summary?.textContent?.trim() ?? null,
direction: button instanceof HTMLElement ? button.dataset.direction ?? null : null,
}
})
await page.click('.stream-monaco-diff-unchanged-bridge .stream-monaco-unchanged-reveal:not([hidden])')
await page.waitForTimeout(300)
const after = await page.evaluate(() => {
const bridge = document.querySelector('.stream-monaco-diff-unchanged-bridge:not([hidden])')
const summary = bridge?.querySelector('.stream-monaco-unchanged-summary')
return {
label: summary?.textContent?.trim() ?? null,
}
})
const beforeCount = parseCount(before.label ?? '')
const afterCount = parseCount(after.label ?? '')
return {
before,
after,
beforeCount,
afterCount,
delta:
Number.isFinite(beforeCount) && Number.isFinite(afterCount)
? beforeCount - afterCount
: null,
}
}
async function main() {
const vite = spawn(
'pnpm',
['-C', playground, 'dev', '--host', host, '--port', String(port), '--strictPort'],
{ stdio: ['ignore', 'pipe', 'pipe'], env: process.env },
)
let logs = ''
vite.stdout.on('data', (chunk) => {
logs += String(chunk)
})
vite.stderr.on('data', (chunk) => {
logs += String(chunk)
})
try {
await waitForPort(port)
const browser = await chromium.launch({ channel: 'chrome', headless: true })
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } })
await page.addInitScript(() => {
localStorage.setItem('vmr-test-render-mode', 'monaco')
localStorage.setItem('vmr-test-code-stream', 'true')
localStorage.setItem('vmr-test-viewport-priority', 'false')
localStorage.setItem('vmr-test-batch-rendering', 'false')
localStorage.setItem('vmr-test-typewriter', 'false')
localStorage.setItem('vmr-test-show-settings', 'true')
localStorage.setItem('vmr-test-stream-speed', '4')
localStorage.setItem('vmr-test-stream-interval', '24')
})
console.log('goto')
page.setDefaultNavigationTimeout(120000)
await page.goto(`http://${host}:${port}/diff-line-info-regression`, { waitUntil: 'commit', timeout: 120000 })
await page.waitForSelector('h1', { timeout: 120000 })
console.log('page-loaded')
const staticState = await captureStaticState(page)
console.log('static-captured')
const revealStepState = await captureRevealStepState(page)
console.log('reveal-step-captured')
const expandedScrollState = await captureExpandedScrollState(page)
console.log('expanded-scroll-captured')
console.log(JSON.stringify({ staticState, revealStepState, expandedScrollState }, null, 2))
await browser.close()
}
catch (error) {
console.error(String(error))
console.error(logs)
process.exitCode = 1
}
finally {
try {
vite.kill('SIGTERM')
}
catch {}
}
}
await main()
@@ -0,0 +1,363 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright-core'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const playgroundDir = path.join(repoRoot, 'playground-vue2-cli')
const host = '127.0.0.1'
function isPortOpen(port, listenHost = host) {
return new Promise((resolve) => {
const socket = net.createConnection({ host: listenHost, port })
socket.on('connect', () => {
socket.end()
resolve(true)
})
socket.on('error', () => {
socket.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 3338, end = 3368) {
for (let port = start; port <= end; port++) {
if (!await isPortOpen(port))
return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, timeout = 30000) {
const start = Date.now()
while (Date.now() - start < timeout) {
if (await isPortOpen(port))
return
await new Promise(resolve => setTimeout(resolve, 150))
}
throw new Error(`Timed out waiting for ${host}:${port}`)
}
function killProcessTree(child) {
if (!child || child.killed)
return
try {
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (!child.killed)
child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
function resolveChromeLaunchOptions() {
const candidates = [
process.env.PLAYWRIGHT_CHROME_PATH,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
].filter(Boolean)
for (const candidate of candidates) {
if (existsSync(candidate)) {
return {
executablePath: candidate,
headless: true,
}
}
}
return {
channel: 'chrome',
headless: true,
}
}
function startDevServer(port) {
const logBuffer = []
const child = spawn(
'pnpm',
['exec', 'vue-cli-service', 'serve', '--port', String(port), '--host', host],
{
cwd: playgroundDir,
env: {
...process.env,
BROWSER: 'none',
CI: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
},
)
const appendLogs = (chunk) => {
const text = String(chunk)
logBuffer.push(text)
if (logBuffer.length > 120)
logBuffer.splice(0, logBuffer.length - 120)
}
child.stdout.on('data', appendLogs)
child.stderr.on('data', appendLogs)
return {
child,
getLogs() {
return logBuffer.join('')
},
}
}
function normalizeColor(color) {
return String(color || '').replace(/\s+/g, '').toLowerCase()
}
function getColorChannels(color) {
const parts = String(color || '').match(/\d+(?:\.\d+)?/g)
if (!parts || parts.length < 3)
return null
return parts.slice(0, 3).map(Number)
}
function getRelativeLuminance(color) {
const channels = getColorChannels(color)
if (!channels)
return null
const [r, g, b] = channels.map((channel) => {
const value = channel / 255
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
})
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
function getContrastRatio(foreground, background) {
const fg = getRelativeLuminance(foreground)
const bg = getRelativeLuminance(background)
if (fg == null || bg == null)
return null
const lighter = Math.max(fg, bg)
const darker = Math.min(fg, bg)
return (lighter + 0.05) / (darker + 0.05)
}
function isDarkColor(color) {
const parts = getColorChannels(color)
if (!parts)
return false
const [r, g, b] = parts
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
return luminance < 140
}
async function collectRegression(page) {
await page.locator('#delay').fill('4')
await page.locator('#chunk').fill('16')
await page.getByRole('button', { name: 'Reset' }).click()
const seenHighlightBlocks = new Set()
const regressedBlocks = new Set()
const uniqueProgress = new Set()
let maxVisibleFallbacks = 0
const start = Date.now()
while (Date.now() - start < 20000) {
const snapshot = await page.evaluate(() => {
const blocks = Array.from(document.querySelectorAll('.code-block-container')).map((block, index) => {
const render = block.querySelector('.code-block-render')
const fallback = block.querySelector('.code-fallback-plain')
const renderText = render?.textContent?.trim() || ''
const fallbackText = fallback?.textContent?.trim() || ''
const style = fallback ? window.getComputedStyle(fallback) : null
return {
index,
hasRenderContent: Boolean(renderText.length || render?.children.length),
fallbackVisible: Boolean(
fallback
&& fallbackText.length > 0
&& style
&& style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number(style.opacity || '1') > 0.05,
),
}
})
const metaText = document.querySelector('.meta')?.textContent || ''
const match = metaText.match(/\((\d+)%\)/)
return {
blocks,
progress: match ? Number(match[1]) : null,
}
})
if (snapshot.progress != null)
uniqueProgress.add(snapshot.progress)
let visibleFallbacks = 0
for (const block of snapshot.blocks) {
if (block.hasRenderContent)
seenHighlightBlocks.add(block.index)
if (block.fallbackVisible)
visibleFallbacks += 1
if (seenHighlightBlocks.has(block.index) && block.fallbackVisible)
regressedBlocks.add(block.index)
}
maxVisibleFallbacks = Math.max(maxVisibleFallbacks, visibleFallbacks)
if (snapshot.progress === 100)
break
await page.waitForTimeout(120)
}
return {
seenHighlightBlocks: Array.from(seenHighlightBlocks),
regressedBlocks: Array.from(regressedBlocks),
uniqueProgress: Array.from(uniqueProgress).sort((a, b) => a - b),
maxVisibleFallbacks,
}
}
async function collectStyleSnapshot(page) {
return page.evaluate(() => {
return Array.from(document.querySelectorAll('.code-block-container')).map((block, index) => {
const render = block.querySelector('.code-block-render')
const renderHasContent = Boolean(render?.textContent?.trim()?.length || render?.children.length)
const titleEl = block.querySelector('.code-block-header .font-mono')
const shikiEl = block.querySelector('.code-block-render .shiki, .code-block-render pre')
const shikiCodeEl = shikiEl?.querySelector('code') || shikiEl
const blockStyle = window.getComputedStyle(block)
const shikiStyle = shikiEl ? window.getComputedStyle(shikiEl) : null
const codeStyle = shikiCodeEl ? window.getComputedStyle(shikiCodeEl) : null
return {
index,
title: titleEl?.textContent?.trim() || '',
hasRenderContent: renderHasContent,
containerBackground: blockStyle.backgroundColor,
containerForeground: blockStyle.color,
shikiBackground: shikiStyle?.backgroundColor || '',
shikiForeground: codeStyle?.color || shikiStyle?.color || '',
}
})
})
}
async function main() {
const port = await findFreePort()
const server = startDevServer(port)
try {
await waitForPort(port, 60000)
const browser = await chromium.launch(resolveChromeLaunchOptions())
const page = await browser.newPage({ viewport: { width: 1440, height: 1600 } })
const consoleErrors = []
const pageErrors = []
page.on('console', (msg) => {
if (msg.type() === 'error')
consoleErrors.push(msg.text())
})
page.on('pageerror', error => pageErrors.push(String(error)))
const url = `http://${host}:${port}/`
await page.goto(url, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('#delay', { timeout: 30000 })
await page.waitForSelector('.meta', { timeout: 30000 })
const regression = await collectRegression(page)
await page.waitForTimeout(600)
const styleSnapshot = await collectStyleSnapshot(page)
const screenshot = '/tmp/vue2-cli-codeblock-highlight-stability.png'
await page.screenshot({ path: screenshot, fullPage: true })
await browser.close()
const highlightedBlocks = styleSnapshot.filter(block => block.hasRenderContent)
const brightBlocks = highlightedBlocks
.filter(block => !isDarkColor(block.shikiBackground || block.containerBackground))
.map(block => block.index)
const desyncedBackgroundBlocks = highlightedBlocks
.filter((block) => {
const shikiBg = normalizeColor(block.shikiBackground)
const containerBg = normalizeColor(block.containerBackground)
return shikiBg && containerBg && shikiBg !== containerBg
})
.map(block => block.index)
const lowContrastBlocks = highlightedBlocks
.filter((block) => {
const contrast = getContrastRatio(block.containerForeground || block.shikiForeground, block.containerBackground || block.shikiBackground)
return contrast != null && contrast < 3
})
.map(block => block.index)
const unnormalizedTitleBlocks = highlightedBlocks
.filter(block => /:/.test(block.title) || /shellscript/i.test(block.title))
.map(block => ({ index: block.index, title: block.title }))
const result = {
url,
...regression,
highlightedBlocks: highlightedBlocks.slice(0, 8),
brightBlocks,
desyncedBackgroundBlocks,
lowContrastBlocks,
unnormalizedTitleBlocks,
consoleErrorCount: consoleErrors.length,
pageErrorCount: pageErrors.length,
screenshot,
}
console.log(JSON.stringify(result, null, 2))
if (result.regressedBlocks.length > 0) {
throw new Error(`Detected code block highlight regression on blocks: ${result.regressedBlocks.join(', ')}`)
}
if (result.seenHighlightBlocks.length === 0) {
throw new Error('No highlighted code block was observed during the run')
}
if (result.consoleErrorCount > 0 || result.pageErrorCount > 0) {
throw new Error(`Detected browser errors (console=${result.consoleErrorCount}, page=${result.pageErrorCount})`)
}
if (result.brightBlocks.length > 0) {
throw new Error(`Detected non-dark highlighted code blocks: ${result.brightBlocks.join(', ')}`)
}
if (result.desyncedBackgroundBlocks.length > 0) {
throw new Error(`Detected container/shiki background mismatch on blocks: ${result.desyncedBackgroundBlocks.join(', ')}`)
}
if (result.lowContrastBlocks.length > 0) {
throw new Error(`Detected unreadable code block foreground contrast on blocks: ${result.lowContrastBlocks.join(', ')}`)
}
if (result.unnormalizedTitleBlocks.length > 0) {
throw new Error(`Detected unnormalized code block titles: ${result.unnormalizedTitleBlocks.map(block => `${block.index}:${block.title}`).join(', ')}`)
}
}
catch (error) {
const recentLogs = server.getLogs().trim()
if (recentLogs) {
console.error('--- vue-cli recent logs ---')
console.error(recentLogs)
console.error('--- end vue-cli recent logs ---')
}
throw error
}
finally {
killProcessTree(server.child)
}
}
main().catch((error) => {
console.error(error)
process.exit(1)
})
@@ -0,0 +1,445 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright-core'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const playgroundDir = path.join(repoRoot, 'playground')
function isPortOpen(port, host = '127.0.0.1') {
return new Promise((resolve) => {
const socket = net.createConnection({ host, port })
socket.on('connect', () => {
socket.end()
resolve(true)
})
socket.on('error', () => {
socket.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 4173, end = 4199) {
for (let port = start; port <= end; port++) {
const open = await isPortOpen(port)
if (!open)
return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, ms = 20000) {
const start = Date.now()
while (true) {
const open = await isPortOpen(port)
if (open)
return
if (Date.now() - start > ms)
throw new Error(`Timed out waiting for port ${port}`)
await new Promise(resolve => setTimeout(resolve, 150))
}
}
function killProcessTree(child) {
if (!child || child.killed)
return
try {
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (!child.killed)
child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
function countLines(text) {
return text.split(/\r?\n/).length
}
function buildScenario() {
const originalLines = [
'type Task = { id: string, status: \'todo\' | \'done\' }',
'',
'export async function synchronizeTasks(tasks: Task[]) {',
' const report: string[] = []',
String.raw` report.push(\`input=\${tasks.length}\`)`,
'',
]
for (let i = 1; i <= 160; i++) {
if (i % 20 === 0)
originalLines.push(` // stable checkpoint ${i}`)
const n = String(i).padStart(3, '0')
originalLines.push(` report.push('stable-${n}')`)
}
originalLines.push('', ' return report', '}')
const modifiedLines = [...originalLines]
const optimized = modifiedLines.findIndex(line => line.includes('stable-018'))
if (optimized >= 0) {
modifiedLines[optimized] = ` report.push('stable-018-optimized')`
modifiedLines[optimized + 1] = ` report.push('stable-019-optimized')`
modifiedLines[optimized + 2] = ` report.push('stable-020-optimized')`
}
const hotfix = modifiedLines.findIndex(line => line.includes('stable-086'))
if (hotfix >= 0) {
modifiedLines[hotfix - 1] = ` report.push('stable-085-hotfix')`
modifiedLines[hotfix] = ` report.push('stable-086-hotfix')`
modifiedLines[hotfix + 1] = ` report.push('stable-087-hotfix')`
}
const cache = modifiedLines.findIndex(line => line.includes('stable-132'))
if (cache >= 0) {
modifiedLines[cache - 1] = ` report.push('stable-131-cache-hit')`
modifiedLines[cache] = ` report.push('stable-132-cache-hit')`
modifiedLines[cache + 1] = ` report.push('stable-133-cache-miss')`
}
const returnLine = modifiedLines.findIndex(line => line.trim() === 'return report')
if (returnLine >= 0)
modifiedLines[returnLine] = ' return report.filter(Boolean)'
return {
original: originalLines.join('\n'),
modified: modifiedLines.join('\n'),
}
}
function buildDiffMarkdown(pair) {
const original = pair.original.split(/\r?\n/)
const modified = pair.modified.split(/\r?\n/)
const max = Math.max(original.length, modified.length)
const diffLines = []
for (let i = 0; i < max; i++) {
const left = original[i]
const right = modified[i]
if (left === right) {
diffLines.push(left ?? '')
continue
}
if (left != null)
diffLines.push(`-${left}`)
if (right != null)
diffLines.push(`+${right}`)
}
return [
'# Diff folding fixture',
'',
'This page is used by the Playwright smoke test.',
'',
'```diff ts:synchronizeTasks.ts',
...diffLines,
'```',
'',
].join('\n')
}
function estimateChangedLines(pair) {
const original = pair.original.split(/\r?\n/)
const modified = pair.modified.split(/\r?\n/)
const max = Math.max(original.length, modified.length)
let changed = 0
for (let i = 0; i < max; i++) {
if ((original[i] ?? '') !== (modified[i] ?? ''))
changed++
}
return changed
}
function resolveChromeLaunchOptions() {
const candidates = [
process.env.PLAYWRIGHT_CHROME_PATH,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
].filter(Boolean)
for (const candidate of candidates) {
if (existsSync(candidate)) {
return {
executablePath: candidate,
headless: true,
}
}
}
return {
channel: 'chrome',
headless: true,
}
}
async function waitForHiddenRegions(page, timeout = 20000) {
await page.waitForFunction(() => {
const hiddenTexts = Array.from(document.querySelectorAll('.diff-hidden-lines'))
.map(el => (el.textContent ?? '').trim())
.filter(Boolean)
return hiddenTexts.some(text => /hidden lines|unchanged|unmodified/i.test(text))
}, { timeout })
}
async function collectResult(page, name, extra = {}) {
return page.evaluate(({ name, extra }) => {
const isVisibleButton = (element) => {
if (!(element instanceof HTMLElement))
return false
const rect = element.getBoundingClientRect()
const style = window.getComputedStyle(element)
return (
rect.width >= 16
&& rect.height >= 16
&& style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number.parseFloat(style.opacity || '1') > 0.01
)
}
const roundPx = (value) => {
return Math.round(value * 100) / 100
}
const parsePx = (value) => {
const n = Number.parseFloat(String(value ?? ''))
return Number.isFinite(n) ? n : null
}
const diffEditors = Array.from(document.querySelectorAll('.monaco-diff-editor'))
const hiddenTexts = Array.from(document.querySelectorAll('.diff-hidden-lines'))
.map(el => (el.textContent ?? '').trim())
.filter(Boolean)
const originalVisibleLines = document.querySelectorAll('.editor.original .view-line').length
const modifiedVisibleLines = document.querySelectorAll('.editor.modified .view-line').length
const bodyText = document.body.textContent ?? ''
const diffEditorMetrics = diffEditors.map((editorEl, index) => {
const editorRect = editorEl.getBoundingClientRect()
const host = editorEl.closest('.code-editor-container')
const hostStyle = host instanceof HTMLElement ? window.getComputedStyle(host) : null
const editorHiddenTexts = Array.from(editorEl.querySelectorAll('.diff-hidden-lines'))
.map(el => (el.textContent ?? '').trim())
.filter(Boolean)
const foldButtonCandidates = Array.from(
editorEl.querySelectorAll('.markstream-inline-fold-proxy, .diff-hidden-lines a'),
)
const foldButton
= foldButtonCandidates.find(candidate => isVisibleButton(candidate))
?? foldButtonCandidates[0]
?? null
const foldButtonRect = foldButton?.getBoundingClientRect?.() ?? null
const hostHeightPx = host instanceof HTMLElement ? roundPx(host.getBoundingClientRect().height) : null
const hostMaxHeightPx = parsePx(hostStyle?.maxHeight)
const hostOverflow = hostStyle?.overflow ?? null
return {
index,
isSideBySide: editorEl.classList.contains('side-by-side'),
foldButtonKind: foldButton?.classList.contains('markstream-inline-fold-proxy') ? 'proxy' : (foldButton ? 'native' : null),
hiddenRegionTexts: editorHiddenTexts,
hostHeightPx,
hostInlineHeight: host instanceof HTMLElement ? host.style.height : '',
hostMaxHeightPx,
hostInlineMaxHeight: host instanceof HTMLElement ? host.style.maxHeight : '',
hostOverflow,
hostOverflowY: hostStyle?.overflowY ?? null,
editorHeightPx: roundPx(editorEl.getBoundingClientRect().height),
foldButtonRect: foldButtonRect
? {
x: roundPx(foldButtonRect.x),
y: roundPx(foldButtonRect.y),
width: roundPx(foldButtonRect.width),
height: roundPx(foldButtonRect.height),
}
: null,
foldButtonVisible: !!(foldButtonRect
&& foldButtonRect.width >= 16
&& foldButtonRect.height >= 16
&& foldButtonRect.left >= editorRect.left - 1
&& foldButtonRect.right <= editorRect.right + 1
&& foldButtonRect.top >= editorRect.top - 1
&& foldButtonRect.bottom <= editorRect.bottom + 1),
hostShrunkAfterFolding: editorHiddenTexts.length > 0
? !!(hostHeightPx != null && hostMaxHeightPx != null && hostHeightPx + 1 < hostMaxHeightPx && hostOverflow !== 'auto')
: null,
}
})
const foldedMetrics = diffEditorMetrics.filter(metric => metric.hiddenRegionTexts.length > 0)
const inlineMetrics = foldedMetrics.filter(metric => metric.isSideBySide === false)
const foldedHostShrinks = foldedMetrics.length > 0 && foldedMetrics.every(metric => metric.hostShrunkAfterFolding === true)
const inlineFoldButtonVisible = inlineMetrics.length > 0 && inlineMetrics.every(metric => metric.foldButtonVisible === true)
return {
name,
...extra,
diffEditors: diffEditors.length,
hiddenRegionCount: hiddenTexts.length,
hiddenRegionTexts: hiddenTexts,
hasHiddenRegionText: hiddenTexts.some(text => /hidden lines|unchanged|unmodified/i.test(text)),
bodyContainsHiddenLinesText: /hidden lines|unchanged|unmodified/i.test(bodyText),
originalVisibleLines,
modifiedVisibleLines,
foldedHostShrinks,
inlineFoldButtonVisible,
diffEditorMetrics,
}
}, { name, extra })
}
async function runControlledScenario(context, baseUrl) {
const pair = buildScenario()
const markdown = buildDiffMarkdown(pair)
const page = await context.newPage()
const url = `${baseUrl}/test#data=raw:${encodeURIComponent(markdown)}`
await page.goto(url, { waitUntil: 'networkidle' })
await page.waitForSelector('.monaco-diff-editor', { timeout: 20000 })
await waitForHiddenRegions(page)
await page.waitForTimeout(500)
const result = await collectResult(page, 'test-route', {
url,
originalLineCount: countLines(pair.original),
modifiedLineCount: countLines(pair.modified),
changedLineEstimate: estimateChangedLines(pair),
})
await page.close()
return result
}
async function runHomeScenario(context, baseUrl) {
const page = await context.newPage()
const url = `${baseUrl}/`
const diffEditor = page.locator('.monaco-diff-editor').last()
await page.goto(url, { waitUntil: 'networkidle' })
await page.waitForSelector('.monaco-diff-editor', { timeout: 30000 })
await diffEditor.scrollIntoViewIfNeeded()
await page.waitForTimeout(500)
await waitForHiddenRegions(page, 30000)
await page.waitForTimeout(800)
const result = await collectResult(page, 'index-route', { url })
await page.close()
return result
}
async function runHomeInlineScenario(context, baseUrl) {
const page = await context.newPage()
const url = `${baseUrl}/`
const diffEditor = page.locator('.monaco-diff-editor').last()
await page.setViewportSize({ width: 430, height: 900 })
await page.goto(url, { waitUntil: 'networkidle' })
await page.waitForSelector('.monaco-diff-editor', { timeout: 30000 })
await diffEditor.scrollIntoViewIfNeeded()
await waitForHiddenRegions(page, 30000)
await page.waitForTimeout(800)
const initial = await collectResult(page, 'index-route-inline', { url })
const foldButton = page.locator('.markstream-inline-fold-proxy:visible, .diff-hidden-lines a:visible').first()
await foldButton.click()
await page.waitForFunction(() => document.querySelectorAll('.diff-hidden-lines').length === 0, { timeout: 10000 })
const afterExpandHiddenRegionCount = await page.locator('.diff-hidden-lines').count()
await page.close()
return {
...initial,
afterExpandHiddenRegionCount,
afterExpandFoldCleared: afterExpandHiddenRegionCount === 0,
}
}
async function run() {
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
if (!Number.isFinite(port))
throw new Error(`Invalid PORT: ${process.env.PORT}`)
const vite = spawn(
'pnpm',
['-C', playgroundDir, 'dev', '--host', '127.0.0.1', '--port', String(port), '--strictPort'],
{
stdio: ['ignore', 'pipe', 'pipe'],
env: process.env,
},
)
const logs = []
vite.stdout.on('data', chunk => logs.push(String(chunk)))
vite.stderr.on('data', chunk => logs.push(String(chunk)))
const cleanup = () => killProcessTree(vite)
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
process.on('exit', cleanup)
try {
await waitForPort(port)
const browser = await chromium.launch(resolveChromeLaunchOptions())
const context = await browser.newContext()
await context.addInitScript(() => {
localStorage.setItem('vmr-settings-stream-delay', '4')
localStorage.setItem('vmr-settings-stream-chunk-size', '16')
localStorage.setItem('vmr-test-render-mode', 'monaco')
localStorage.setItem('vmr-test-code-stream', 'false')
localStorage.setItem('vmr-test-viewport-priority', 'false')
localStorage.setItem('vmr-test-batch-rendering', 'false')
localStorage.setItem('vmr-test-typewriter', 'false')
localStorage.setItem('vmr-test-show-settings', 'false')
})
const baseUrl = `http://127.0.0.1:${port}`
const controlled = await runControlledScenario(context, baseUrl)
const home = await runHomeScenario(context, baseUrl)
const homeInline = await runHomeInlineScenario(context, baseUrl)
await browser.close()
const ok = controlled.hasHiddenRegionText && controlled.hiddenRegionCount > 0
&& home.hasHiddenRegionText && home.hiddenRegionCount > 0
&& home.foldedHostShrinks
&& homeInline.hasHiddenRegionText && homeInline.hiddenRegionCount > 0
&& homeInline.inlineFoldButtonVisible
&& homeInline.afterExpandFoldCleared
&& home.diffEditors === 1
const result = {
ok,
controlled,
home,
homeInline,
}
console.log(JSON.stringify(result, null, 2))
if (!ok) {
console.error('Recent Vite logs:\n', logs.slice(-40).join(''))
process.exit(1)
}
}
finally {
cleanup()
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
@@ -0,0 +1,643 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright-core'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const playgroundDir = path.join(repoRoot, 'playground')
const host = '127.0.0.1'
const diffSample = [
'```diff json:package.json',
'{',
' "name": "markstream-vue",',
'- "version": "0.0.49",',
'+ "version": "0.0.54-beta.1",',
' "packageManager": "pnpm@10.16.1",',
'}',
'```',
].join('\n')
const sampleFrameIntervalMs = 25
const sampleStreamCharDelayMs = 80
const estimatedStreamingDurationMs
= diffSample.length * sampleStreamCharDelayMs
const samplingDurationMs = Math.max(
12000,
estimatedStreamingDurationMs + 4000,
)
const samplingFrameCount = Math.ceil(
samplingDurationMs / sampleFrameIntervalMs,
)
function isPortOpen(port) {
return new Promise((resolve) => {
const socket = net.createConnection({ host, port })
socket.on('connect', () => {
socket.end()
resolve(true)
})
socket.on('error', () => {
socket.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 4370, end = 4410) {
for (let port = start; port <= end; port++) {
if (!await isPortOpen(port))
return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, timeoutMs = 60000) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (await isPortOpen(port))
return
await new Promise(resolve => setTimeout(resolve, 150))
}
throw new Error(`Timed out waiting for ${host}:${port}`)
}
function killProcessTree(child) {
if (!child || child.killed)
return
try {
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (!child.killed)
child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
function resolveChromeLaunchOptions() {
const candidates = [
process.env.PLAYWRIGHT_CHROME_PATH,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
].filter(Boolean)
for (const candidate of candidates) {
if (existsSync(candidate)) {
return {
executablePath: candidate,
headless: true,
}
}
}
return {
channel: 'chrome',
headless: true,
}
}
function startDevServer(port) {
const logs = []
const child = spawn(
'pnpm',
['-C', playgroundDir, 'exec', 'vite', '--host', host, '--port', String(port), '--strictPort'],
{
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
CI: '1',
},
},
)
child.stdout.on('data', chunk => logs.push(String(chunk)))
child.stderr.on('data', chunk => logs.push(String(chunk)))
return {
child,
getLogs() {
return logs.join('')
},
}
}
function range(values) {
if (!values.length)
return null
return Math.max(...values) - Math.min(...values)
}
async function main() {
const port = await findFreePort()
const server = startDevServer(port)
try {
await waitForPort(port)
const browser = await chromium.launch(resolveChromeLaunchOptions())
const context = await browser.newContext({
viewport: { width: 1600, height: 1200 },
storageState: {
cookies: [],
origins: [
{
origin: `http://${host}:${port}`,
localStorage: [
{ name: 'vmr-test-render-mode', value: 'monaco' },
{ name: 'vmr-test-code-stream', value: 'true' },
{ name: 'vmr-test-stream-chunk-size-min', value: '1' },
{ name: 'vmr-test-stream-chunk-size-max', value: '1' },
{ name: 'vmr-test-stream-delay-min', value: '80' },
{ name: 'vmr-test-stream-delay-max', value: '80' },
{ name: 'vmr-test-stream-burstiness', value: '0' },
{ name: 'vmr-test-stream-slice-mode', value: 'pure-random' },
{ name: 'vmr-test-stream-transport-mode', value: 'scheduler' },
],
},
],
},
})
const page = await context.newPage()
await page.goto(`http://${host}:${port}/test`, { waitUntil: 'load' })
await page.waitForSelector('.editor-textarea')
await page.evaluate((sample) => {
const textarea = document.querySelector('.editor-textarea')
textarea.value = sample
textarea.dispatchEvent(new Event('input', { bubbles: true }))
}, diffSample)
await page.getByRole('button', { name: '开始流式渲染' }).click()
await page.waitForSelector('.code-block-container')
await page.evaluate(() => {
document.querySelector('.code-block-container')?.scrollIntoView({
block: 'center',
inline: 'nearest',
})
})
const frames = []
for (let index = 0; index < samplingFrameCount; index++) {
await page.waitForTimeout(sampleFrameIntervalMs)
const frame = await page.evaluate(() => {
const preview = document.querySelector('.preview-surface')
const viewportCenter = window.innerHeight / 2
const diffRoots = Array.from(
document.querySelectorAll('.preview-surface .stream-monaco-diff-root'),
)
const diffRoot = diffRoots
.map((root) => {
const rect = root.getBoundingClientRect()
const visibleHeight = Math.max(
0,
Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0),
)
return {
root,
rect,
visibleHeight,
distance: Math.abs((rect.top + rect.bottom) / 2 - viewportCenter),
}
})
.filter(entry => entry.visibleHeight > 0)
.sort((a, b) => {
if (b.visibleHeight !== a.visibleHeight)
return b.visibleHeight - a.visibleHeight
return a.distance - b.distance
})[0]
?.root ?? null
const block = diffRoot?.closest('.code-block-container') ?? null
const diffRootDataset = diffRoot
? {
streamingInlineControlledRaw: diffRoot.dataset.streamingInlineControlledRaw ?? null,
streamingInlineNextSpecCount: diffRoot.dataset.streamingInlineNextSpecCount ?? null,
streamingInlineVisibleSpecCount: diffRoot.dataset.streamingInlineVisibleSpecCount ?? null,
streamingInlineRenderedSpecCount: diffRoot.dataset.streamingInlineRenderedSpecCount ?? null,
streamingInlineNodeCount: diffRoot.dataset.streamingInlineNodeCount ?? null,
streamingInlineNodesConnected: diffRoot.dataset.streamingInlineNodesConnected ?? null,
streamingInlineReuse: diffRoot.dataset.streamingInlineReuse ?? null,
streamingInlineDefer: diffRoot.dataset.streamingInlineDefer ?? null,
streamingInlineRenderedMode: diffRoot.dataset.streamingInlineRenderedMode ?? null,
}
: null
const modifiedScroll = diffRoot?.querySelector(
'.editor.modified .monaco-scrollable-element.editor-scrollable',
) ?? null
const diffStatsText = block?.querySelector('.code-diff-stats')?.textContent?.trim() ?? null
const versionLine = Array.from(
diffRoot?.querySelectorAll('.editor.modified .view-line') ?? [],
).find((element) => {
if (!(element.textContent || '').includes('0.0.54-beta.1'))
return false
const style = getComputedStyle(element)
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number.parseFloat(style.opacity || '1') > 0.01
})
const versionLineDetail = versionLine
? (() => {
const rect = versionLine.getBoundingClientRect()
const style = getComputedStyle(versionLine)
const parent = versionLine.parentElement
const parentRect = parent?.getBoundingClientRect() ?? null
const parentStyle = parent ? getComputedStyle(parent) : null
return {
text: (versionLine.textContent || '').trim(),
top: Math.round(rect.top),
height: Math.round(rect.height),
display: style.display,
visibility: style.visibility,
opacity: style.opacity,
lineHeight: style.lineHeight,
innerHTML: versionLine.innerHTML.slice(0, 300),
parentClassName: typeof parent?.className === 'string' ? parent.className : '',
parentTop: parentRect ? Math.round(parentRect.top) : null,
parentHeight: parentRect ? Math.round(parentRect.height) : null,
parentDisplay: parentStyle?.display ?? null,
parentVisibility: parentStyle?.visibility ?? null,
parentOpacity: parentStyle?.opacity ?? null,
}
})()
: null
const visibleDiffTexts = Array.from(
diffRoot?.querySelectorAll(
'.editor.modified .view-line, .editor.modified .stream-monaco-fallback-inline-delete-zone, .editor.modified .inline-deleted-text, .editor.modified .view-lines.line-delete',
) ?? [],
)
.map((element) => {
const rect = element.getBoundingClientRect()
const text = (element.textContent || '').replace(/\u00A0/g, ' ').trim()
const style = getComputedStyle(element)
return {
text,
top: Math.round(rect.top),
height: Math.round(rect.height),
display: style.display,
visibility: style.visibility,
opacity: style.opacity,
}
})
.filter(entry =>
entry.height > 0
&& entry.display !== 'none'
&& entry.visibility !== 'hidden'
&& Number.parseFloat(entry.opacity || '1') > 0.01
&& (entry.text.includes('version')
|| entry.text.includes('0.0.49')
|| entry.text.includes('0.0.54')),
)
.sort((left, right) => {
if (left.top !== right.top)
return left.top - right.top
return left.text.localeCompare(right.text)
})
const uniqueVisibleDiffTexts = []
const visibleDiffKeys = new Set()
for (const entry of visibleDiffTexts) {
const key = `${entry.top}:${entry.text}`
if (visibleDiffKeys.has(key))
continue
visibleDiffKeys.add(key)
uniqueVisibleDiffTexts.push(entry)
}
const visibleOldNodes = Array.from(
diffRoot?.querySelectorAll(
'.editor.modified .stream-monaco-fallback-inline-delete-zone, .editor.modified .stream-monaco-fallback-inline-delete-line, .editor.modified .inline-deleted-text, .editor.modified .view-line.line-delete, .editor.modified .view-lines.line-delete',
) ?? [],
)
.map((element) => {
const rect = element.getBoundingClientRect()
const style = getComputedStyle(element)
return {
text: (element.textContent || '').trim(),
className: typeof element.className === 'string' ? element.className : '',
parentClassName: typeof element.parentElement?.className === 'string'
? element.parentElement.className
: '',
inViewZones: !!element.closest('.view-zones'),
inOverlayRoot: !!element.closest('.stream-monaco-fallback-inline-delete-overlay-root'),
width: rect.width,
height: rect.height,
display: style.display,
visibility: style.visibility,
opacity: style.opacity,
}
})
.filter(entry =>
entry.width > 0
&& entry.height > 0
&& entry.display !== 'none'
&& entry.visibility !== 'hidden'
&& Number.parseFloat(entry.opacity || '1') > 0.01
&& entry.text.includes('0.0.49'),
)
const allFallbackZones = Array.from(
diffRoot?.querySelectorAll(
'.editor.modified .stream-monaco-fallback-inline-delete-zone, .editor.modified .stream-monaco-fallback-inline-delete-line',
) ?? [],
)
.map((element) => {
const rect = element.getBoundingClientRect()
const style = getComputedStyle(element)
return {
text: (element.textContent || '').trim(),
className: typeof element.className === 'string' ? element.className : '',
parentClassName: typeof element.parentElement?.className === 'string'
? element.parentElement.className
: '',
inViewZones: !!element.closest('.view-zones'),
inOverlayRoot: !!element.closest('.stream-monaco-fallback-inline-delete-overlay-root'),
width: Math.round(rect.width),
height: Math.round(rect.height),
top: Math.round(rect.top),
display: style.display,
visibility: style.visibility,
opacity: style.opacity,
}
})
const extraFallbackZones = allFallbackZones.filter((entry) => {
return entry.text.length > 0
&& !entry.text.includes('"version"')
&& !entry.text.includes('0.0.49')
})
const nearbyModifiedRows = Array.from(
diffRoot?.querySelectorAll(
'.editor.modified .view-line, .editor.modified .view-zones > div, .editor.modified .margin-view-zones > div',
) ?? [],
)
.map((element) => {
const rect = element.getBoundingClientRect()
const style = getComputedStyle(element)
return {
text: (element.textContent || '').trim(),
className: typeof element.className === 'string' ? element.className : '',
width: Math.round(rect.width),
height: Math.round(rect.height),
top: Math.round(rect.top),
display: style.display,
visibility: style.visibility,
opacity: style.opacity,
}
})
.filter(entry => entry.height > 0 && entry.top >= 740 && entry.top <= 880)
const visibleViewportRows = Array.from(
diffRoot?.querySelectorAll(
'.editor.modified .view-line, .editor.modified .view-zones > div, .editor.modified .margin-view-zones > div, .editor.modified .margin-view-overlays > div',
) ?? [],
)
.map((element) => {
const rect = element.getBoundingClientRect()
const style = getComputedStyle(element)
return {
text: (element.textContent || '').trim().slice(0, 160),
className: typeof element.className === 'string' ? element.className : '',
width: Math.round(rect.width),
height: Math.round(rect.height),
top: Math.round(rect.top),
display: style.display,
visibility: style.visibility,
opacity: style.opacity,
}
})
.filter(entry => entry.height > 0 && entry.top >= 620 && entry.top <= 1180)
const sampleVisibleLineParents = Array.from(
diffRoot?.querySelectorAll('.editor.modified .view-line') ?? [],
)
.filter((element) => {
const rect = element.getBoundingClientRect()
const style = getComputedStyle(element)
return rect.width > 0
&& rect.height > 0
&& style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number.parseFloat(style.opacity || '1') > 0.01
})
.slice(0, 6)
.map((element) => {
const chain = []
let current = element
for (let index = 0; index < 6 && current; index++) {
chain.push({
tag: current.tagName,
className: typeof current.className === 'string' ? current.className : '',
})
current = current.parentElement
}
return {
text: (element.textContent || '').trim().slice(0, 80),
chain,
}
})
const visibleViewLineGroups = Array.from(
diffRoot?.querySelectorAll('.editor.modified .view-lines') ?? [],
)
.map((element) => {
const style = getComputedStyle(element)
const visibleLines = Array.from(
element.querySelectorAll < HTMLElement > ('.view-line'),
).filter((line) => {
const lineRect = line.getBoundingClientRect()
const lineStyle = getComputedStyle(line)
return lineRect.width > 0
&& lineRect.height > 0
&& lineStyle.display !== 'none'
&& lineStyle.visibility !== 'hidden'
&& Number.parseFloat(lineStyle.opacity || '1') > 0.01
}).length
const tops = Array.from(
element.querySelectorAll < HTMLElement > ('.view-line'),
)
.map((line) => {
const lineRect = line.getBoundingClientRect()
const lineStyle = getComputedStyle(line)
if (
lineRect.width <= 0
|| lineRect.height <= 0
|| lineStyle.display === 'none'
|| lineStyle.visibility === 'hidden'
|| Number.parseFloat(lineStyle.opacity || '1') <= 0.01
) {
return null
}
return Math.round(lineRect.top)
})
.filter(top => Number.isFinite(top))
return {
display: style.display,
visibility: style.visibility,
opacity: style.opacity,
visibleLineCount: visibleLines,
minTop: tops.length ? Math.min(...tops) : null,
maxTop: tops.length ? Math.max(...tops) : null,
}
})
.filter(entry =>
entry.display !== 'none'
&& entry.visibility !== 'hidden'
&& Number.parseFloat(entry.opacity || '1') > 0.01
&& entry.visibleLineCount > 0,
)
return {
hasRoot: !!diffRoot,
targetRootTop: diffRoot ? Math.round(diffRoot.getBoundingClientRect().top) : null,
streaming: !!diffRoot && diffRoot.classList.contains('stream-monaco-diff-streaming-active'),
diffStatsText,
diffRootDataset,
previewScrollTop: preview ? Math.round(preview.scrollTop) : null,
blockTop: block ? Math.round(block.getBoundingClientRect().top) : null,
modifiedScrollTop: modifiedScroll ? Math.round(modifiedScroll.scrollTop) : null,
versionTop: versionLine ? Math.round(versionLine.getBoundingClientRect().top) : null,
versionLineDetail,
visibleDiffTexts: uniqueVisibleDiffTexts,
visibleOldNodes,
allFallbackZones,
extraFallbackZones,
nearbyModifiedRows,
visibleViewportRows,
visibleViewLineGroups,
sampleVisibleLineParents,
}
})
frames.push(frame)
}
const activeFrames = frames.filter(frame => frame.hasRoot || frame.diffStatsText?.includes('-1'))
const firstStablePairIndex = activeFrames.findIndex((frame) => {
if (frame.visibleDiffTexts.length !== 2)
return false
const texts = frame.visibleDiffTexts.map(item => item.text)
return texts.some(text => text.includes('"version"') && text.includes('0.0.49'))
&& texts.some(text => text.includes('"version"') && text.includes('0.0.54-beta.1'))
})
const stableFrames = firstStablePairIndex === -1
? []
: activeFrames.slice(firstStablePairIndex)
const oldOnlyFramesBeforePair = (firstStablePairIndex === -1
? activeFrames
: activeFrames.slice(0, firstStablePairIndex))
.filter((frame) => {
return frame.visibleDiffTexts.some(item => item.text.includes('0.0.49'))
&& !frame.visibleDiffTexts.some(item => item.text.includes('0.0.54-beta.1'))
})
const newOnlyFramesBeforePair = (firstStablePairIndex === -1
? activeFrames
: activeFrames.slice(0, firstStablePairIndex))
.filter((frame) => {
return frame.visibleDiffTexts.some(item => item.text.includes('0.0.54-beta.1'))
&& !frame.visibleDiffTexts.some(item => item.text.includes('0.0.49'))
})
const versionFrames = stableFrames.filter(frame => Number.isFinite(frame.versionTop))
const missingVersionFrames = stableFrames.filter(frame => !Number.isFinite(frame.versionTop))
const signatureValues = versionFrames.map(frame =>
JSON.stringify(frame.visibleDiffTexts.map(item => item.text)),
)
const zeroDiffStatsFrames = activeFrames.filter((frame) => {
if (!frame.streaming)
return false
const stats = (frame.diffStatsText || '').replace(/\s+/g, '')
return stats === '-0+0' || stats === '-0+0…'
})
const blankViewportFrames = activeFrames.filter(frame =>
frame.visibleViewportRows.filter(row => !row.text).length >= 4,
)
const result = {
activeFrameCount: activeFrames.length,
stableFrameCount: stableFrames.length,
versionFrameCount: versionFrames.length,
oldOnlyFramesBeforePair: oldOnlyFramesBeforePair.length,
newOnlyFramesBeforePair: newOnlyFramesBeforePair.length,
previewScrollTopRange: range(versionFrames.map(frame => frame.previewScrollTop).filter(Number.isFinite)),
blockTopRange: range(versionFrames.map(frame => frame.blockTop).filter(Number.isFinite)),
modifiedScrollTopRange: range(versionFrames.map(frame => frame.modifiedScrollTop).filter(Number.isFinite)),
versionTopRange: range(versionFrames.map(frame => frame.versionTop).filter(Number.isFinite)),
diffSignatureCount: new Set(signatureValues).size,
duplicateOldVisibleFrames: versionFrames.filter(frame =>
frame.visibleOldNodes.filter(node => node.className.includes('stream-monaco-fallback-inline-delete-zone')).length > 1,
).length,
missingVersionFrames: missingVersionFrames.length,
zeroDiffStatsFrames: zeroDiffStatsFrames.length,
blankViewportFrames: blankViewportFrames.length,
extraFallbackZoneFrames: versionFrames.filter(frame => frame.extraFallbackZones.length > 0).length,
multipleVisibleViewLineGroupFrames: versionFrames.filter(frame => frame.visibleViewLineGroups.length > 1).length,
badSignatureFrames: versionFrames.filter((frame) => {
if (frame.visibleDiffTexts.length !== 2)
return true
const texts = frame.visibleDiffTexts.map(item => item.text)
return !texts.some(text => text.includes('"version"') && text.includes('0.0.49'))
|| !texts.some(text => text.includes('"version"') && text.includes('0.0.54-beta.1'))
}).length,
shiftedFrames: versionFrames
.filter(frame =>
frame.versionTop !== versionFrames[0]?.versionTop,
)
.slice(0, 12),
firstVersionFrame: versionFrames[0] ?? null,
firstActiveFrame: activeFrames[0] ?? null,
firstBadOldFrame: versionFrames.find(frame =>
frame.visibleOldNodes.filter(node => node.className.includes('stream-monaco-fallback-inline-delete-zone')).length > 1,
) ?? null,
firstOldOnlyFrame: oldOnlyFramesBeforePair[0] ?? null,
firstNewOnlyFrame: newOnlyFramesBeforePair[0] ?? null,
firstMissingVersionFrame: missingVersionFrames[0] ?? null,
firstZeroDiffStatsFrame: zeroDiffStatsFrames[0] ?? null,
firstBlankViewportFrame: blankViewportFrames[0] ?? null,
firstBadSignatureFrame: versionFrames.find((frame) => {
if (frame.visibleDiffTexts.length !== 2)
return true
const texts = frame.visibleDiffTexts.map(item => item.text)
return !texts.some(text => text.includes('"version"') && text.includes('0.0.49'))
|| !texts.some(text => text.includes('"version"') && text.includes('0.0.54-beta.1'))
}) ?? null,
}
const ok = result.activeFrameCount >= 12
&& result.stableFrameCount > 0
&& result.versionFrameCount > 0
&& result.oldOnlyFramesBeforePair === 0
&& result.newOnlyFramesBeforePair === 0
&& result.previewScrollTopRange === 0
&& result.blockTopRange === 0
&& result.modifiedScrollTopRange === 0
&& result.missingVersionFrames === 0
&& result.zeroDiffStatsFrames === 0
&& result.blankViewportFrames === 0
&& result.versionTopRange === 0
&& result.diffSignatureCount === 1
&& result.duplicateOldVisibleFrames === 0
&& result.extraFallbackZoneFrames === 0
&& result.multipleVisibleViewLineGroupFrames === 0
&& result.badSignatureFrames === 0
console.log(JSON.stringify({ ok, ...result }, null, 2))
await context.close()
await browser.close()
killProcessTree(server.child)
process.exit(ok ? 0 : 1)
}
catch (error) {
killProcessTree(server.child)
console.error(server.getLogs())
console.error(error)
process.exit(1)
}
}
main()
@@ -0,0 +1,278 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright-core'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const playgroundDir = path.join(repoRoot, 'playground')
const host = '127.0.0.1'
function isPortOpen(port) {
return new Promise((resolve) => {
const socket = net.createConnection({ host, port })
socket.on('connect', () => {
socket.end()
resolve(true)
})
socket.on('error', () => {
socket.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 4190, end = 4235) {
for (let port = start; port <= end; port++) {
if (!await isPortOpen(port))
return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, timeout = 30000) {
const start = Date.now()
while (Date.now() - start < timeout) {
if (await isPortOpen(port))
return
await new Promise(resolve => setTimeout(resolve, 150))
}
throw new Error(`Timed out waiting for ${host}:${port}`)
}
function killProcessTree(child) {
if (!child || child.killed)
return
try {
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (!child.killed)
child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
function resolveChromeLaunchOptions() {
const candidates = [
process.env.PLAYWRIGHT_CHROME_PATH,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
].filter(Boolean)
for (const candidate of candidates) {
if (existsSync(candidate)) {
return {
executablePath: candidate,
headless: true,
}
}
}
return {
channel: 'chrome',
headless: true,
}
}
function colorLuminance(color) {
const channels = String(color || '').match(/\d+(?:\.\d+)?/g)
if (!channels || channels.length < 3)
return null
const [r, g, b] = channels.slice(0, 3).map(Number)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
async function snapshot(page) {
return page.evaluate(() => {
function visibleTextElements(selector) {
return Array.from(document.querySelectorAll(selector))
.filter((node) => {
if (!(node instanceof HTMLElement))
return false
const text = node.textContent?.trim() ?? ''
if (!text)
return false
const style = window.getComputedStyle(node)
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number.parseFloat(style.opacity || '1') > 0.05
})
.map(node => node.textContent.trim())
}
const container = document.querySelector('.code-block-container')
const header = document.querySelector('.code-block-header')
const preview = document.querySelector('[data-testid="diff-theme-preview"]')
const diffRoot = document.querySelector('.stream-monaco-diff-root')
const editorBackground = diffRoot?.querySelector('.monaco-editor .monaco-editor-background')
const containerStyle = container instanceof HTMLElement ? window.getComputedStyle(container) : null
const headerStyle = header instanceof HTMLElement ? window.getComputedStyle(header) : null
const previewStyle = preview instanceof HTMLElement ? window.getComputedStyle(preview) : null
const editorStyle = editorBackground instanceof HTMLElement ? window.getComputedStyle(editorBackground) : null
const visibleNativeCompactTexts = visibleTextElements('.diff-hidden-lines-compact .text')
const visibleNativeCenterTexts = Array.from(document.querySelectorAll('.diff-hidden-lines .center'))
.filter((node) => {
if (!(node instanceof HTMLElement))
return false
if (node.classList.contains('stream-monaco-clickable'))
return false
const style = window.getComputedStyle(node)
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number.parseFloat(style.opacity || '1') > 0.05
})
.map(node => (node instanceof HTMLElement ? node.textContent.trim() : ''))
.filter(Boolean)
return {
pageMode: document.documentElement.dataset.themeMode || '',
containerBg: containerStyle?.backgroundColor || '',
headerBg: headerStyle?.backgroundColor || '',
previewBg: previewStyle?.backgroundColor || '',
editorBg: editorStyle?.backgroundColor || '',
rootClasses: diffRoot instanceof HTMLElement ? Array.from(diffRoot.classList) : [],
isDarkClass: container instanceof HTMLElement ? container.classList.contains('is-dark') : false,
metadataLabels: Array.from(document.querySelectorAll('.stream-monaco-unchanged-metadata-label')).map(node => node.textContent?.trim() || ''),
visibleNativeCompactTexts,
visibleNativeCenterTexts,
visibleHiddenLinesInBody: /hidden lines/i.test(document.body.textContent || ''),
}
})
}
async function captureTransition(page, mode) {
await page.click(`[data-theme-toggle="${mode}"]`)
const frames = []
for (let i = 0; i < 72; i++) {
await page.waitForTimeout(16)
frames.push(await snapshot(page))
}
return {
frames,
final: frames[frames.length - 1] ?? await snapshot(page),
}
}
async function main() {
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
const vite = spawn(
'pnpm',
['-C', playgroundDir, 'dev', '--host', host, '--port', String(port), '--strictPort'],
{
stdio: ['ignore', 'pipe', 'pipe'],
env: process.env,
},
)
const logs = []
vite.stdout.on('data', chunk => logs.push(String(chunk)))
vite.stderr.on('data', chunk => logs.push(String(chunk)))
const cleanup = () => killProcessTree(vite)
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
process.on('exit', cleanup)
try {
await waitForPort(port)
const browser = await chromium.launch(resolveChromeLaunchOptions())
const page = await browser.newPage({
viewport: { width: 1440, height: 1200 },
})
const pageErrors = []
const consoleErrors = []
page.on('pageerror', (error) => {
pageErrors.push(String(error))
})
page.on('console', (message) => {
if (message.type() === 'error')
consoleErrors.push(message.text())
})
await page.goto(`http://${host}:${port}/diff-theme-regression`, {
waitUntil: 'networkidle',
})
await page.waitForSelector('.monaco-diff-editor', { timeout: 30000 })
await page.waitForSelector('.stream-monaco-unchanged-metadata-label', { timeout: 30000 })
await page.waitForTimeout(400)
const initial = await snapshot(page)
const toLight = await captureTransition(page, 'light')
const toDark = await captureTransition(page, 'dark')
await browser.close()
const initialLum = colorLuminance(initial.containerBg)
const lightLum = colorLuminance(toLight.final.containerBg)
const darkLum = colorLuminance(toDark.final.containerBg)
const frames = [...toLight.frames, ...toDark.frames]
const anyHiddenFlash = frames.some(frame =>
frame.visibleHiddenLinesInBody
|| frame.visibleNativeCompactTexts.length > 0
|| frame.visibleNativeCenterTexts.some(text => /hidden lines/i.test(text)),
)
const ok = (initialLum != null && initialLum < 90)
&& (lightLum != null && lightLum > 170)
&& (darkLum != null && darkLum < 90)
&& initial.containerBg !== toLight.final.containerBg
&& toLight.final.containerBg !== toDark.final.containerBg
&& toLight.final.rootClasses.includes('stream-monaco-diff-appearance-light')
&& toDark.final.rootClasses.includes('stream-monaco-diff-appearance-dark')
&& !anyHiddenFlash
&& pageErrors.length === 0
&& consoleErrors.length === 0
const result = {
ok,
colors: {
initial: initial.containerBg,
light: toLight.final.containerBg,
dark: toDark.final.containerBg,
},
luminance: {
initial: initialLum,
light: lightLum,
dark: darkLum,
},
initial,
finalLight: toLight.final,
finalDark: toDark.final,
anyHiddenFlash,
pageErrors,
consoleErrors,
}
console.log(JSON.stringify(result, null, 2))
if (!ok) {
console.error('Recent Vite logs:\n', logs.slice(-60).join(''))
process.exit(1)
}
}
finally {
cleanup()
}
}
main().catch((error) => {
console.error(error)
process.exit(1)
})
@@ -0,0 +1,255 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright-core'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const playgroundDir = path.join(repoRoot, 'playground')
const host = '127.0.0.1'
function isPortOpen(port) {
return new Promise((resolve) => {
const socket = net.createConnection({ host, port })
socket.on('connect', () => {
socket.end()
resolve(true)
})
socket.on('error', () => {
socket.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 4200, end = 4240) {
for (let port = start; port <= end; port++) {
if (!await isPortOpen(port))
return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, timeoutMs = 60000) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (await isPortOpen(port))
return
await new Promise(resolve => setTimeout(resolve, 150))
}
throw new Error(`Timed out waiting for ${host}:${port}`)
}
function killProcessTree(child) {
if (!child || child.killed)
return
try {
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (!child.killed)
child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
function resolveChromeLaunchOptions() {
const candidates = [
process.env.PLAYWRIGHT_CHROME_PATH,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
].filter(Boolean)
for (const candidate of candidates) {
if (existsSync(candidate)) {
return {
executablePath: candidate,
headless: true,
}
}
}
return {
channel: 'chrome',
headless: true,
}
}
function startDevServer(port) {
const logs = []
const child = spawn(
'pnpm',
['-C', playgroundDir, 'exec', 'vite', '--host', host, '--port', String(port), '--strictPort'],
{
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
CI: '1',
},
},
)
child.stdout.on('data', chunk => logs.push(String(chunk)))
child.stderr.on('data', chunk => logs.push(String(chunk)))
return {
child,
getLogs() {
return logs.join('')
},
}
}
async function runScenario(page, sourceMode, codeRendererMode) {
return page.evaluate(async ({ sourceMode, codeRendererMode }) => {
const api = window.__heightEstimationExperiment
if (!api)
throw new Error('Missing __heightEstimationExperiment API.')
await api.setSourceMode(sourceMode)
await api.setCodeRendererMode(codeRendererMode)
await api.waitUntilReady()
const summaryInitial = api.getSummary()
const restore = await api.runRestoreTrial()
const loading = sourceMode === 'nodes' ? await api.runLoadingReadyTrial() : null
const resize = await api.runResizeTrial([375, 768, 1280])
await api.waitUntilReady()
const summaryFinal = api.getSummary()
return {
sourceMode,
codeRendererMode,
summaryInitial,
summaryFinal,
restore,
loading,
resize,
reports: api.getReports(),
}
}, { sourceMode, codeRendererMode })
}
function absoluteOrNull(value) {
return value == null ? null : Math.abs(Number(value))
}
function assertScenario(result) {
const id = `${result.sourceMode}/${result.codeRendererMode}`
const summary = result.summaryFinal
if (!summary)
throw new Error(`[${id}] Missing summary.`)
if (!(summary.actualTotalHeight > 0))
throw new Error(`[${id}] Actual total height must be > 0.`)
const restoreDrift = absoluteOrNull(result.restore?.t500?.experiment)
if (restoreDrift == null)
throw new Error(`[${id}] Missing experiment restore drift.`)
if (!Array.isArray(result.resize) || result.resize.length !== 3)
throw new Error(`[${id}] Resize trial did not return 3 samples.`)
if (!(summary.experimentError < summary.baselineError))
throw new Error(`[${id}] Experiment error should be lower than baseline.`)
if (!(restoreDrift <= 8))
throw new Error(`[${id}] Experiment restore drift should stay within 8px at t500.`)
for (const sample of result.resize) {
const drift = absoluteOrNull(sample?.experiment)
if (drift == null)
throw new Error(`[${id}] Missing resize drift for width ${sample?.width ?? 'unknown'}.`)
if (!(drift <= 12))
throw new Error(`[${id}] Resize drift should stay within 12px (width ${sample.width}).`)
}
if (result.sourceMode === 'nodes' && !result.loading)
throw new Error(`[${id}] Missing nodes loading trial result.`)
if (result.sourceMode === 'nodes') {
const loadingDelta = absoluteOrNull(result.loading?.experiment)
if (loadingDelta == null)
throw new Error(`[${id}] Missing experiment loading delta.`)
if (!(loadingDelta <= 16))
throw new Error(`[${id}] Loading transition should stay within 16px.`)
}
}
async function run() {
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
const server = startDevServer(port)
const cleanup = () => killProcessTree(server.child)
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
process.on('exit', cleanup)
try {
await waitForPort(port)
const browser = await chromium.launch(resolveChromeLaunchOptions())
const page = await browser.newPage({ viewport: { width: 1600, height: 1200 } })
await page.goto(`http://${host}:${port}/height-estimation-experiment`, { waitUntil: 'load' })
const scenarios = []
for (const sourceMode of ['nodes', 'content']) {
for (const codeRendererMode of ['markdown', 'monaco'])
scenarios.push(await runScenario(page, sourceMode, codeRendererMode))
}
for (const scenario of scenarios) {
try {
assertScenario(scenario)
}
catch (error) {
console.error('[e2e-height-estimation-experiment] scenario failure')
console.error(JSON.stringify({
sourceMode: scenario.sourceMode,
codeRendererMode: scenario.codeRendererMode,
summaryInitial: scenario.summaryInitial,
summaryFinal: scenario.summaryFinal,
restore: scenario.restore,
loading: scenario.loading,
resize: scenario.resize,
}, null, 2))
throw error
}
}
const report = { nodes: {}, content: {} }
for (const scenario of scenarios) {
report[scenario.sourceMode][scenario.codeRendererMode] = {
initial: scenario.summaryInitial,
final: scenario.summaryFinal,
restore: scenario.restore,
loading: scenario.loading,
resize: scenario.resize,
}
}
console.log(JSON.stringify(report, null, 2))
await browser.close()
}
catch (error) {
console.error('[e2e-height-estimation-experiment] failed')
console.error(error)
console.error(server.getLogs())
process.exitCode = 1
}
finally {
cleanup()
}
}
run()
@@ -0,0 +1,229 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright-core'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const playgroundDir = path.join(repoRoot, 'playground')
const host = '127.0.0.1'
const issueMarkdown = `- 代入数据:$c=0.75\\times10^3\\ \\text{J/(kg·℃)}$,$m=1.1\\ \\text{kg}$,$\\Delta t=40℃$
- 计算得:$Q_1=0.75\\times10^3\\ \\text{J/(kg·℃)}\\times1.1\\ \\text{kg}\\times40℃=3.3\\times 10^{4}\\ \\text{J}$
- 第(1)问:学生遗漏了比热容的数量级$10^3$,将$0.75\\times10^3\\ \\text{J/(kg·℃)}$错写为$0.75\\ \\text{J/(kg·℃)}$,导致计算出的$Q_1$错误。`
function isPortOpen(port) {
return new Promise((resolve) => {
const socket = net.createConnection({ host, port })
socket.on('connect', () => {
socket.end()
resolve(true)
})
socket.on('error', () => {
socket.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 4291, end = 4330) {
for (let port = start; port <= end; port++) {
if (!await isPortOpen(port))
return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, timeoutMs = 60000) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (await isPortOpen(port))
return
await new Promise(resolve => setTimeout(resolve, 150))
}
throw new Error(`Timed out waiting for ${host}:${port}`)
}
function killProcessTree(child) {
if (!child || child.killed)
return
try {
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (!child.killed)
child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
function resolveChromeLaunchOptions() {
const candidates = [
process.env.PLAYWRIGHT_CHROME_PATH,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
].filter(Boolean)
for (const candidate of candidates) {
if (existsSync(candidate)) {
return {
executablePath: candidate,
headless: true,
}
}
}
return {
channel: 'chrome',
headless: true,
}
}
function startDevServer(port) {
const logs = []
const child = spawn(
'pnpm',
['-C', playgroundDir, 'exec', 'vite', '--host', host, '--port', String(port), '--strictPort'],
{
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
CI: '1',
},
},
)
child.stdout.on('data', chunk => logs.push(String(chunk)))
child.stderr.on('data', chunk => logs.push(String(chunk)))
return {
child,
getLogs() {
return logs.join('')
},
}
}
function createIssueUrl(port) {
return `http://${host}:${port}/test#data=raw:${encodeURIComponent(issueMarkdown)}`
}
async function waitForIssueMath(page) {
await page.locator('.preview-surface').waitFor({ state: 'visible', timeout: 30000 })
await page.waitForFunction(() => {
const root = document.querySelector('.preview-surface')
if (!root)
return false
const inlineNodes = Array.from(root.querySelectorAll('[data-markstream-math="inline"]'))
if (inlineNodes.length !== 8)
return false
return inlineNodes.every((node) => {
const mode = node.getAttribute('data-markstream-mode')
return mode === 'katex'
&& Boolean(node.querySelector('.katex'))
&& !node.querySelector('.math-inline--fallback')
&& !node.querySelector('.math-inline__loading')
})
}, { timeout: 30000 })
}
async function readSummary(page) {
return page.evaluate(() => {
const root = document.querySelector('.preview-surface')
const inlineNodes = Array.from(root?.querySelectorAll('[data-markstream-math="inline"]') || [])
return {
inlineCount: inlineNodes.length,
inlineModes: inlineNodes.map(node => node.getAttribute('data-markstream-mode')),
katexCount: root?.querySelectorAll('[data-markstream-math="inline"] .katex').length || 0,
fallbackInlineCount: root?.querySelectorAll('.math-inline--fallback').length || 0,
fallbackBlockCount: root?.querySelectorAll('.math-block__fallback').length || 0,
loadingInlineCount: root?.querySelectorAll('.math-inline__loading').length || 0,
katexErrorCount: root?.querySelectorAll('.katex-error').length || 0,
previewText: root?.textContent || '',
}
})
}
async function main() {
const port = await findFreePort()
const server = startDevServer(port)
let browser
let page
try {
await waitForPort(port)
browser = await chromium.launch(resolveChromeLaunchOptions())
page = await browser.newPage({ viewport: { width: 1440, height: 1200 } })
const relevantConsoleIssues = []
const pageErrors = []
page.on('console', (msg) => {
if ((msg.type() === 'error' || msg.type() === 'warning') && /katex|cdotp|character metrics|undefined control sequence/i.test(msg.text()))
relevantConsoleIssues.push(`[${msg.type()}] ${msg.text()}`)
})
page.on('pageerror', error => pageErrors.push(String(error?.message || error)))
await page.goto(createIssueUrl(port), { waitUntil: 'load' })
await waitForIssueMath(page)
const summary = await readSummary(page)
if (summary.inlineCount !== 8)
throw new Error(`Expected 8 inline math nodes, got ${summary.inlineCount}`)
if (summary.katexCount !== 8)
throw new Error(`Expected 8 rendered KaTeX nodes, got ${summary.katexCount}`)
if (summary.inlineModes.some(mode => mode !== 'katex'))
throw new Error(`Expected all inline math nodes to be in katex mode, got ${summary.inlineModes.join(', ')}`)
if (summary.fallbackInlineCount !== 0 || summary.fallbackBlockCount !== 0)
throw new Error(`Unexpected fallback nodes: inline=${summary.fallbackInlineCount}, block=${summary.fallbackBlockCount}`)
if (summary.loadingInlineCount !== 0)
throw new Error(`Unexpected loading math nodes left in preview: ${summary.loadingInlineCount}`)
if (summary.katexErrorCount !== 0)
throw new Error(`Unexpected KaTeX error nodes in preview: ${summary.katexErrorCount}`)
if (summary.previewText.includes('$c=0.75\\times10^3\\ \\text{J/(kg·℃)}$'))
throw new Error('Preview still contains raw fallback text for the first issue formula')
if (relevantConsoleIssues.length)
throw new Error(`Unexpected KaTeX console issues:\n${relevantConsoleIssues.join('\n')}`)
if (pageErrors.length)
throw new Error(`Unexpected page errors:\n${pageErrors.join('\n')}`)
console.log('KaTeX unicode-unit e2e passed.')
console.log(JSON.stringify(summary, null, 2))
}
catch (error) {
if (page) {
try {
const screenshot = '/tmp/markstream-katex-unicode-unit-regression.png'
await page.screenshot({ path: screenshot, fullPage: true })
console.error(`Saved failure screenshot to ${screenshot}`)
}
catch {}
}
console.error(server.getLogs())
throw error
}
finally {
await browser?.close().catch(() => {})
killProcessTree(server.child)
}
}
main().catch((error) => {
console.error('[e2e-katex-unicode-unit-regression] failed')
console.error(error)
process.exitCode = 1
})
@@ -0,0 +1,291 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright-core'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const playgroundDir = path.join(repoRoot, 'playground-vue2-cli')
const host = '127.0.0.1'
function isPortOpen(port, listenHost = host) {
return new Promise((resolve) => {
const socket = net.createConnection({ host: listenHost, port })
socket.on('connect', () => {
socket.end()
resolve(true)
})
socket.on('error', () => {
socket.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 3338, end = 3368) {
for (let port = start; port <= end; port++) {
if (!await isPortOpen(port))
return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, timeout = 60000) {
const start = Date.now()
while (Date.now() - start < timeout) {
if (await isPortOpen(port))
return
await new Promise(resolve => setTimeout(resolve, 150))
}
throw new Error(`Timed out waiting for ${host}:${port}`)
}
function killProcessTree(child) {
if (!child || child.killed)
return
try {
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (!child.killed)
child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
function resolveChromeLaunchOptions() {
const candidates = [
process.env.PLAYWRIGHT_CHROME_PATH,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
].filter(Boolean)
for (const candidate of candidates) {
if (existsSync(candidate)) {
return {
executablePath: candidate,
headless: true,
}
}
}
return {
channel: 'chrome',
headless: true,
}
}
function startDevServer(port) {
const logBuffer = []
const child = spawn(
'pnpm',
['exec', 'vue-cli-service', 'serve', '--port', String(port), '--host', host],
{
cwd: playgroundDir,
env: {
...process.env,
BROWSER: 'none',
CI: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
},
)
const appendLogs = (chunk) => {
const text = String(chunk)
logBuffer.push(text)
if (logBuffer.length > 120)
logBuffer.splice(0, logBuffer.length - 120)
}
child.stdout.on('data', appendLogs)
child.stderr.on('data', appendLogs)
return {
child,
getLogs() {
return logBuffer.join('')
},
}
}
async function readProgress(page) {
return page.evaluate(() => {
const metaText = document.querySelector('.meta')?.textContent || ''
const match = metaText.match(/\((\d+)%\)/)
return match ? Number(match[1]) : null
})
}
async function readTooltipState(page) {
return page.evaluate(() => {
const tooltip = document.querySelector('.tooltip-element')
const style = tooltip ? window.getComputedStyle(tooltip) : null
const text = tooltip?.textContent?.trim() || ''
const visible = Boolean(
tooltip
&& text.length > 0
&& style
&& style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number(style.opacity || '1') > 0.05,
)
return {
visible,
text,
html: tooltip?.innerHTML || '',
}
})
}
async function hoverAndReadTooltip(page, locator, expectedText) {
await locator.scrollIntoViewIfNeeded()
await locator.hover()
await page.waitForFunction(() => {
const tooltip = document.querySelector('.tooltip-element')
if (!tooltip)
return false
const style = window.getComputedStyle(tooltip)
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number(style.opacity || '1') > 0.05
&& Boolean(tooltip.textContent?.trim())
}, { timeout: 10000 })
const startedAt = Date.now()
while (Date.now() - startedAt < 4000) {
const state = await readTooltipState(page)
if (state.visible && state.text.includes(expectedText))
return state
await page.waitForTimeout(80)
}
const state = await readTooltipState(page)
throw new Error(`Tooltip text mismatch. Expected to include "${expectedText}", got "${state.text}".`)
}
async function collectTooltipRegression(page) {
await page.locator('#delay').fill('4')
await page.locator('#chunk').fill('16')
await page.getByRole('button', { name: 'Reset' }).click()
const firstLink = page.locator('a.link-node[href="https://github.com/Simon-He95/markstream-vue"]').filter({ hasText: 'Star on GitHub' }).first()
const secondLink = page.locator('a.link-node[href="https://simonhe.me/"]').filter({ hasText: 'Link (Test 1)' }).first()
await firstLink.waitFor({ state: 'visible', timeout: 15000 })
const firstProgress = await readProgress(page)
const firstTooltip = await hoverAndReadTooltip(page, firstLink, 'https://github.com/Simon-He95/markstream-vue')
await secondLink.waitFor({ state: 'visible', timeout: 15000 })
const secondTooltip = await hoverAndReadTooltip(page, secondLink, 'https://simonhe.me/')
const progressAtMonitorStart = await readProgress(page)
const samples = []
const startedAt = Date.now()
while (Date.now() - startedAt < 20000) {
const tooltip = await readTooltipState(page)
const progress = await readProgress(page)
samples.push({ tooltip, progress })
if (progress === 100 && samples.length >= 10)
break
await page.waitForTimeout(120)
}
const hiddenSamples = samples.filter(sample => !sample.tooltip.visible).length
const wrongTextSamples = samples.filter(sample => !sample.tooltip.text.includes('https://simonhe.me/')).length
const finalProgress = samples.at(-1)?.progress ?? null
const progressAdvanced = progressAtMonitorStart != null && finalProgress != null
? finalProgress - progressAtMonitorStart
: null
return {
firstProgress,
progressAtMonitorStart,
finalProgress,
progressAdvanced,
firstTooltipText: firstTooltip.text,
secondTooltipText: secondTooltip.text,
hiddenSamples,
wrongTextSamples,
sampleCount: samples.length,
}
}
async function main() {
const port = await findFreePort()
const server = startDevServer(port)
try {
await waitForPort(port)
const browser = await chromium.launch(resolveChromeLaunchOptions())
const page = await browser.newPage({ viewport: { width: 1440, height: 1600 } })
const consoleErrors = []
const pageErrors = []
page.on('console', (msg) => {
if (msg.type() === 'error')
consoleErrors.push(msg.text())
})
page.on('pageerror', error => pageErrors.push(String(error)))
const url = `http://${host}:${port}/?probe=1`
await page.goto(url, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('#delay', { timeout: 30000 })
await page.waitForSelector('.meta', { timeout: 30000 })
const regression = await collectTooltipRegression(page)
const screenshot = '/tmp/vue2-cli-link-tooltip-streaming.png'
await page.screenshot({ path: screenshot, fullPage: true })
await browser.close()
const result = {
url,
...regression,
consoleErrorCount: consoleErrors.length,
pageErrorCount: pageErrors.length,
screenshot,
}
console.log(JSON.stringify(result, null, 2))
if (result.hiddenSamples > 0)
throw new Error(`Tooltip became hidden during streaming (${result.hiddenSamples} hidden samples).`)
if (result.wrongTextSamples > 0)
throw new Error(`Tooltip content got stuck or stale during streaming (${result.wrongTextSamples} wrong-text samples).`)
if (result.finalProgress !== 100)
throw new Error(`Streaming did not finish while tooltip was monitored (ended at ${result.finalProgress ?? 'n/a'}%).`)
if (result.consoleErrorCount > 0 || result.pageErrorCount > 0)
throw new Error(`Unexpected browser errors: console=${result.consoleErrorCount}, page=${result.pageErrorCount}`)
}
catch (error) {
const recentLogs = server.getLogs().trim()
if (recentLogs) {
console.error('--- vue-cli recent logs ---')
console.error(recentLogs)
console.error('--- end vue-cli recent logs ---')
}
throw error
}
finally {
killProcessTree(server.child)
}
}
main().catch((error) => {
console.error(error)
process.exit(1)
})
@@ -0,0 +1,431 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright-core'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const playgroundDir = path.join(repoRoot, 'playground')
const host = '127.0.0.1'
function isPortOpen(port) {
return new Promise((resolve) => {
const socket = net.createConnection({ host, port })
socket.on('connect', () => {
socket.end()
resolve(true)
})
socket.on('error', () => {
socket.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 4321, end = 4360) {
for (let port = start; port <= end; port++) {
if (!await isPortOpen(port))
return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, timeoutMs = 60000) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (await isPortOpen(port))
return
await new Promise(resolve => setTimeout(resolve, 150))
}
throw new Error(`Timed out waiting for ${host}:${port}`)
}
function killProcessTree(child) {
if (!child || child.killed)
return
try {
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (!child.killed)
child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
function resolveChromeLaunchOptions() {
const candidates = [
process.env.PLAYWRIGHT_CHROME_PATH,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
].filter(Boolean)
for (const candidate of candidates) {
if (existsSync(candidate)) {
return {
executablePath: candidate,
headless: true,
}
}
}
return {
channel: 'chrome',
headless: true,
}
}
function startDevServer(port) {
const logs = []
const child = spawn(
'pnpm',
['-C', playgroundDir, 'exec', 'vite', '--host', host, '--port', String(port), '--strictPort'],
{
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
CI: '1',
},
},
)
child.stdout.on('data', chunk => logs.push(String(chunk)))
child.stderr.on('data', chunk => logs.push(String(chunk)))
return {
child,
getLogs() {
return logs.join('')
},
}
}
async function waitForVisibleBlocksReady(page, rootSelector) {
await page.waitForFunction((selector) => {
const root = document.querySelector(selector)
if (!root)
return false
const rootRect = root.getBoundingClientRect()
const isVisible = (element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > rootRect.top && rect.top < rootRect.bottom
}
const visibleCodeBlocks = Array.from(document.querySelectorAll('.code-block-container')).filter(isVisible)
const visibleMermaids = Array.from(document.querySelectorAll('[data-markstream-mermaid="1"]')).filter(isVisible)
const visibleInfographics = Array.from(document.querySelectorAll('[data-markstream-infographic="1"]')).filter(isVisible)
return visibleCodeBlocks.every(element => !element.querySelector('.code-fallback-plain, .code-pre-fallback'))
&& visibleMermaids.every(element => element.querySelector('svg'))
&& visibleInfographics.every(element => element.querySelector('svg'))
}, rootSelector, { timeout: 30000 })
}
async function waitForAllD2Ready(page, timeout = 15000) {
await page.waitForFunction(() => {
const d2Blocks = Array.from(document.querySelectorAll('[data-markstream-d2="1"]'))
return d2Blocks.every(element => element.querySelector('.d2-svg svg'))
}, null, { timeout })
}
async function scrollThroughRoot(page, rootSelector) {
while (true) {
await waitForVisibleBlocksReady(page, rootSelector)
const state = await page.evaluate((selector) => {
const root = document.querySelector(selector)
if (!root)
return { done: true }
root.style.scrollBehavior = 'auto'
const maxScrollTop = Math.max(0, root.scrollHeight - root.clientHeight)
const step = Math.max(320, Math.round(root.clientHeight * 0.85))
const nextScrollTop = Math.min(maxScrollTop, root.scrollTop + step)
const done = nextScrollTop <= root.scrollTop + 1
if (!done)
root.scrollTop = nextScrollTop
return { done }
}, rootSelector)
if (state.done)
break
await page.waitForTimeout(180)
}
await waitForVisibleBlocksReady(page, rootSelector)
}
async function collectMetrics(page) {
const rootSelector = '.chatbot-messages'
await page.goto('/', { waitUntil: 'load' })
await page.locator('.chatbot-container').waitFor({ state: 'visible', timeout: 15000 })
await waitForVisibleBlocksReady(page, rootSelector)
await page.waitForTimeout(250)
const initial = await page.evaluate(() => {
const state = window.__mainPlaygroundPerf ?? {}
const longTasks = Array.isArray(state.longTasks) ? state.longTasks : []
const mermaids = Array.from(document.querySelectorAll('[data-markstream-mermaid="1"]'))
const infographics = Array.from(document.querySelectorAll('[data-markstream-infographic="1"]'))
const d2Blocks = Array.from(document.querySelectorAll('[data-markstream-d2="1"]'))
const root = document.querySelector('.chatbot-messages')
const rootRect = root?.getBoundingClientRect()
const isVisible = (element) => {
if (!rootRect)
return false
const rect = element.getBoundingClientRect()
return rect.bottom > rootRect.top && rect.top < rootRect.bottom
}
const visibleMermaids = mermaids.filter(isVisible)
const visibleInfographics = infographics.filter(isVisible)
const visibleD2Blocks = d2Blocks.filter(isVisible)
const visibleCodeBlocks = Array.from(document.querySelectorAll('.code-block-container')).filter(isVisible)
return {
lcpMs: Number(state.lcpMs ?? 0),
lcpElement: state.lcpElement ?? null,
cls: Number(state.cls ?? 0),
firstPaintMs: Number(state.paints?.paint ?? 0),
firstContentfulPaintMs: Number(state.paints?.['first-contentful-paint'] ?? 0),
longTaskCount: longTasks.length,
longTaskTotalMs: longTasks.reduce((sum, duration) => sum + Number(duration || 0), 0),
longTaskMaxMs: longTasks.length ? Math.max(...longTasks) : 0,
settleTimeMs: performance.now() - Number(state.startedAt ?? 0),
codeBlockCount: document.querySelectorAll('.code-block-container').length,
fallbackCount: document.querySelectorAll('.code-fallback-plain, .code-pre-fallback').length,
visibleCodeBlockCount: visibleCodeBlocks.length,
visibleFallbackCount: visibleCodeBlocks.filter(element => element.querySelector('.code-fallback-plain, .code-pre-fallback')).length,
mermaidCount: mermaids.length,
renderedMermaidCount: mermaids.filter(element => element.querySelector('svg')).length,
visibleMermaidCount: visibleMermaids.length,
visibleRenderedMermaidCount: visibleMermaids.filter(element => element.querySelector('svg')).length,
infographicCount: infographics.length,
renderedInfographicCount: infographics.filter(element => element.querySelector('svg')).length,
visibleInfographicCount: visibleInfographics.length,
visibleRenderedInfographicCount: visibleInfographics.filter(element => element.querySelector('svg')).length,
d2Count: d2Blocks.length,
renderedD2Count: d2Blocks.filter(element => element.querySelector('.d2-svg svg')).length,
visibleD2Count: visibleD2Blocks.length,
visibleRenderedD2Count: visibleD2Blocks.filter(element => element.querySelector('.d2-svg svg')).length,
topLayoutShifts: Array.isArray(state.layoutShifts)
? [...state.layoutShifts]
.sort((a, b) => Number(b?.value || 0) - Number(a?.value || 0))
.slice(0, 5)
: [],
}
})
await scrollThroughRoot(page, rootSelector)
await waitForAllD2Ready(page)
await page.waitForTimeout(250)
const fullScroll = await page.evaluate(() => {
const state = window.__mainPlaygroundPerf ?? {}
const longTasks = Array.isArray(state.longTasks) ? state.longTasks : []
const mermaids = Array.from(document.querySelectorAll('[data-markstream-mermaid="1"]'))
const infographics = Array.from(document.querySelectorAll('[data-markstream-infographic="1"]'))
const d2Blocks = Array.from(document.querySelectorAll('[data-markstream-d2="1"]'))
return {
settleTimeMs: performance.now() - Number(state.startedAt ?? 0),
codeBlockCount: document.querySelectorAll('.code-block-container').length,
fallbackCount: document.querySelectorAll('.code-fallback-plain, .code-pre-fallback').length,
mermaidCount: mermaids.length,
renderedMermaidCount: mermaids.filter(element => element.querySelector('svg')).length,
infographicCount: infographics.length,
renderedInfographicCount: infographics.filter(element => element.querySelector('svg')).length,
d2Count: d2Blocks.length,
renderedD2Count: d2Blocks.filter(element => element.querySelector('.d2-svg svg')).length,
longTaskTotalMs: longTasks.reduce((sum, duration) => sum + Number(duration || 0), 0),
}
})
const replayButton = page.locator('button[title="Pause streaming"], button[title="Resume streaming"]')
await replayButton.waitFor({ state: 'visible', timeout: 5000 })
if ((await replayButton.getAttribute('title')) === 'Pause streaming') {
await replayButton.click()
await page.waitForFunction(() => {
const button = document.querySelector('button[title="Resume streaming"]')
return Boolean(button)
}, null, { timeout: 5000 })
}
await page.evaluate(() => {
const state = window.__mainPlaygroundPerf
if (state) {
state.replayStartedAt = performance.now()
state.replayLongTaskCountBaseline = state.longTasks.length
state.replayLongTaskTotalBaseline = state.longTasks.reduce((sum, duration) => sum + Number(duration || 0), 0)
}
})
await page.locator('button[title="Resume streaming"]').click()
await waitForVisibleBlocksReady(page, rootSelector)
await page.waitForTimeout(250)
const replay = await page.evaluate(() => {
const state = window.__mainPlaygroundPerf ?? {}
const longTasks = Array.isArray(state.longTasks) ? state.longTasks : []
const totalLongTaskMs = longTasks.reduce((sum, duration) => sum + Number(duration || 0), 0)
return {
settleTimeMs: performance.now() - Number(state.replayStartedAt ?? performance.now()),
longTaskCount: longTasks.length - Number(state.replayLongTaskCountBaseline ?? 0),
longTaskTotalMs: totalLongTaskMs - Number(state.replayLongTaskTotalBaseline ?? 0),
}
})
return { initial, fullScroll, replay }
}
function assertScenario(result) {
if (!(result.initial.visibleFallbackCount === 0))
throw new Error(`Visible code block fallbacks should be gone after initial settle. Got ${result.initial.visibleFallbackCount}.`)
if (result.initial.visibleRenderedMermaidCount !== result.initial.visibleMermaidCount)
throw new Error('Visible mermaid blocks should finish in preview mode after initial settle.')
if (result.initial.visibleRenderedInfographicCount !== result.initial.visibleInfographicCount)
throw new Error('Visible infographic blocks should finish after initial settle.')
if (!(result.initial.lcpMs > 0))
throw new Error('Expected a positive LCP measurement.')
if (!(result.initial.lcpMs <= 4000))
throw new Error(`LCP should stay within 4000ms. Got ${result.initial.lcpMs}.`)
if (!(result.initial.cls <= 0.05))
throw new Error(`CLS should stay within 0.05. Got ${result.initial.cls}.`)
if (!(result.initial.longTaskMaxMs <= 500))
throw new Error(`Max long task should stay within 500ms. Got ${result.initial.longTaskMaxMs}.`)
if (!(result.initial.longTaskTotalMs <= 1400))
throw new Error(`Total long task time should stay within 1400ms. Got ${result.initial.longTaskTotalMs}.`)
if (!(result.initial.settleTimeMs <= 7000))
throw new Error(`Initial settle should stay within 7000ms. Got ${result.initial.settleTimeMs}.`)
if (result.fullScroll.fallbackCount !== 0)
throw new Error('Code block fallbacks should be gone after full scroll settle.')
if (result.fullScroll.renderedMermaidCount !== result.fullScroll.mermaidCount)
throw new Error('All mermaid blocks should finish after full scroll settle.')
if (result.fullScroll.renderedInfographicCount !== result.fullScroll.infographicCount)
throw new Error('All infographic blocks should finish after full scroll settle.')
if (result.fullScroll.d2Count > 0 && result.fullScroll.renderedD2Count !== result.fullScroll.d2Count)
throw new Error('All d2 blocks should finish after full scroll settle.')
if (!(result.replay.settleTimeMs <= 5000))
throw new Error(`Replay settle should stay within 5000ms. Got ${result.replay.settleTimeMs}.`)
}
async function run() {
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
const server = startDevServer(port)
let result = null
const cleanup = () => killProcessTree(server.child)
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
process.on('exit', cleanup)
try {
await waitForPort(port)
const browser = await chromium.launch(resolveChromeLaunchOptions())
const warmupContext = await browser.newContext({
viewport: { width: 1600, height: 1200 },
baseURL: `http://${host}:${port}`,
})
const warmupPage = await warmupContext.newPage()
await warmupPage.goto('/', { waitUntil: 'load' })
await warmupPage.locator('.chatbot-container').waitFor({ state: 'visible', timeout: 15000 })
await warmupContext.close()
const context = await browser.newContext({
viewport: { width: 1600, height: 1200 },
baseURL: `http://${host}:${port}`,
})
await context.addInitScript(() => {
const state = {
startedAt: performance.now(),
cls: 0,
lcpMs: 0,
lcpElement: null,
longTasks: [],
paints: {},
layoutShifts: [],
pauseEnabledSeen: false,
}
window.__mainPlaygroundPerf = state
const describeElement = (element) => {
if (!element)
return null
const tag = element.tagName ? element.tagName.toLowerCase() : 'unknown'
const id = element.id ? `#${element.id}` : ''
const className = typeof element.className === 'string'
? element.className.trim().split(/\s+/).filter(Boolean).join('.')
: ''
return `${tag}${id}${className ? `.${className}` : ''}`
}
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.startTime > state.lcpMs) {
state.lcpMs = entry.startTime
state.lcpElement = describeElement(entry.element)
}
}
}).observe({ type: 'largest-contentful-paint', buffered: true })
}
catch {}
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
state.cls += entry.value
state.layoutShifts.push({
value: entry.value,
sources: Array.isArray(entry.sources)
? entry.sources
.map(source => describeElement(source?.node))
.filter(Boolean)
.slice(0, 4)
: [],
})
}
}
}).observe({ type: 'layout-shift', buffered: true })
}
catch {}
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries())
state.longTasks.push(entry.duration)
}).observe({ type: 'longtask', buffered: true })
}
catch {}
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries())
state.paints[entry.name] = entry.startTime
}).observe({ type: 'paint', buffered: true })
}
catch {}
})
const page = await context.newPage()
result = await collectMetrics(page)
assertScenario(result)
console.log(JSON.stringify(result, null, 2))
await context.close()
await browser.close()
}
catch (error) {
console.error('[e2e-main-playground-performance] failed')
console.error(error)
if (result)
console.error(JSON.stringify(result, null, 2))
console.error(server.getLogs())
process.exitCode = 1
}
finally {
cleanup()
}
}
run()
@@ -0,0 +1,314 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright-core'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const playgroundDir = path.join(repoRoot, 'playground-vue2-cli')
const host = '127.0.0.1'
function isPortOpen(port, listenHost = host) {
return new Promise((resolve) => {
const socket = net.createConnection({ host: listenHost, port })
socket.on('connect', () => {
socket.end()
resolve(true)
})
socket.on('error', () => {
socket.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 3338, end = 3368) {
for (let port = start; port <= end; port++) {
if (!await isPortOpen(port))
return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, timeout = 60000) {
const start = Date.now()
while (Date.now() - start < timeout) {
if (await isPortOpen(port))
return
await new Promise(resolve => setTimeout(resolve, 150))
}
throw new Error(`Timed out waiting for ${host}:${port}`)
}
function killProcessTree(child) {
if (!child || child.killed)
return
try {
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (!child.killed)
child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
function resolveChromeLaunchOptions() {
const candidates = [
process.env.PLAYWRIGHT_CHROME_PATH,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
].filter(Boolean)
for (const candidate of candidates) {
if (existsSync(candidate)) {
return {
executablePath: candidate,
headless: true,
}
}
}
return {
channel: 'chrome',
headless: true,
}
}
function startDevServer(port) {
const logBuffer = []
const child = spawn(
'pnpm',
['exec', 'vue-cli-service', 'serve', '--port', String(port), '--host', host],
{
cwd: playgroundDir,
env: {
...process.env,
BROWSER: 'none',
CI: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
},
)
const appendLogs = (chunk) => {
const text = String(chunk)
logBuffer.push(text)
if (logBuffer.length > 120)
logBuffer.splice(0, logBuffer.length - 120)
}
child.stdout.on('data', appendLogs)
child.stderr.on('data', appendLogs)
return {
child,
getLogs() {
return logBuffer.join('')
},
}
}
async function readProgress(page) {
return page.evaluate(() => {
const metaText = document.querySelector('.meta')?.textContent || ''
const match = metaText.match(/\((\d+)%\)/)
return match ? Number(match[1]) : null
})
}
async function scrollFirstIntoView(page, selector, timeout = 20000) {
await page.waitForFunction((query) => {
const el = document.querySelector(query)
if (!el)
return false
el.scrollIntoView({ block: 'center', inline: 'nearest' })
return true
}, selector, { timeout })
}
async function waitForRenderedMath(page) {
await scrollFirstIntoView(page, '.math-block')
await page.waitForFunction(() => Boolean(document.querySelector('.math-block .katex')), { timeout: 20000 })
await scrollFirstIntoView(page, '.math-inline-wrapper')
await page.waitForFunction(() => Boolean(document.querySelector('.math-inline-wrapper .katex')), { timeout: 20000 })
}
async function collectRegression(page) {
await page.locator('#delay').fill('4')
await page.locator('#chunk').fill('16')
await page.getByRole('button', { name: 'Reset' }).click()
await waitForRenderedMath(page)
const progressAtMonitorStart = await readProgress(page)
const seenRenderedBlocks = new Set()
const seenRenderedInline = new Set()
const regressedBlocks = new Set()
const regressedInline = new Set()
let maxVisibleBlockLoaders = 0
let maxVisibleInlineLoaders = 0
const uniqueProgress = new Set(progressAtMonitorStart == null ? [] : [progressAtMonitorStart])
const start = Date.now()
while (Date.now() - start < 25000) {
const snapshot = await page.evaluate(() => {
const isVisible = (el) => {
if (!el)
return false
const style = window.getComputedStyle(el)
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number(style.opacity || '1') > 0.05
}
const blocks = Array.from(document.querySelectorAll('.math-block')).map((block, index) => ({
index,
hasKatex: Boolean(block.querySelector('.katex')),
loadingVisible: isVisible(block.querySelector('.math-loading-overlay')),
fallbackVisible: isVisible(block.querySelector('.math-block__fallback')),
}))
const inline = Array.from(document.querySelectorAll('.math-inline-wrapper')).map((item, index) => ({
index,
hasKatex: Boolean(item.querySelector('.katex')),
loadingVisible: isVisible(item.querySelector('.math-inline__loading')),
fallbackVisible: isVisible(item.querySelector('.math-inline--fallback')),
}))
const metaText = document.querySelector('.meta')?.textContent || ''
const match = metaText.match(/\((\d+)%\)/)
return {
blocks,
inline,
progress: match ? Number(match[1]) : null,
}
})
if (snapshot.progress != null)
uniqueProgress.add(snapshot.progress)
let visibleBlockLoaders = 0
for (const block of snapshot.blocks) {
if (block.hasKatex)
seenRenderedBlocks.add(block.index)
if (block.loadingVisible)
visibleBlockLoaders += 1
if (seenRenderedBlocks.has(block.index) && (block.loadingVisible || block.fallbackVisible))
regressedBlocks.add(block.index)
}
maxVisibleBlockLoaders = Math.max(maxVisibleBlockLoaders, visibleBlockLoaders)
let visibleInlineLoaders = 0
for (const item of snapshot.inline) {
if (item.hasKatex)
seenRenderedInline.add(item.index)
if (item.loadingVisible)
visibleInlineLoaders += 1
if (seenRenderedInline.has(item.index) && (item.loadingVisible || item.fallbackVisible))
regressedInline.add(item.index)
}
maxVisibleInlineLoaders = Math.max(maxVisibleInlineLoaders, visibleInlineLoaders)
if (snapshot.progress === 100)
break
await page.waitForTimeout(120)
}
const finalProgress = Math.max(...Array.from(uniqueProgress.values()), 0)
return {
progressAtMonitorStart,
finalProgress,
seenRenderedBlocks: Array.from(seenRenderedBlocks),
seenRenderedInline: Array.from(seenRenderedInline),
regressedBlocks: Array.from(regressedBlocks),
regressedInline: Array.from(regressedInline),
uniqueProgress: Array.from(uniqueProgress).sort((a, b) => a - b),
maxVisibleBlockLoaders,
maxVisibleInlineLoaders,
}
}
async function main() {
const port = await findFreePort()
const server = startDevServer(port)
try {
await waitForPort(port)
const browser = await chromium.launch(resolveChromeLaunchOptions())
const page = await browser.newPage({ viewport: { width: 1440, height: 1600 } })
const consoleErrors = []
const pageErrors = []
page.on('console', (msg) => {
if (msg.type() === 'error')
consoleErrors.push(msg.text())
})
page.on('pageerror', error => pageErrors.push(String(error)))
const url = `http://${host}:${port}/?probe=1`
await page.goto(url, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('#delay', { timeout: 30000 })
await page.waitForSelector('.meta', { timeout: 30000 })
const regression = await collectRegression(page)
const screenshot = '/tmp/vue2-cli-math-streaming-stability.png'
await page.screenshot({ path: screenshot, fullPage: true })
await browser.close()
const result = {
url,
...regression,
consoleErrorCount: consoleErrors.length,
pageErrorCount: pageErrors.length,
screenshot,
}
console.log(JSON.stringify(result, null, 2))
if (result.regressedBlocks.length > 0)
throw new Error(`Math block regressed back to loading/fallback: ${result.regressedBlocks.join(', ')}`)
if (result.regressedInline.length > 0)
throw new Error(`Math inline regressed back to loading/fallback: ${result.regressedInline.join(', ')}`)
if (result.seenRenderedBlocks.length === 0)
throw new Error('No rendered math block was observed during the run')
if (result.seenRenderedInline.length === 0)
throw new Error('No rendered math inline node was observed during the run')
if (result.finalProgress !== 100)
throw new Error(`Streaming did not finish while monitoring math nodes (ended at ${result.finalProgress}%)`)
if (result.consoleErrorCount > 0 || result.pageErrorCount > 0)
throw new Error(`Unexpected browser errors: console=${result.consoleErrorCount}, page=${result.pageErrorCount}`)
}
catch (error) {
const recentLogs = server.getLogs().trim()
if (recentLogs) {
console.error('--- vue-cli recent logs ---')
console.error(recentLogs)
console.error('--- end vue-cli recent logs ---')
}
throw error
}
finally {
killProcessTree(server.child)
}
}
main().catch((error) => {
console.error(error)
process.exit(1)
})
@@ -0,0 +1,473 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { existsSync, lstatSync, readlinkSync, rmSync, symlinkSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright-core'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const markstreamReactDir = path.join(repoRoot, 'packages/markstream-react')
const host = '127.0.0.1'
const EXPORT_NAMES = [
'NodeRenderer',
'AdmonitionNode',
'BlockquoteNode',
'CheckboxNode',
'CodeBlockNode',
'ReactCodeBlockNode',
'MarkdownCodeBlockNode',
'PreCodeNode',
'D2BlockNode',
'MermaidBlockNode',
'InfographicBlockNode',
'DefinitionListNode',
'EmojiNode',
'EmphasisNode',
'StrongNode',
'StrikethroughNode',
'HighlightNode',
'InsertNode',
'SubscriptNode',
'SuperscriptNode',
'FootnoteNode',
'FootnoteReferenceNode',
'FootnoteAnchorNode',
'HardBreakNode',
'HeadingNode',
'ParagraphNode',
'HtmlBlockNode',
'HtmlInlineNode',
'ImageNode',
'InlineCodeNode',
'LinkNode',
'ListItemNode',
'ListNode',
'MathBlockNode',
'MathInlineNode',
'ReferenceNode',
'TableNode',
'TextNode',
'ThematicBreakNode',
'Tooltip',
'HtmlPreviewFrame',
'VmrContainerNode',
'FallbackComponent',
]
function parseArgs(argv) {
const args = {}
for (let i = 2; i < argv.length; i++) {
const current = argv[i]
const next = argv[i + 1]
if (current === '--playground' && next) {
args.playground = next
i++
}
else if (current === '--version' && next) {
args.version = next
i++
}
}
if (!args.playground || !args.version)
throw new Error('Usage: node scripts/e2e-next-ssr.mjs --playground <dir> --version <next14|next15>')
return args
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
function isPortOpen(port, listenHost = host) {
return new Promise((resolve) => {
const socket = net.createConnection({ host: listenHost, port })
socket.on('connect', () => {
socket.end()
resolve(true)
})
socket.on('error', () => {
socket.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 3400, end = 3499) {
for (let port = start; port <= end; port++) {
if (!await isPortOpen(port))
return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, timeout = 120000) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeout) {
if (await isPortOpen(port))
return
await sleep(200)
}
throw new Error(`Timed out waiting for ${host}:${port}`)
}
function killProcessTree(child) {
if (!child || child.killed)
return
try {
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (!child.killed)
child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
function resolveChromeLaunchOptions() {
const candidates = [
process.env.PLAYWRIGHT_CHROME_PATH,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
].filter(Boolean)
for (const candidate of candidates) {
if (existsSync(candidate)) {
return {
executablePath: candidate,
headless: true,
}
}
}
return {
channel: 'chrome',
headless: true,
}
}
function runCommand(command, args, cwd) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env: {
...process.env,
CI: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
const append = (chunk) => {
output += String(chunk)
if (output.length > 12000)
output = output.slice(output.length - 12000)
}
child.stdout.on('data', append)
child.stderr.on('data', append)
child.on('error', reject)
child.on('close', (code) => {
if (code === 0)
resolve(output)
else
reject(new Error(`Command failed: ${command} ${args.join(' ')}\n${output}`))
})
})
}
function syncMarkstreamReactPeers(playgroundDir) {
const peerNames = ['react', 'react-dom']
const originalLinks = []
for (const peerName of peerNames) {
const packagePeerPath = path.join(markstreamReactDir, 'node_modules', peerName)
const playgroundPeerPath = path.join(playgroundDir, 'node_modules', peerName)
if (!existsSync(playgroundPeerPath))
throw new Error(`Missing ${peerName} in ${playgroundDir}`)
const stats = lstatSync(packagePeerPath)
if (!stats.isSymbolicLink())
throw new Error(`Expected ${packagePeerPath} to be a symlink`)
originalLinks.push({
packagePeerPath,
originalTarget: readlinkSync(packagePeerPath),
})
rmSync(packagePeerPath, { force: true, recursive: true })
symlinkSync(playgroundPeerPath, packagePeerPath, 'dir')
}
return () => {
for (const { packagePeerPath, originalTarget } of originalLinks) {
rmSync(packagePeerPath, { force: true, recursive: true })
symlinkSync(originalTarget, packagePeerPath, 'dir')
}
}
}
function startServer({ cwd, port, mode }) {
const logBuffer = []
const args = mode === 'dev'
? ['exec', 'next', 'dev', '--hostname', host, '--port', String(port)]
: ['exec', 'next', 'start', '--hostname', host, '--port', String(port)]
const child = spawn('pnpm', args, {
cwd,
env: {
...process.env,
CI: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
})
const appendLogs = (chunk) => {
logBuffer.push(String(chunk))
if (logBuffer.length > 200)
logBuffer.splice(0, logBuffer.length - 200)
}
child.stdout.on('data', appendLogs)
child.stderr.on('data', appendLogs)
return {
child,
getLogs() {
return logBuffer.join('')
},
}
}
async function fetchText(url) {
const response = await fetch(url)
if (!response.ok)
throw new Error(`Failed to fetch ${url}: ${response.status}`)
return await response.text()
}
function assertIncludes(html, value, label) {
if (!html.includes(value))
throw new Error(`Missing ${label}: ${value}`)
}
function assertRawHtml(html, version, router) {
assertIncludes(html, `data-ssr-version="${version}"`, 'version marker')
assertIncludes(html, `data-ssr-router="${router}"`, 'router marker')
const requiredPairs = [
['basic', 'next'],
['basic', 'server'],
['custom', 'next'],
['custom', 'server'],
['enhanced', 'next'],
['fallback', 'next'],
['fallback', 'server'],
['export-matrix', 'next'],
['server-export-matrix', 'server'],
['hydration', 'next'],
['hydration', 'server'],
]
for (const [caseName, entryName] of requiredPairs) {
assertIncludes(html, `data-ssr-case="${caseName}"`, `${caseName} case`)
assertIncludes(html, `data-ssr-entry="${entryName}"`, `${entryName} entry`)
}
assertIncludes(html, 'Next Entry Basic', 'next basic heading')
assertIncludes(html, 'Server Entry Basic', 'server basic heading')
assertIncludes(html, 'data-ssr-custom-id="next-primary"', 'next primary custom scope')
assertIncludes(html, 'data-ssr-custom-id="next-alt"', 'next alternate custom scope')
assertIncludes(html, 'data-ssr-custom-id="server-primary"', 'server primary custom scope')
assertIncludes(html, 'data-ssr-custom-id="server-alt"', 'server alternate custom scope')
assertIncludes(html, 'data-ssr-status="next-paragraph-override"', 'next custom paragraph')
assertIncludes(html, 'data-ssr-status="next-paragraph-alt"', 'next alternate custom paragraph')
assertIncludes(html, 'data-ssr-status="server-paragraph-override"', 'server custom paragraph')
assertIncludes(html, 'data-ssr-status="server-paragraph-alt"', 'server alternate custom paragraph')
assertIncludes(html, 'data-ssr-status="next-custom-node-children"', 'next custom node children')
assertIncludes(html, 'data-ssr-status="server-custom-node-children"', 'server custom node children')
assertIncludes(html, 'Next custom child body', 'next custom child body')
assertIncludes(html, 'Server custom child body', 'server custom child body')
assertIncludes(html, 'data-ssr-status="next-thinking-tag"', 'next custom tag')
assertIncludes(html, 'data-ssr-status="next-thinking-tag-alt"', 'next alternate custom tag')
assertIncludes(html, 'data-ssr-status="server-thinking-tag"', 'server custom tag')
assertIncludes(html, 'data-ssr-status="server-thinking-tag-alt"', 'server alternate custom tag')
assertIncludes(html, 'data-ssr-tone="calm"', 'custom tag attrs')
assertIncludes(html, 'data-ssr-scope="next-primary"', 'next custom tag scope')
assertIncludes(html, 'data-ssr-scope="server-primary"', 'server custom tag scope')
assertIncludes(html, 'data-ssr-fallback="code-block"', 'code fallback')
assertIncludes(html, 'data-ssr-fallback="markdown-code-block"', 'markdown code fallback')
assertIncludes(html, 'data-ssr-fallback="mermaid"', 'mermaid fallback')
assertIncludes(html, 'data-ssr-fallback="d2"', 'd2 fallback')
assertIncludes(html, 'data-ssr-fallback="infographic"', 'infographic fallback')
for (const name of EXPORT_NAMES)
assertIncludes(html, `data-ssr-export="${name}"`, `${name} export marker`)
}
async function waitForHydration(page, route) {
await page.goto(route, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('[data-ssr-case="hydration"][data-ssr-entry="next"][data-ssr-status="enhanced"]', { timeout: 90000 })
await page.waitForSelector('[data-ssr-case="hydration"][data-ssr-entry="server"][data-ssr-status="fallback-stable"]', { timeout: 90000 })
}
async function assertHydratedUi(page) {
const nextEnhanced = page.locator('[data-ssr-case="enhanced"][data-ssr-entry="next"]')
const nextCustomPrimary = page.locator('[data-ssr-case="custom"][data-ssr-entry="next"] [data-ssr-custom-id="next-primary"]')
const nextCustomAlt = page.locator('[data-ssr-case="custom"][data-ssr-entry="next"] [data-ssr-custom-id="next-alt"]')
const serverCustomPrimary = page.locator('[data-ssr-case="custom"][data-ssr-entry="server"] [data-ssr-custom-id="server-primary"]')
const serverCustomAlt = page.locator('[data-ssr-case="custom"][data-ssr-entry="server"] [data-ssr-custom-id="server-alt"]')
const exportCount = await page.locator('[data-ssr-case="export-matrix"] [data-ssr-export]').count()
if (exportCount !== EXPORT_NAMES.length)
throw new Error(`Expected ${EXPORT_NAMES.length} next export markers, received ${exportCount}`)
const serverExportCount = await page.locator('[data-ssr-case="server-export-matrix"] [data-ssr-export]').count()
if (serverExportCount !== EXPORT_NAMES.length)
throw new Error(`Expected ${EXPORT_NAMES.length} server export markers, received ${serverExportCount}`)
const codeEnhanced = await nextEnhanced.locator('[data-ssr-demo="code"] .monaco-editor, [data-ssr-demo="code"] .code-editor-fallback-surface').count()
if (codeEnhanced === 0)
throw new Error('Code block did not enhance to the client shell')
if (!await nextEnhanced.locator('[data-ssr-demo="math"] .katex').first().isVisible())
throw new Error('Math block did not enhance to KaTeX')
const mermaidShellCount = await nextEnhanced.locator('[data-ssr-demo="mermaid"] .mermaid-block').count()
const mermaidFallbackCount = await nextEnhanced.locator('[data-ssr-demo="mermaid"] [data-ssr-fallback="mermaid"]').count()
if (mermaidShellCount === 0 || mermaidFallbackCount > 0)
throw new Error('Mermaid block did not switch to the client shell')
const fallbackCount = await page.locator('[data-ssr-case="fallback"][data-ssr-entry="server"] [data-ssr-fallback]').count()
if (fallbackCount < 3)
throw new Error(`Expected stable server fallback markers, received ${fallbackCount}`)
if (!await nextCustomPrimary.locator('[data-ssr-status="next-custom-node-children"]').first().isVisible())
throw new Error('Next custom node children did not render in the primary custom scope')
if (!await nextCustomPrimary.locator('[data-ssr-status="next-thinking-tag"][data-ssr-tone="calm"][data-ssr-scope="next-primary"]').first().isVisible())
throw new Error('Next custom tag attrs did not render in the primary custom scope')
if (await nextCustomPrimary.locator('[data-ssr-status="next-paragraph-alt"]').count() > 0)
throw new Error('Next primary custom scope leaked alternate overrides')
if (await nextCustomAlt.locator('[data-ssr-status="next-paragraph-override"]').count() > 0)
throw new Error('Next alternate custom scope leaked primary overrides')
if (!await serverCustomPrimary.locator('[data-ssr-status="server-custom-node-children"]').first().isVisible())
throw new Error('Server custom node children did not render in the primary custom scope')
if (!await serverCustomPrimary.locator('[data-ssr-status="server-thinking-tag"][data-ssr-tone="calm"][data-ssr-scope="server-primary"]').first().isVisible())
throw new Error('Server custom tag attrs did not render in the primary custom scope')
if (await serverCustomPrimary.locator('[data-ssr-status="server-paragraph-alt"]').count() > 0)
throw new Error('Server primary custom scope leaked alternate overrides')
if (await serverCustomAlt.locator('[data-ssr-status="server-paragraph-override"]').count() > 0)
throw new Error('Server alternate custom scope leaked primary overrides')
}
async function runBrowserChecks(baseUrl) {
const browser = await chromium.launch(resolveChromeLaunchOptions())
const page = await browser.newPage()
const browserErrors = []
const shouldIgnoreBrowserError = text =>
text.includes('An empty string ("") was passed to the')
&& text.includes('attribute')
&& text.includes('src')
page.on('pageerror', (error) => {
browserErrors.push(`pageerror: ${error.message}`)
})
page.on('console', (message) => {
if (message.type() === 'error' && !shouldIgnoreBrowserError(message.text()))
browserErrors.push(`console: ${message.text()}`)
})
try {
const appRoute = `${baseUrl}/app-ssr-lab`
const pagesRoute = `${baseUrl}/pages-ssr-lab`
await waitForHydration(page, appRoute)
await assertHydratedUi(page)
await waitForHydration(page, pagesRoute)
await assertHydratedUi(page)
await waitForHydration(page, appRoute)
await assertHydratedUi(page)
await page.reload({ waitUntil: 'domcontentloaded' })
await page.waitForSelector('[data-ssr-case="hydration"][data-ssr-entry="next"][data-ssr-status="enhanced"]', { timeout: 90000 })
if (browserErrors.length > 0)
throw new Error(browserErrors.join('\n'))
}
finally {
await browser.close()
}
}
async function runMode({ playgroundDir, version, mode }) {
const port = await findFreePort(mode === 'dev' ? 3400 : 3500, mode === 'dev' ? 3499 : 3599)
const baseUrl = `http://${host}:${port}`
const server = startServer({ cwd: playgroundDir, port, mode })
try {
await waitForPort(port)
await sleep(mode === 'dev' ? 2500 : 1500)
const appHtml = await fetchText(`${baseUrl}/app-ssr-lab`)
const pagesHtml = await fetchText(`${baseUrl}/pages-ssr-lab`)
assertRawHtml(appHtml, version, 'app')
assertRawHtml(pagesHtml, version, 'pages')
await runBrowserChecks(baseUrl)
}
catch (error) {
throw new Error(`${String(error instanceof Error ? error.message : error)}\n\nServer logs:\n${server.getLogs()}`)
}
finally {
killProcessTree(server.child)
}
}
async function main() {
const { playground, version } = parseArgs(process.argv)
const playgroundDir = path.join(repoRoot, playground)
const restorePeers = syncMarkstreamReactPeers(playgroundDir)
try {
console.log(`[next-ssr] preparing ${version} at ${playground}`)
await runCommand('pnpm', ['run', 'build:parser'], repoRoot)
await runCommand('pnpm', ['--filter', 'markstream-react', 'build'], repoRoot)
console.log(`[next-ssr] ${version} dev`)
await runMode({ playgroundDir, version, mode: 'dev' })
console.log(`[next-ssr] ${version} build`)
await runCommand('pnpm', ['exec', 'next', 'build'], playgroundDir)
console.log(`[next-ssr] ${version} start`)
await runMode({ playgroundDir, version, mode: 'prod' })
console.log(`[next-ssr] ${version} passed`)
}
finally {
restorePeers()
}
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
@@ -0,0 +1,342 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright-core'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const playgroundDir = path.join(repoRoot, 'playground-nuxt')
const host = '127.0.0.1'
function assert(condition, message) {
if (!condition)
throw new Error(message)
}
function isPortOpen(port, listenHost = host) {
return new Promise((resolve) => {
const socket = net.createConnection({ host: listenHost, port })
socket.on('connect', () => {
socket.end()
resolve(true)
})
socket.on('error', () => {
socket.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 4300, end = 4360) {
for (let port = start; port <= end; port++) {
if (!await isPortOpen(port))
return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, timeoutMs = 90000) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (await isPortOpen(port))
return
await new Promise(resolve => setTimeout(resolve, 200))
}
throw new Error(`Timed out waiting for ${host}:${port}`)
}
async function waitForHttpReady(url, timeoutMs = 90000) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
try {
const response = await fetch(url)
if (response.ok)
return
}
catch {}
await new Promise(resolve => setTimeout(resolve, 250))
}
throw new Error(`Timed out waiting for HTTP readiness: ${url}`)
}
function killProcessTree(child) {
if (!child || child.killed)
return
try {
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (!child.killed)
child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
function resolveChromeLaunchOptions() {
const candidates = [
process.env.PLAYWRIGHT_CHROME_PATH,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
].filter(Boolean)
for (const candidate of candidates) {
if (existsSync(candidate)) {
return {
executablePath: candidate,
headless: true,
}
}
}
return {
channel: 'chrome',
headless: true,
}
}
function createProcessRunner(command, args, cwd, extraEnv = {}) {
const logBuffer = []
const child = spawn(command, args, {
cwd,
env: {
...process.env,
CI: '1',
NUXT_TELEMETRY_DISABLED: '1',
...extraEnv,
},
stdio: ['ignore', 'pipe', 'pipe'],
})
const appendLogs = (chunk) => {
const text = String(chunk)
logBuffer.push(text)
if (logBuffer.length > 240)
logBuffer.splice(0, logBuffer.length - 240)
}
child.stdout.on('data', appendLogs)
child.stderr.on('data', appendLogs)
return {
child,
getLogs() {
return logBuffer.join('')
},
}
}
async function runCommand(command, args, cwd, label) {
const runner = createProcessRunner(command, args, cwd)
const exitCode = await new Promise((resolve, reject) => {
runner.child.on('error', reject)
runner.child.on('exit', resolve)
})
if (exitCode !== 0) {
throw new Error(`${label} failed with exit code ${exitCode}\n${runner.getLogs()}`)
}
}
function startNuxtDev(port) {
return createProcessRunner('pnpm', ['exec', 'nuxt', 'dev', '--host', host, '--port', String(port)], playgroundDir)
}
function startNuxtPreview(port) {
return createProcessRunner(
'pnpm',
['exec', 'nuxt', 'preview'],
playgroundDir,
{
HOST: host,
PORT: String(port),
NITRO_HOST: host,
NITRO_PORT: String(port),
},
)
}
async function assertRawHtml(baseUrl, mode) {
const response = await fetch(`${baseUrl}/ssr-lab`)
assert(response.ok, `[${mode}] raw SSR fetch failed with status ${response.status}`)
const html = await response.text()
const expectedSnippets = [
'data-ssr-case="basic"',
'data-ssr-case="enhanced"',
'data-ssr-case="fallback"',
'SSR Basic Coverage',
'data-ssr-inline-html="1"',
'data-ssr-html-block="1"',
'data-markstream-code-block="1"',
'data-markstream-pre="1"',
'data-markstream-math="inline"',
'data-markstream-mode="katex"',
'data-markstream-mermaid="1"',
'data-markstream-d2="1"',
'data-markstream-infographic="1"',
'data-ssr-fallback="math-inline"',
'data-ssr-fallback="mermaid"',
]
for (const snippet of expectedSnippets)
assert(html.includes(snippet), `[${mode}] raw SSR HTML is missing expected snippet: ${snippet}`)
}
function collectUnexpectedIssues(entries) {
const patterns = [
/hydration/i,
/window is not defined/i,
/referenceerror/i,
/cannot read properties/i,
/failed to initialize mermaid renderer/i,
/failed to render/i,
]
return entries.filter(({ type, text }) => {
if (type === 'pageerror')
return String(text || '').trim() !== 'Event'
if (/Could not create web worker\(s\)\./.test(text))
return false
return patterns.some(pattern => pattern.test(text))
})
}
async function waitForEnhancedWidgets(page) {
await page.locator('[data-ssr-case="enhanced"]').scrollIntoViewIfNeeded()
await page.waitForSelector('[data-ssr-case="hydration"][data-ssr-hydrated="true"]', { timeout: 90000 })
await page.waitForSelector('[data-ssr-case="enhanced"] [data-markstream-code-block="1"][data-markstream-enhanced="true"]', { timeout: 90000 })
await page.waitForSelector('[data-ssr-case="enhanced"] [data-markstream-math="inline"][data-markstream-mode="katex"] .katex', { timeout: 90000 })
await page.waitForSelector('[data-ssr-case="enhanced"] [data-markstream-math="block"][data-markstream-mode="katex"] .katex-display', { timeout: 90000 })
await page.waitForSelector('[data-ssr-case="enhanced"] [data-markstream-mermaid="1"][data-markstream-mode="preview"] ._mermaid svg', { timeout: 90000 })
await page.waitForSelector('[data-ssr-case="enhanced"] [data-markstream-d2="1"][data-markstream-mode="preview"] svg.markstream-d2-root-svg', { timeout: 90000 })
await page.waitForSelector('[data-ssr-case="enhanced"] [data-markstream-infographic="1"][data-markstream-mode="preview"] svg', { timeout: 90000 })
await page.waitForFunction(() => {
const readyCount = document.querySelector('[data-ssr-status="ready-count"]')?.textContent?.trim()
return readyCount === '5'
}, { timeout: 90000 })
}
async function assertFallbackSection(page, mode) {
const fallbackState = await page.evaluate(() => {
const section = document.querySelector('[data-ssr-case="fallback"]')
return {
mathInline: !!section?.querySelector('[data-ssr-fallback="math-inline"]'),
mathBlock: !!section?.querySelector('[data-ssr-fallback="math-block"]'),
mermaid: !!section?.querySelector('[data-ssr-fallback="mermaid"]'),
d2: !!section?.querySelector('[data-ssr-fallback="d2"]'),
infographic: !!section?.querySelector('[data-ssr-fallback="infographic"]'),
previewLeak: !!section?.querySelector('[data-markstream-mode="preview"]'),
}
})
assert(fallbackState.mathInline, `[${mode}] fallback section lost inline math fallback`)
assert(fallbackState.mathBlock, `[${mode}] fallback section lost block math fallback`)
assert(fallbackState.mermaid, `[${mode}] fallback section lost mermaid fallback`)
assert(fallbackState.d2, `[${mode}] fallback section lost d2 fallback`)
assert(fallbackState.infographic, `[${mode}] fallback section lost infographic fallback`)
assert(!fallbackState.previewLeak, `[${mode}] fallback section unexpectedly switched to preview mode`)
}
async function assertBrowserBehavior(baseUrl, mode) {
const browser = await chromium.launch(resolveChromeLaunchOptions())
const page = await browser.newPage()
const issues = []
page.on('console', (msg) => {
const type = msg.type()
if (type === 'error' || type === 'warning')
issues.push({ type, text: msg.text() })
})
page.on('pageerror', (error) => {
issues.push({ type: 'pageerror', text: String(error?.message ?? error) })
})
try {
await page.goto(`${baseUrl}/ssr-lab`, { waitUntil: 'domcontentloaded', timeout: 90000 })
await waitForEnhancedWidgets(page, mode)
await assertFallbackSection(page, mode)
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 })
await waitForEnhancedWidgets(page, `${mode} reload`)
await assertFallbackSection(page, `${mode} reload`)
await page.goto(`${baseUrl}/`, { waitUntil: 'domcontentloaded', timeout: 90000 })
await page.goto(`${baseUrl}/ssr-lab`, { waitUntil: 'domcontentloaded', timeout: 90000 })
await waitForEnhancedWidgets(page, `${mode} route`)
await assertFallbackSection(page, `${mode} route`)
}
finally {
await browser.close()
}
const unexpectedIssues = collectUnexpectedIssues(issues)
assert(unexpectedIssues.length === 0, `[${mode}] browser reported unexpected issues:\n${unexpectedIssues.map(issue => `${issue.type}: ${issue.text}`).join('\n')}`)
}
async function runDevMode() {
const port = await findFreePort(4300, 4330)
const runner = startNuxtDev(port)
const baseUrl = `http://${host}:${port}`
try {
await waitForPort(port)
await waitForHttpReady(`${baseUrl}/ssr-lab`)
await assertRawHtml(baseUrl, 'nuxt dev')
await assertBrowserBehavior(baseUrl, 'nuxt dev')
}
catch (error) {
throw new Error(`${error.message}\n\nNuxt dev logs:\n${runner.getLogs()}`)
}
finally {
killProcessTree(runner.child)
}
}
async function runPreviewMode() {
await runCommand('pnpm', ['exec', 'nuxt', 'build'], playgroundDir, 'Nuxt build')
const port = await findFreePort(4331, 4360)
const runner = startNuxtPreview(port)
const baseUrl = `http://${host}:${port}`
try {
await waitForPort(port)
await waitForHttpReady(`${baseUrl}/ssr-lab`)
await assertRawHtml(baseUrl, 'nuxt preview')
await assertBrowserBehavior(baseUrl, 'nuxt preview')
}
catch (error) {
throw new Error(`${error.message}\n\nNuxt preview logs:\n${runner.getLogs()}`)
}
finally {
killProcessTree(runner.child)
}
}
async function main() {
await runCommand('pnpm', ['build'], repoRoot, 'Library build')
await runDevMode()
await runPreviewMode()
console.log('Nuxt SSR e2e passed in dev and preview modes.')
}
main()
.then(() => {
process.exit(0)
})
.catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exit(1)
})
@@ -0,0 +1,446 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright-core'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const playgroundDir = path.join(repoRoot, 'playground')
const host = '127.0.0.1'
function isPortOpen(port) {
return new Promise((resolve) => {
const socket = net.createConnection({ host, port })
socket.on('connect', () => {
socket.end()
resolve(true)
})
socket.on('error', () => {
socket.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 4250, end = 4290) {
for (let port = start; port <= end; port++) {
if (!await isPortOpen(port))
return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, timeoutMs = 60000) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (await isPortOpen(port))
return
await new Promise(resolve => setTimeout(resolve, 150))
}
throw new Error(`Timed out waiting for ${host}:${port}`)
}
function killProcessTree(child) {
if (!child || child.killed)
return
try {
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (!child.killed)
child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
function resolveChromeLaunchOptions() {
const candidates = [
process.env.PLAYWRIGHT_CHROME_PATH,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
].filter(Boolean)
for (const candidate of candidates) {
if (existsSync(candidate)) {
return {
executablePath: candidate,
headless: true,
}
}
}
return {
channel: 'chrome',
headless: true,
}
}
function startDevServer(port) {
const logs = []
const child = spawn(
'pnpm',
['-C', playgroundDir, 'exec', 'vite', '--host', host, '--port', String(port), '--strictPort'],
{
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
CI: '1',
},
},
)
child.stdout.on('data', chunk => logs.push(String(chunk)))
child.stderr.on('data', chunk => logs.push(String(chunk)))
return {
child,
getLogs() {
return logs.join('')
},
}
}
async function waitForVisibleBlocksReady(page, rootSelector) {
await page.waitForFunction((selector) => {
const root = document.querySelector(selector)
if (!root)
return false
const rootRect = root.getBoundingClientRect()
const isVisible = (element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > rootRect.top && rect.top < rootRect.bottom
}
const visibleCodeBlocks = Array.from(document.querySelectorAll('.code-block-container')).filter(isVisible)
const visibleMermaids = Array.from(document.querySelectorAll('[data-markstream-mermaid="1"]')).filter(isVisible)
const visibleInfographics = Array.from(document.querySelectorAll('[data-markstream-infographic="1"]')).filter(isVisible)
return visibleCodeBlocks.every(element => !element.querySelector('.code-fallback-plain, .code-pre-fallback'))
&& visibleMermaids.every(element => Boolean(element.querySelector('svg')))
&& visibleInfographics.every(element => Boolean(element.querySelector('svg')))
}, rootSelector, { timeout: 30000 })
}
async function waitForAllD2Ready(page, timeout = 15000) {
await page.waitForFunction(() => {
const d2Blocks = Array.from(document.querySelectorAll('[data-markstream-d2="1"]'))
return d2Blocks.every(element => Boolean(element.querySelector('.d2-svg svg')))
}, null, { timeout })
}
async function scrollThroughRoot(page, rootSelector) {
while (true) {
await waitForVisibleBlocksReady(page, rootSelector)
const state = await page.evaluate((selector) => {
const root = document.querySelector(selector)
if (!root)
return { done: true }
root.style.scrollBehavior = 'auto'
const maxScrollTop = Math.max(0, root.scrollHeight - root.clientHeight)
const step = Math.max(320, Math.round(root.clientHeight * 0.85))
const nextScrollTop = Math.min(maxScrollTop, root.scrollTop + step)
const done = nextScrollTop <= root.scrollTop + 1
if (!done)
root.scrollTop = nextScrollTop
return { done }
}, rootSelector)
if (state.done)
break
await page.waitForTimeout(180)
}
await waitForVisibleBlocksReady(page, rootSelector)
}
async function runScenario(browser, port, mode) {
const rootSelector = '.preview-surface'
const sample = process.env.PLAYGROUND_SAMPLE || 'baseline'
const warmupContext = await browser.newContext({
viewport: { width: 1600, height: 1200 },
storageState: {
cookies: [],
origins: [
{
origin: `http://${host}:${port}`,
localStorage: [
{ name: 'vmr-test-sample', value: sample },
{ name: 'vmr-test-render-mode', value: mode },
],
},
],
},
})
const warmupPage = await warmupContext.newPage()
await warmupPage.goto(`http://${host}:${port}/test`, { waitUntil: 'load' })
await warmupPage.locator('.workspace-card--preview').waitFor({ state: 'visible', timeout: 15000 })
await warmupContext.close()
const context = await browser.newContext({
viewport: { width: 1600, height: 1200 },
storageState: {
cookies: [],
origins: [
{
origin: `http://${host}:${port}`,
localStorage: [
{ name: 'vmr-test-sample', value: sample },
{ name: 'vmr-test-render-mode', value: mode },
],
},
],
},
})
await context.addInitScript(() => {
const state = {
startedAt: performance.now(),
cls: 0,
lcpMs: 0,
lcpElement: null,
longTasks: [],
paints: {},
layoutShifts: [],
}
window.__playgroundPerfState = state
const describeElement = (element) => {
if (!element)
return null
const tag = element.tagName ? element.tagName.toLowerCase() : 'unknown'
const id = element.id ? `#${element.id}` : ''
const className = typeof element.className === 'string'
? element.className.trim().split(/\s+/).filter(Boolean).join('.')
: ''
return `${tag}${id}${className ? `.${className}` : ''}`
}
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.startTime > state.lcpMs) {
state.lcpMs = entry.startTime
state.lcpElement = describeElement(entry.element)
}
}
}).observe({ type: 'largest-contentful-paint', buffered: true })
}
catch {}
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
state.cls += entry.value
state.layoutShifts.push({
value: entry.value,
sources: Array.isArray(entry.sources)
? entry.sources
.map(source => describeElement(source?.node))
.filter(Boolean)
.slice(0, 4)
: [],
})
}
}
}).observe({ type: 'layout-shift', buffered: true })
}
catch {}
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries())
state.longTasks.push(entry.duration)
}).observe({ type: 'longtask', buffered: true })
}
catch {}
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries())
state.paints[entry.name] = entry.startTime
}).observe({ type: 'paint', buffered: true })
}
catch {}
})
const page = await context.newPage()
await page.goto(`http://${host}:${port}/test`, { waitUntil: 'load' })
await page.locator('.workspace-card--preview').waitFor({ state: 'visible', timeout: 15000 })
await waitForVisibleBlocksReady(page, rootSelector)
await page.waitForTimeout(200)
const result = await page.evaluate(() => {
const state = window.__playgroundPerfState ?? {}
const longTasks = Array.isArray(state.longTasks) ? state.longTasks : []
const mermaids = Array.from(document.querySelectorAll('[data-markstream-mermaid="1"]'))
const infographics = Array.from(document.querySelectorAll('[data-markstream-infographic="1"]'))
const d2Blocks = Array.from(document.querySelectorAll('[data-markstream-d2="1"]'))
const root = document.querySelector('.preview-surface')
const rootRect = root?.getBoundingClientRect()
const isVisible = (element) => {
if (!rootRect)
return false
const rect = element.getBoundingClientRect()
return rect.bottom > rootRect.top && rect.top < rootRect.bottom
}
const visibleMermaids = mermaids.filter(isVisible)
const visibleInfographics = infographics.filter(isVisible)
const visibleD2Blocks = d2Blocks.filter(isVisible)
const allCodeBlocks = Array.from(document.querySelectorAll('.code-block-container'))
const visibleCodeBlocks = allCodeBlocks.filter(isVisible)
const diffCodeBlocks = allCodeBlocks.filter(element =>
element.classList.contains('is-diff') || Boolean(element.querySelector('.monaco-diff-editor')),
)
const visibleDiffCodeBlocks = diffCodeBlocks.filter(isVisible)
const renderedMermaidCount = mermaids.filter(element => element.querySelector('svg')).length
return {
sample: localStorage.getItem('vmr-test-sample') ?? 'unknown',
mode: localStorage.getItem('vmr-test-render-mode') ?? 'unknown',
lcpMs: Number(state.lcpMs ?? 0),
lcpElement: state.lcpElement ?? null,
cls: Number(state.cls ?? 0),
firstPaintMs: Number(state.paints?.paint ?? 0),
firstContentfulPaintMs: Number(state.paints?.['first-contentful-paint'] ?? 0),
longTaskCount: longTasks.length,
longTaskTotalMs: longTasks.reduce((sum, duration) => sum + Number(duration || 0), 0),
longTaskMaxMs: longTasks.length ? Math.max(...longTasks) : 0,
settleTimeMs: performance.now() - Number(state.startedAt ?? 0),
codeBlockCount: allCodeBlocks.length,
diffCodeBlockCount: diffCodeBlocks.length,
fallbackCount: document.querySelectorAll('.code-fallback-plain, .code-pre-fallback').length,
visibleCodeBlockCount: visibleCodeBlocks.length,
visibleFallbackCount: visibleCodeBlocks.filter(element => element.querySelector('.code-fallback-plain, .code-pre-fallback')).length,
visibleDiffCodeBlockCount: visibleDiffCodeBlocks.length,
visibleDiffFallbackCount: visibleDiffCodeBlocks.filter(element => element.querySelector('.code-fallback-plain, .code-pre-fallback')).length,
mermaidCount: mermaids.length,
renderedMermaidCount,
visibleMermaidCount: visibleMermaids.length,
visibleRenderedMermaidCount: visibleMermaids.filter(element => element.querySelector('svg')).length,
infographicCount: infographics.length,
renderedInfographicCount: infographics.filter(element => element.querySelector('svg')).length,
visibleInfographicCount: visibleInfographics.length,
visibleRenderedInfographicCount: visibleInfographics.filter(element => element.querySelector('svg')).length,
d2Count: d2Blocks.length,
renderedD2Count: d2Blocks.filter(element => element.querySelector('.d2-svg svg')).length,
visibleD2Count: visibleD2Blocks.length,
visibleRenderedD2Count: visibleD2Blocks.filter(element => element.querySelector('.d2-svg svg')).length,
sandboxFrameMounted: Boolean(document.querySelector('.sandbox-frame')),
topLayoutShifts: Array.isArray(state.layoutShifts)
? [...state.layoutShifts]
.sort((a, b) => Number(b?.value || 0) - Number(a?.value || 0))
.slice(0, 5)
: [],
}
})
await scrollThroughRoot(page, rootSelector)
await waitForAllD2Ready(page)
await page.waitForTimeout(200)
result.fullScroll = await page.evaluate(() => {
const state = window.__playgroundPerfState ?? {}
const longTasks = Array.isArray(state.longTasks) ? state.longTasks : []
const mermaids = Array.from(document.querySelectorAll('[data-markstream-mermaid="1"]'))
const infographics = Array.from(document.querySelectorAll('[data-markstream-infographic="1"]'))
const d2Blocks = Array.from(document.querySelectorAll('[data-markstream-d2="1"]'))
return {
settleTimeMs: performance.now() - Number(state.startedAt ?? 0),
fallbackCount: document.querySelectorAll('.code-fallback-plain, .code-pre-fallback').length,
mermaidCount: mermaids.length,
renderedMermaidCount: mermaids.filter(element => element.querySelector('svg')).length,
infographicCount: infographics.length,
renderedInfographicCount: infographics.filter(element => element.querySelector('svg')).length,
d2Count: d2Blocks.length,
renderedD2Count: d2Blocks.filter(element => element.querySelector('.d2-svg svg')).length,
longTaskTotalMs: longTasks.reduce((sum, duration) => sum + Number(duration || 0), 0),
}
})
await context.close()
return result
}
function assertScenario(result) {
if (!(result.codeBlockCount > 0))
throw new Error(`[${result.mode}] Expected at least one rendered code block.`)
if (result.visibleFallbackCount !== 0)
throw new Error(`[${result.mode}] Visible code fallback should be gone after initial settle.`)
if (result.sample === 'diff' && result.mode === 'monaco' && !(result.diffCodeBlockCount > 0))
throw new Error('[monaco] Diff sample should render at least one Monaco diff block.')
if (result.sample === 'diff' && result.mode === 'monaco' && result.visibleDiffFallbackCount !== 0)
throw new Error('[monaco] Visible diff fallback should be gone after initial settle.')
if (result.visibleRenderedMermaidCount !== result.visibleMermaidCount)
throw new Error(`[${result.mode}] Visible mermaid blocks should finish in preview mode after initial settle.`)
if (result.visibleRenderedInfographicCount !== result.visibleInfographicCount)
throw new Error(`[${result.mode}] Visible infographic blocks should finish after initial settle.`)
if (!(result.lcpMs > 0 && result.lcpMs <= 5000))
throw new Error(`[${result.mode}] LCP should stay within 5000ms. Got ${result.lcpMs}.`)
if (!(result.cls <= 0.05))
throw new Error(`[${result.mode}] CLS should stay within 0.05. Got ${result.cls}.`)
if (!(result.settleTimeMs <= 6000))
throw new Error(`[${result.mode}] Settle time should stay within 6000ms. Got ${result.settleTimeMs}.`)
if (!(result.longTaskMaxMs <= 700))
throw new Error(`[${result.mode}] Max long task should stay within 700ms. Got ${result.longTaskMaxMs}.`)
if (!(result.longTaskTotalMs <= 1800))
throw new Error(`[${result.mode}] Total long task time should stay within 1800ms. Got ${result.longTaskTotalMs}.`)
if (result.fullScroll.fallbackCount !== 0)
throw new Error(`[${result.mode}] Code fallback should be gone after full scroll settle.`)
if (result.fullScroll.renderedMermaidCount !== result.fullScroll.mermaidCount)
throw new Error(`[${result.mode}] Mermaid blocks should all finish after full scroll settle.`)
if (result.fullScroll.renderedInfographicCount !== result.fullScroll.infographicCount)
throw new Error(`[${result.mode}] Infographic blocks should all finish after full scroll settle.`)
if (result.fullScroll.renderedD2Count !== result.fullScroll.d2Count)
throw new Error(`[${result.mode}] D2 blocks should all finish after full scroll settle.`)
}
async function run() {
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
const server = startDevServer(port)
let results = null
const cleanup = () => killProcessTree(server.child)
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
process.on('exit', cleanup)
try {
await waitForPort(port)
const browser = await chromium.launch(resolveChromeLaunchOptions())
const markdownResult = await runScenario(browser, port, 'markdown')
const monacoResult = await runScenario(browser, port, 'monaco')
results = {
markdown: markdownResult,
monaco: monacoResult,
}
assertScenario(markdownResult)
assertScenario(monacoResult)
console.log(JSON.stringify(results, null, 2))
await browser.close()
}
catch (error) {
console.error('[e2e-playground-performance] failed')
console.error(error)
if (results)
console.error(JSON.stringify(results, null, 2))
console.error(server.getLogs())
process.exitCode = 1
}
finally {
cleanup()
}
}
run()
@@ -0,0 +1,75 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const checks = [
{
name: 'codeblock-highlight-stability',
script: path.join(repoRoot, 'scripts/e2e-codeblock-highlight-stability.mjs'),
},
{
name: 'link-tooltip-streaming',
script: path.join(repoRoot, 'scripts/e2e-link-tooltip-streaming.mjs'),
},
{
name: 'math-streaming-stability',
script: path.join(repoRoot, 'scripts/e2e-math-streaming-stability.mjs'),
},
]
function runCheck(check) {
return new Promise((resolve, reject) => {
const startedAt = Date.now()
const child = spawn(process.execPath, [check.script], {
cwd: repoRoot,
env: process.env,
stdio: 'inherit',
})
child.on('error', reject)
child.on('exit', (code, signal) => {
const durationMs = Date.now() - startedAt
if (code === 0) {
resolve({
...check,
durationMs,
ok: true,
})
return
}
reject(new Error(`${check.name} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`))
})
})
}
async function main() {
const results = []
for (const check of checks) {
console.log(`\n=== Running ${check.name} ===`)
const result = await runCheck(check)
results.push(result)
}
console.log('\n=== Vue2 CLI smoke summary ===')
console.log(JSON.stringify({
checks: results.map(result => ({
name: result.name,
durationMs: result.durationMs,
ok: result.ok,
})),
}, null, 2))
}
main().catch((error) => {
console.error('\n=== Vue2 CLI smoke failed ===')
console.error(error)
process.exit(1)
})
@@ -0,0 +1,74 @@
#!/usr/bin/env node
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
const root = process.cwd()
const dist = path.resolve(root, 'dist')
const assetsDir = path.resolve(dist, 'assets')
const indexFile = path.resolve(dist, 'index.js')
// Remove any hashed worker files that might be left in dist root or dist/assets
function removeHashedWorkers() {
const candidates = [dist, path.resolve(dist, 'assets')]
for (const dir of candidates) {
if (!fs.existsSync(dir))
continue
const files = fs.readdirSync(dir)
for (const f of files) {
if (/katexRenderer\.worker[-_.].*\.js$/.test(f) || /mermaidParser\.worker[-_.].*\.js$/.test(f)) {
try {
fs.unlinkSync(path.resolve(dir, f))
console.log('Removed hashed worker file:', path.resolve(dir, f))
}
catch (e) {
console.warn('Failed to remove hashed worker file:', f, e)
}
}
}
}
}
removeHashedWorkers()
// Now flatten remaining assets from dist/assets into dist root (if any)
function flattenAssets() {
if (!fs.existsSync(assetsDir))
return
const files = fs.readdirSync(assetsDir)
for (const f of files) {
// skip the worker files we've already handled
if (/^katexRenderer\\.worker|^mermaidParser\\.worker/.test(f))
continue
const src = path.resolve(assetsDir, f)
const dest = path.resolve(dist, f)
// avoid overwriting existing file
if (fs.existsSync(dest)) {
console.log('Skipping move because destination exists:', dest)
continue
}
fs.renameSync(src, dest)
console.log('Moved asset', src, '->', dest)
// patch index.js references from assets/<f> to <f>
if (fs.existsSync(indexFile)) {
let content = fs.readFileSync(indexFile, 'utf8')
const oldRef = `assets/${f}`
if (content.includes(oldRef)) {
content = content.split(oldRef).join(f)
fs.writeFileSync(indexFile, content, 'utf8')
console.log('Patched index.js reference', oldRef, '->', f)
}
}
}
// remove assetsDir if empty
try {
const remaining = fs.readdirSync(assetsDir)
if (remaining.length === 0)
fs.rmdirSync(assetsDir)
}
catch {
// ignore
}
}
flattenAssets()
@@ -0,0 +1,45 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
const sourceCss = path.resolve('dist/index.css')
const targetCss = path.resolve('dist/index.px.css')
const pxPerRem = 16
const remPattern = /(-?(?:\d+(?:\.\d+)?|\.\d+))rem\b/g
function formatPx(value) {
if (value === 0)
return '0'
const rounded = Math.round(value * 10000) / 10000
return `${Number.parseFloat(rounded.toFixed(4))}px`
}
async function main() {
let css = ''
try {
css = await fs.readFile(sourceCss, 'utf8')
}
catch (err) {
const e = err
throw new Error(`[generate-px-css] Missing ${sourceCss}. Did the npm build run? (${e?.message || e})`)
}
let replacementCount = 0
const convertedCss = css.replace(remPattern, (_, remValue) => {
const rem = Number(remValue)
if (Number.isNaN(rem))
return `${remValue}rem`
replacementCount += 1
return formatPx(rem * pxPerRem)
})
await fs.writeFile(targetCss, convertedCss, 'utf8')
console.log(`[generate-px-css] Wrote ${targetCss} (${replacementCount} rem values converted using 1rem=${pxPerRem}px)`)
}
main().catch((err) => {
console.error(err)
process.exitCode = 1
})
@@ -0,0 +1,27 @@
import process from 'node:process'
import {
parseInstallArgs,
printInstallHelp,
printInstallSummary,
runSkillsInstall,
} from '../bin/skills-lib.mjs'
async function main() {
const args = parseInstallArgs(process.argv.slice(2), {
target: 'agents',
mode: 'symlink',
})
if (args.help) {
printInstallHelp('node scripts/install-skills.mjs', 'symlink')
return
}
const result = await runSkillsInstall(args)
printInstallSummary(result)
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exit(1)
})
@@ -0,0 +1,102 @@
#!/usr/bin/env node
/**
* Measure KaTeX render time and recommend a worker threshold N
* N ≈ B / (R × (1 - H))
* - B: main-thread budget (ms) — defaults to 50ms and 16.7ms
* - R: avg render time per unique formula (ms)
* - H: cache hit rate (0~1). For first paint assume H=0
*/
import { performance } from 'node:perf_hooks'
import process from 'node:process'
async function getKatex() {
try {
const k = await import('katex')
return k.default || k
}
catch {
console.error('[measure-katex-threshold] KaTeX not installed. pnpm add katex -D')
process.exit(1)
}
}
const FORMULAS = {
simple: [
'x',
'a + b',
'E = mc^2',
],
medium: [
'\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}',
'\\frac{a}{b} + \\sqrt{x}',
'\\lim_{x \\to 0} \\frac{\\sin x}{x}',
],
complex: [
'\\int_{-\\infty}^{\\infty} e^{-x^2} \\, dx = \\sqrt{\\pi}',
'\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}',
'\\frac{d}{dx}\\left(\\int_{a}^{x} f(t)\\,dt\\right) = f(x)',
],
}
function bench(katex, formulas, iterations = 50) {
// warm-up
for (let i = 0; i < 10; i++) {
for (const f of formulas) katex.renderToString(f, { throwOnError: false, displayMode: true })
}
const start = performance.now()
for (let i = 0; i < iterations; i++) {
for (const f of formulas) katex.renderToString(f, { throwOnError: false, displayMode: true })
}
const end = performance.now()
const total = end - start
const renders = iterations * formulas.length
return { avg: total / renders, total, renders }
}
function recommend({ R, H = 0, B = 50 }) {
const denom = R * (1 - H) || 1e-6
const N = B / denom
return Math.max(1, Math.floor(N))
}
function fmt(n, digits = 2) {
return Number(n).toFixed(digits)
}
(async () => {
const katex = await getKatex()
const s = bench(katex, FORMULAS.simple)
const m = bench(katex, FORMULAS.medium)
const c = bench(katex, FORMULAS.complex)
const rec50 = {
simple: recommend({ R: s.avg, B: 50 }),
medium: recommend({ R: m.avg, B: 50 }),
complex: recommend({ R: c.avg, B: 50 }),
}
const rec16 = {
simple: recommend({ R: s.avg, B: 16.7 }),
medium: recommend({ R: m.avg, B: 16.7 }),
complex: recommend({ R: c.avg, B: 16.7 }),
}
console.log('\nKaTeX Rendering Benchmark (ms per render)')
console.table({
simple: fmt(s.avg),
medium: fmt(m.avg),
complex: fmt(c.avg),
})
console.log('\nRecommended worker thresholds (N = floor(B / R))')
console.table({
'B=50ms (no cache)': rec50,
'B=16.7ms (no cache)': rec16,
})
console.log('\nNotes:')
console.log('- Thresholds are for first paint (H=0). With cache, N increases roughly by 1/(1-H).')
console.log('- Use medium thresholds as default. Drop to complex thresholds if many matrices/integrals.')
console.log('- If your content is very simple, you can raise the threshold accordingly.')
})()
@@ -0,0 +1,23 @@
const fs = require('node:fs')
const path = require('node:path')
const process = require('node:process')
const distDir = path.resolve(__dirname, '..', 'dist')
const sourcePath = path.join(distDir, 'types', 'src', 'exports.d.ts')
const targetPath = path.join(distDir, 'types', 'exports.d.ts')
function rewriteEntryImports(content) {
return content
.replace(/from\s+(['"])\.\//g, 'from $1./src/')
.replace(/import\((['"])\.\//g, 'import($1./src/')
}
if (!fs.existsSync(sourcePath)) {
process.exit(0)
}
fs.mkdirSync(path.dirname(targetPath), { recursive: true })
const sourceContent = fs.readFileSync(sourcePath, 'utf8')
const rewrittenContent = rewriteEntryImports(sourceContent)
fs.writeFileSync(targetPath, rewrittenContent)
console.log('Prepared declaration entry', targetPath, 'from', sourcePath)
@@ -0,0 +1,118 @@
import { execFileSync } from 'node:child_process'
import process from 'node:process'
function run(command, args, options = {}) {
return execFileSync(command, args, {
cwd: options.cwd,
encoding: 'utf8',
stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'],
})
}
function runGit(args, options) {
return run('git', args, options)
}
function parseArgs(argv) {
const args = {
pattern: 'v*',
remote: 'origin',
dryRun: true,
deleteLocal: false,
deleteRemote: false,
}
for (let i = 0; i < argv.length; i++) {
const cur = argv[i]
if (cur === '--pattern') {
args.pattern = argv[++i]
}
else if (cur === '--remote') {
args.remote = argv[++i]
}
else if (cur === '--apply') {
args.dryRun = false
}
else if (cur === '--dry-run') {
args.dryRun = true
}
else if (cur === '--local') {
args.deleteLocal = true
}
else if (cur === '--remote-delete') {
args.deleteRemote = true
}
else if (cur === '--help' || cur === '-h') {
console.log(`
Usage:
node scripts/prune-tags.mjs [options]
Options:
--pattern <glob> Tag pattern for git tag --list (default: v*)
--dry-run Print actions only (default)
--apply Actually delete tags (requires --local and/or --remote-delete)
--local Delete local tags
--remote-delete Delete remote tags (git push origin :refs/tags/<tag>)
--remote <name> Git remote name (default: origin)
Examples:
node scripts/prune-tags.mjs --pattern 'v*' --dry-run
node scripts/prune-tags.mjs --pattern 'v*' --apply --local
node scripts/prune-tags.mjs --pattern 'v*' --apply --local --remote-delete --remote origin
`)
process.exit(0)
}
else {
throw new Error(`Unknown argument: ${cur}`)
}
}
if (!args.dryRun && !args.deleteLocal && !args.deleteRemote)
throw new Error('Nothing to do: pass --local and/or --remote-delete with --apply.')
return args
}
async function main() {
const args = parseArgs(process.argv.slice(2))
const tags = runGit(['tag', '--list', args.pattern])
.split('\n')
.map(s => s.trim())
.filter(Boolean)
if (!tags.length) {
console.log(`[prune-tags] No tags match: ${args.pattern}`)
return
}
console.log(`[prune-tags] matched=${tags.length} pattern=${args.pattern} dryRun=${args.dryRun}`)
for (const tag of tags) {
if (args.deleteLocal) {
const cmd = ['tag', '-d', tag]
if (args.dryRun) {
console.log(`[prune-tags][dry-run] git ${cmd.join(' ')}`)
}
else {
runGit(cmd, { stdio: 'inherit' })
}
}
}
if (args.deleteRemote) {
for (const tag of tags) {
const cmd = ['push', args.remote, `:refs/tags/${tag}`]
if (args.dryRun) {
console.log(`[prune-tags][dry-run] git ${cmd.join(' ')}`)
}
else {
runGit(cmd, { stdio: 'inherit' })
}
}
}
}
main().catch((err) => {
console.error(err?.message || err)
process.exit(1)
})
@@ -0,0 +1,45 @@
import fs from 'node:fs'
import path from 'node:path'
import dts from 'rollup-plugin-dts'
// Bundle the generated declaration entry into a single dist/index.d.ts.
// Treat workspace deps as external so we don't try to resolve them during
// bundling (the consumer will have their own types for those packages).
// If the expected Vite output entry (./dist/types/exports.d.ts) is missing
// fall back to an existing `dist/index.d.ts` so the build doesn't fail in
// environments where declarations were previously emitted differently.
const fallbackInput = './dist/index.d.ts'
const preferredInput = './dist/types/exports.d.ts'
const preferredExists = fs.existsSync(path.resolve(preferredInput))
const fallbackExists = fs.existsSync(path.resolve(fallbackInput))
if (!preferredExists && !fallbackExists) {
throw new Error(
'No declaration entry found. Run `pnpm run build` (which emits declarations) or otherwise ensure one of `./dist/types/exports.d.ts` or `./dist/index.d.ts` exists before running `build:dts`.',
)
}
const inputPath = preferredExists ? preferredInput : fallbackInput
export default [
{
// Prefer the Vite emitted declarations under dist/types if present
input: inputPath,
plugins: [
dts({
// Inline local type imports to ensure named interfaces are bundled
respectExternal: false,
}),
],
external: [
/^stream-markdown-parser(?:\/.*)?$/,
],
output: [
{
file: 'dist/index.d.ts',
format: 'es',
},
],
},
]
@@ -0,0 +1,91 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
const root = process.cwd()
const enDir = path.join(root, 'docs', 'guide')
const zhDir = path.join(root, 'docs', 'zh', 'guide')
async function ensureDir(d) {
try {
await fs.mkdir(d, { recursive: true })
}
catch {
// ignore
}
}
function toZhFilename(file) {
return file
}
async function main() {
await ensureDir(zhDir)
const files = await fs.readdir(enDir)
const mdFiles = files.filter(f => f.endsWith('.md'))
const created = []
for (const f of mdFiles) {
const enPath = path.join(enDir, f)
const zhPath = path.join(zhDir, toZhFilename(f))
// decide whether to create or refresh placeholder
let shouldWrite = false
try {
await fs.access(zhPath)
const cur = await fs.readFile(zhPath, 'utf8')
if (/中文占位|自动生成占位/.test(cur)) {
shouldWrite = true // refresh old placeholder with improved template
}
else {
// translation exists; skip
continue
}
}
catch {
// file doesn't exist -> create placeholder
shouldWrite = true
}
if (!shouldWrite)
continue
const enContent = await fs.readFile(enPath, 'utf8')
// eslint-disable-next-line regexp/no-super-linear-backtracking
const titleMatch = enContent.match(/^#\s+(.+)$/m)
const title = titleMatch ? titleMatch[1].trim() : path.basename(f, '.md')
// include a short excerpt to help translators
// Use a robust, non-regex approach to extract a short excerpt from the
// section content to avoid regexp backtracking issues flagged by eslint.
const startIdx = titleMatch ? enContent.indexOf(titleMatch[0]) + titleMatch[0].length : 0
const excerptRaw = enContent.slice(startIdx).trim().split('\n').slice(0, 6).join(' ')
const excerpt = excerptRaw.substring(0, 300).trim()
const placeholder = `# ${title}(中文占位)\n\n`
+ `> 原文节选(仅供翻译参考):\n\n${
excerpt ? `> ${excerpt.replace(/\n/g, '\n> ')}\n\n` : ''
}## 说明\n\n此页面为中文占位,原文(English):/guide/${f}。\n\n`
+ `- [ ] 翻译标题和正文\n`
+ `- [ ] 本地化示例中的代码注释\n`
+ `- [ ] 检查链接并改为 /zh/ 前缀(如有)\n`
+ `\n`
+ `如果你愿意贡献中文翻译,请参考翻译指南并提交 PR:/guide/translation\n\n`
+ `---\n\n`
+ `> 自动生成占位:将原文翻译并替换此文件的内容即可。`
await fs.writeFile(zhPath, placeholder, 'utf8')
created.push(zhPath)
}
if (created.length) {
console.log('Created placeholders for missing zh docs:')
created.forEach(f => console.log(' -', f))
}
else {
console.log('All zh docs exist. Nothing to create.')
}
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
@@ -0,0 +1,168 @@
import { execFileSync } from 'node:child_process'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
function run(command, args, options = {}) {
return execFileSync(command, args, {
cwd: options.cwd,
encoding: 'utf8',
stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'],
})
}
function runGit(args, options) {
return run('git', args, options)
}
function parseArgs(argv) {
const args = {
packageJson: 'package.json',
ref: 'HEAD',
tag: null,
message: null,
remote: 'origin',
push: false,
pushTagOnly: false,
allowDirty: false,
dryRun: false,
force: false,
}
for (let i = 0; i < argv.length; i++) {
const cur = argv[i]
if (cur === '--package-json') {
args.packageJson = argv[++i]
}
else if (cur === '--ref') {
args.ref = argv[++i]
}
else if (cur === '--tag') {
args.tag = argv[++i]
}
else if (cur === '--message') {
args.message = argv[++i]
}
else if (cur === '--remote') {
args.remote = argv[++i]
}
else if (cur === '--push') {
args.push = true
}
else if (cur === '--push-tag-only') {
args.pushTagOnly = true
}
else if (cur === '--allow-dirty') {
args.allowDirty = true
}
else if (cur === '--dry-run') {
args.dryRun = true
}
else if (cur === '--force') {
args.force = true
}
else if (cur === '--help' || cur === '-h') {
console.log(`
Usage:
node scripts/tag-package.mjs [options]
Options:
--package-json <path> Path to package.json (default: package.json)
--ref <git-ref> Tag target ref (default: HEAD)
--tag <tagName> Override tag name (default: <name>@<version>)
--message <msg> Tag annotation message (default: tag name)
--remote <name> Git remote to push to (default: origin)
--push Push current branch + the new tag
--push-tag-only Only push the tag (skip git push)
--allow-dirty Skip clean working tree check
--dry-run Print actions without mutating git state
--force Force-update local tag if it exists
`)
process.exit(0)
}
else {
throw new Error(`Unknown argument: ${cur}`)
}
}
return args
}
function getCommitForRef(ref) {
return runGit(['rev-parse', `${ref}^{}`]).trim()
}
function getCommitForTag(tag) {
try {
return runGit(['rev-parse', `${tag}^{}`]).trim()
}
catch {
return null
}
}
async function main() {
const args = parseArgs(process.argv.slice(2))
if (!args.allowDirty) {
const dirty = runGit(['status', '--porcelain']).trim()
if (dirty) {
throw new Error(`Working tree is not clean. Commit/stash changes or pass --allow-dirty.\n\n${dirty}`)
}
}
const packageJsonAbs = path.resolve(process.cwd(), args.packageJson)
const pkg = JSON.parse(await readFile(packageJsonAbs, 'utf8'))
const name = String(pkg?.name ?? '').trim()
const version = String(pkg?.version ?? '').trim()
if (!name || !version)
throw new Error(`Invalid package.json (missing name/version): ${args.packageJson}`)
const tagName = args.tag ?? `${name}@${version}`
const message = args.message ?? tagName
const refCommit = getCommitForRef(args.ref)
const existingCommit = getCommitForTag(tagName)
if (existingCommit && existingCommit === refCommit) {
console.log(`[tag-package] Tag already exists: ${tagName} -> ${existingCommit}`)
return
}
if (existingCommit && !args.force)
throw new Error(`[tag-package] Tag already exists and points elsewhere: ${tagName} -> ${existingCommit} (wanted ${refCommit}). Use --force if you really want to retag locally.`)
const tagArgs = ['tag', '-a', tagName, args.ref, '-m', message]
if (args.force)
tagArgs.splice(2, 0, '-f')
if (args.dryRun) {
console.log(`[tag-package][dry-run] git ${tagArgs.join(' ')}`)
}
else {
runGit(tagArgs, { stdio: 'inherit' })
}
if (!args.push)
return
if (!args.pushTagOnly) {
if (args.dryRun) {
console.log('[tag-package][dry-run] git push')
}
else {
runGit(['push'], { stdio: 'inherit' })
}
}
if (args.dryRun) {
console.log(`[tag-package][dry-run] git push ${args.remote} ${tagName}`)
}
else {
runGit(['push', args.remote, tagName], { stdio: 'inherit' })
}
}
main().catch((err) => {
console.error(err?.message || err)
process.exit(1)
})
@@ -0,0 +1,378 @@
#!/usr/bin/env node
/**
* Theme generation CLI.
*
* Usage:
* # Generate from key colors (hex)
* node scripts/theme-gen/cli.mjs generate \
* --name claude \
* --bg "#f5f4ed" --fg "#141413" --brand "#c96442" \
* [--surface "#faf9f5"] [--border "#e8e6dc"] [--link "#0366d6"]
*
* # Convert a single color
* node scripts/theme-gen/cli.mjs convert "#c96442"
*
* # Validate contrast of a generated theme
* node scripts/theme-gen/cli.mjs validate --file themes/claude.css
*
* # Demo: generate themes for several well-known DESIGN.md palettes
* node scripts/theme-gen/cli.mjs demo
*/
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { hslObjToHex, toHsl, toOklch, toOklchCss, toShadcnHsl } from './color.mjs'
import { generateBothSchemes, validateContrast } from './dark.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url))
/** Token keys that are not color values (font stacks, radius, etc.) — rendered in the shared block */
const NON_COLOR_TOKENS = new Set(['font-sans', 'font-mono', 'font-serif', 'radius'])
// ─── CLI dispatch ───────────────────────────────────────────────────
const [,, command, ...args] = process.argv
const commands = { generate, convert, validate, build }
if (!command || !commands[command]) {
console.log(`
Theme Generator for markstream-vue
Commands:
build Build all themes from registry.json
generate Generate a single theme CSS from key colors
convert Convert a CSS color to shadcn HSL / OKLCH
validate Check WCAG contrast for a theme
Run with --help for per-command usage.
`)
process.exit(0)
}
commands[command](args)
// ─── Commands ───────────────────────────────────────────────────────
function convert(args) {
const color = args[0]
if (!color) {
console.error('Usage: convert <color>\n e.g. convert "#c96442"\n e.g. convert "oklch(0.6 0.15 40)"')
process.exit(1)
}
const hsl = toHsl(color)
const shadcn = toShadcnHsl(color)
const hex = hslObjToHex(hsl)
const oklch = toOklch(color)
const oklchCss = toOklchCss(color)
console.log(`Input: ${color}`)
console.log(`hex: ${hex}`)
console.log(`HSL: h=${hsl.h} s=${hsl.s}% l=${hsl.l}%${hsl.a < 1 ? ` a=${hsl.a}` : ''}`)
console.log(`shadcn: ${shadcn}`)
console.log(`OKLCH: L=${oklch.l} C=${oklch.c} H=${oklch.h}${oklch.a < 1 ? ` a=${oklch.a}` : ''}`)
console.log(`oklch(): ${oklchCss}`)
}
function generate(args) {
const opts = parseArgs(args)
if (opts.help) {
console.log(`
Usage: generate --name <name> --bg <hex> --fg <hex> --brand <hex>
[--surface <hex>] [--border <hex>] [--link <hex>]
[--error <hex>] [--secondary-text <hex>]
[--brand-fg <hex>] [--ring <hex>]
[--font-sans <stack>] [--font-mono <stack>] [--font-serif <stack>]
[--out <dir>] [--format css|json]
`)
process.exit(0)
}
const name = opts.name || 'custom'
const format = opts.format || 'css'
const outDir = opts.out || resolve(__dirname, '../../themes')
if (!opts.bg || !opts.fg || !opts.brand) {
console.error('Error: --bg, --fg, and --brand are required.')
process.exit(1)
}
// Build font overrides if any --font-* flags provided
const fonts = {}
if (opts['font-sans'])
fonts.sans = opts['font-sans']
if (opts['font-mono'])
fonts.mono = opts['font-mono']
if (opts['font-serif'])
fonts.serif = opts['font-serif']
const keyColors = {
background: opts.bg,
foreground: opts.fg,
brand: opts.brand,
...(opts.surface && { surface: opts.surface }),
...(opts.border && { border: opts.border }),
...(opts.link && { link: opts.link }),
...(opts.error && { error: opts.error }),
...(opts['secondary-text'] && { secondaryText: opts['secondary-text'] }),
...(opts['brand-fg'] && { brandForeground: opts['brand-fg'] }),
...(opts.ring && { ring: opts.ring }),
...(Object.keys(fonts).length > 0 && { fonts }),
}
const { light, dark } = generateBothSchemes(keyColors)
// Validate contrast
console.log(`\n── ${name} (light) ──`)
printContrast(validateContrast(light))
console.log(`\n── ${name} (dark) ──`)
printContrast(validateContrast(dark))
// Output
mkdirSync(outDir, { recursive: true })
if (format === 'json') {
const path = resolve(outDir, `${name}.json`)
writeFileSync(path, JSON.stringify({ name, light, dark }, null, 2))
console.log(`\nWritten: ${path}`)
}
else {
const css = renderThemeCss(name, light, dark)
const path = resolve(outDir, `${name}.css`)
writeFileSync(path, css)
console.log(`\nWritten: ${path}`)
}
}
function validate(args) {
const opts = parseArgs(args)
const filePath = opts.file || args[0]
if (!filePath) {
console.error('Usage: validate --file <path-to-theme.css>')
process.exit(1)
}
const css = readFileSync(resolve(filePath), 'utf-8')
const tokens = parseCssTokens(css)
if (tokens.light) {
console.log('\n── Light scheme ──')
printContrast(validateContrast(tokens.light))
}
if (tokens.dark) {
console.log('\n── Dark scheme ──')
printContrast(validateContrast(tokens.dark))
}
}
function build(args) {
const opts = parseArgs(args)
const registryPath = opts.registry || resolve(__dirname, 'registry.json')
const outDir = opts.out || resolve(__dirname, '../../themes')
const filter = opts.only ? opts.only.split(',') : null
const registry = JSON.parse(readFileSync(registryPath, 'utf-8'))
const entries = filter
? registry.filter(e => filter.includes(e.id) || filter.includes(e.category))
: registry
console.log(`Building ${entries.length} themes from registry...\n`)
mkdirSync(outDir, { recursive: true })
let passCount = 0
let warnCount = 0
let failCount = 0
for (const entry of entries) {
const keyColors = {
background: entry.colors.background,
foreground: entry.colors.foreground,
brand: entry.colors.brand,
...(entry.colors.surface && { surface: entry.colors.surface }),
...(entry.colors.secondaryText && { secondaryText: entry.colors.secondaryText }),
...(entry.colors.border && { border: entry.colors.border }),
...(entry.colors.error && { error: entry.colors.error }),
...(entry.colors.link && { link: entry.colors.link }),
...(entry.colors.brandForeground && { brandForeground: entry.colors.brandForeground }),
...(entry.colors.ring && { ring: entry.colors.ring }),
...(entry.colors.info && { info: entry.colors.info }),
...(entry.colors.success && { success: entry.colors.success }),
...(entry.colors.warning && { warning: entry.colors.warning }),
...(entry.colors.highlight && { highlight: entry.colors.highlight }),
...(entry.colors.diffAdded && { diffAdded: entry.colors.diffAdded }),
...(entry.colors.diffRemoved && { diffRemoved: entry.colors.diffRemoved }),
...(entry.fonts && { fonts: entry.fonts }),
...(entry.radius && { radius: entry.radius }),
...(entry.dark && { dark: entry.dark }),
}
const { light, dark } = generateBothSchemes(keyColors)
const lightReport = validateContrast(light)
const darkReport = validateContrast(dark)
const errors = [...lightReport, ...darkReport].filter(r => !r.passAA && r.severity === 'error').length
const warns = [...lightReport, ...darkReport].filter(r => !r.passAA && r.severity === 'warn').length
let status
if (errors > 0) {
status = `✗ ${errors}`
failCount++
}
else if (warns > 0) {
status = `~ ${warns}`
warnCount++
}
else {
status = '✓'
passCount++
}
const css = renderThemeCss(entry.id, light, dark)
const path = resolve(outDir, `${entry.id}.css`)
writeFileSync(path, css)
const cat = (entry.category || '').padEnd(10)
const mode = entry.native === 'dark' ? '◐' : '◑'
console.log(` ${status} ${mode} ${cat} ${entry.id}`)
}
console.log(`\n${passCount} pass, ${warnCount} warn, ${failCount} fail — ${entries.length} themes → themes/`)
// Also generate an index CSS that imports all themes
const indexLines = entries.map(e => `@import './${e.id}.css';`)
writeFileSync(resolve(outDir, 'index.css'), `${indexLines.join('\n')}\n`)
console.log(`Index written → themes/index.css`)
}
// ─── CSS rendering ──────────────────────────────────────────────────
function splitTokens(tokens) {
const colors = {}
const shared = {}
for (const [key, val] of Object.entries(tokens)) {
if (NON_COLOR_TOKENS.has(key))
shared[key] = val
else
colors[key] = val
}
return { colors, shared }
}
function renderThemeCss(name, light, dark) {
const { colors: lightColors, shared } = splitTokens(light)
const { colors: darkColors } = splitTokens(dark)
const lines = []
lines.push(`/**`)
lines.push(` * markstream-vue theme: ${name}`)
lines.push(` * Auto-generated by scripts/theme-gen/cli.mjs`)
lines.push(` */`)
lines.push('')
// Shared tokens (fonts, radius — mode-independent)
if (Object.keys(shared).length > 0) {
lines.push(`/* ── Shared ── */`)
lines.push(`.markstream-vue[data-theme="${name}"] {`)
for (const [key, val] of Object.entries(shared)) {
lines.push(` --ms-${key}: ${val};`)
}
lines.push('}')
lines.push('')
}
lines.push(`/* ── Light ── */`)
lines.push(`.markstream-vue[data-theme="${name}"] {`)
for (const [key, val] of Object.entries(lightColors)) {
lines.push(` --ms-${key}: ${val};`)
}
lines.push('}')
lines.push('')
lines.push(`/* ── Dark ── */`)
lines.push(`.dark .markstream-vue[data-theme="${name}"],`)
lines.push(`.markstream-vue[data-theme="${name}"].dark {`)
for (const [key, val] of Object.entries(darkColors)) {
lines.push(` --ms-${key}: ${val};`)
}
lines.push('}')
lines.push('')
return lines.join('\n')
}
// ─── Helpers ────────────────────────────────────────────────────────
function parseArgs(args) {
const result = {}
for (let i = 0; i < args.length; i++) {
if (args[i].startsWith('--')) {
const key = args[i].slice(2)
const next = args[i + 1]
if (!next || next.startsWith('--')) {
result[key] = true
}
else {
result[key] = next
i++
}
}
else {
result._positional = result._positional || []
result._positional.push(args[i])
}
}
return result
}
/**
* Parse --ms-* tokens from a CSS file (simple regex, not a full parser).
*/
function parseCssTokens(css) {
const result = {}
// Find light block (first rule block)
const lightMatch = css.match(/\.markstream-vue\[data-theme[^\]]+\]\s*\{([^}]+)\}/)
if (lightMatch) {
result.light = extractTokens(lightMatch[1])
}
// Find dark block
const darkMatch = css.match(/\.dark[^{]*\{([^}]+)\}\s*$/)
if (darkMatch) {
result.dark = extractTokens(darkMatch[1])
}
return result
}
function extractTokens(block) {
const tokens = {}
for (const line of block.split('\n')) {
const trimmed = line.trim()
if (!trimmed.startsWith('--ms-'))
continue
const colonIndex = trimmed.indexOf(':')
const semicolonIndex = trimmed.lastIndexOf(';')
if (colonIndex === -1 || semicolonIndex === -1 || semicolonIndex <= colonIndex)
continue
const key = trimmed.slice('--ms-'.length, colonIndex).trim()
const value = trimmed.slice(colonIndex + 1, semicolonIndex).trim()
if (!key || !value)
continue
tokens[key] = value
}
return tokens
}
function printContrast(results) {
for (const r of results) {
const icon = r.passAA ? (r.passAAA ? '◉' : '○') : (r.severity === 'error' ? '✗' : '~')
const ratio = r.ratio.toFixed(1).padStart(5)
const sev = r.severity === 'warn' && !r.passAA ? ' [warn]' : ''
console.log(` ${icon} ${ratio}:1 ${r.pair} (≥${r.threshold})${sev}`)
}
}
@@ -0,0 +1,619 @@
/**
* Color conversion utilities for theme generation.
* Zero dependencies — pure math.
*
* Supported color spaces: sRGB, HSL, OKLCH.
* All public functions accept flexible input (hex, rgba, hsl, oklch string)
* and output either structured objects or shadcn bare format "H S% L%".
*/
// ─── Parsing ────────────────────────────────────────────────────────
/**
* Parse any CSS color string into { r, g, b, a } (0-255 / 0-1).
* Supports: #rgb, #rrggbb, #rrggbbaa, rgb(), rgba(), hsl(), hsla(), oklch().
*/
export function parseColor(str) {
str = str.trim()
// hex
if (str.startsWith('#'))
return parseHex(str)
// rgb / rgba
const rgbArgs = extractFunctionArgs(str, ['rgb', 'rgba'])
if (rgbArgs)
return parseRgbArgs(rgbArgs)
// hsl / hsla → convert to rgb internally
const hslArgs = extractFunctionArgs(str, ['hsl', 'hsla'])
if (hslArgs)
return parseHslArgs(hslArgs)
// oklch → convert to rgb via OKLab → linear RGB → sRGB
const oklchArgs = extractFunctionArgs(str, ['oklch'])
if (oklchArgs)
return parseOklchArgs(oklchArgs)
throw new Error(`Unsupported color format: "${str}"`)
}
function extractFunctionArgs(str, names) {
const lower = str.toLowerCase()
for (const name of names) {
const prefix = `${name}(`
if (lower.startsWith(prefix) && str.endsWith(')'))
return str.slice(prefix.length, -1).trim()
}
return null
}
function parseHex(hex) {
hex = hex.replace('#', '')
if (hex.length === 3)
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
if (hex.length === 4)
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]
const r = Number.parseInt(hex.slice(0, 2), 16)
const g = Number.parseInt(hex.slice(2, 4), 16)
const b = Number.parseInt(hex.slice(4, 6), 16)
const a = hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1
return { r, g, b, a }
}
function parseRgbArgs(args) {
// Handles both comma-separated and space-separated (with optional / alpha)
// Channels can be 0-255 or 0%-100% (100% = 255)
const parts = args.split(/[\s,/]+/).map(s => s.trim()).filter(Boolean)
const r = parseRgbChannel(parts[0])
const g = parseRgbChannel(parts[1])
const b = parseRgbChannel(parts[2])
const a = parts[3] !== undefined ? parseAlpha(parts[3]) : 1
return { r, g, b, a }
}
function parseRgbChannel(v) {
if (v.endsWith('%'))
return Math.round(Number.parseFloat(v) / 100 * 255)
return Number.parseFloat(v)
}
function parseHslArgs(args) {
// H: angle — deg (default), rad, grad, turn
// S, L: percentage (the % sign is required per spec but we tolerate bare numbers)
const parts = args.split(/[\s,/]+/).map(s => s.trim()).filter(Boolean)
let h = Number.parseFloat(parts[0])
h = parseAngle(parts[0], h)
const s = Number.parseFloat(parts[1]) / 100
const l = Number.parseFloat(parts[2]) / 100
const a = parts[3] !== undefined ? parseAlpha(parts[3]) : 1
const { r, g, b } = hslToRgbRaw(h, s, l)
return { r, g, b, a }
}
function parseOklchArgs(args) {
// CSS oklch: oklch(L C H) or oklch(L C H / alpha)
// L: 0-1 or 0%-100% (100% = 1)
// C: 0-0.4 or 0%-100% (100% = 0.4, per CSS Color 4)
// H: angle — deg (default), rad, grad, turn
const parts = args.split(/[\s,/]+/).map(s => s.trim()).filter(Boolean)
let l = Number.parseFloat(parts[0])
if (parts[0].endsWith('%'))
l /= 100
l = Math.min(Math.max(l, 0), 1)
let c = Number.parseFloat(parts[1])
if (parts[1]?.endsWith('%'))
c = c / 100 * 0.4 // CSS Color 4: 100% chroma = 0.4
let h = Number.parseFloat(parts[2]) || 0
h = parseAngle(parts[2], h)
const a = parts[3] !== undefined ? parseAlpha(parts[3]) : 1
const { r, g, b } = oklchToSrgb(l, c, h)
return { r, g, b, a }
}
function parseAlpha(v) {
if (v.endsWith('%'))
return Number.parseFloat(v) / 100
return Number.parseFloat(v)
}
/**
* Parse CSS angle units. Returns degrees.
* Supports: deg (default), rad, grad, turn.
* Note: 'grad' must be checked before 'rad' since 'grad'.endsWith('rad') === true.
*/
function parseAngle(raw, numericValue) {
if (!raw)
return numericValue
if (raw.endsWith('grad'))
return numericValue * 0.9
if (raw.endsWith('rad'))
return numericValue * 180 / Math.PI
if (raw.endsWith('turn'))
return numericValue * 360
// 'deg' suffix or bare number — already in degrees
return numericValue
}
// ─── OKLCH ↔ sRGB conversion chain ─────────────────────────────────
//
// OKLCH ↔ OKLab ↔ LMS (cube) ↔ Linear RGB ↔ sRGB (gamma)
//
// Reference: Björn Ottosson — https://bottosson.github.io/posts/oklab/
/**
* OKLCH → sRGB { r, g, b } (0-255).
* Uses CSS Color 4 gamut mapping: binary-search chroma reduction in OKLCH
* to preserve lightness and hue. Falls back to simple clamp only for
* achromatic colors or near-boundary values.
*
* @param {number} L Lightness 0-1
* @param {number} C Chroma ≥ 0
* @param {number} H Hue 0-360
*/
function oklchToSrgb(L, C, H) {
// Fast path: achromatic or no chroma
if (C <= 0 || Number.isNaN(H))
return oklabToLinearSrgb_gammaEncode(L, 0, 0)
// Try direct conversion first
const direct = oklchToLinearSrgbChannels(L, C, H)
if (isInGamut(direct))
return gammaEncodeRgb(direct)
// CSS Color 4 gamut mapping: binary search on chroma, keep L and H fixed.
// Tolerance: ΔE_ok < 0.002 (spec uses 0.02 in deltaEOK, ≈ 0.002 in raw OKLab)
const EPSILON = 0.001
let lo = 0
let hi = C
let mapped = direct
while (hi - lo > EPSILON) {
const mid = (lo + hi) / 2
mapped = oklchToLinearSrgbChannels(L, mid, H)
if (isInGamut(mapped))
lo = mid
else
hi = mid
}
// Use the last in-gamut result (lo), clamp rounding errors
mapped = oklchToLinearSrgbChannels(L, lo, H)
return gammaEncodeRgb(mapped)
}
/** Check if linear RGB channels are within [0, 1] gamut (with tiny tolerance). */
function isInGamut({ lr, lg, lb }) {
const E = 0.000075 // tolerance for floating point
return lr >= -E && lr <= 1 + E
&& lg >= -E && lg <= 1 + E
&& lb >= -E && lb <= 1 + E
}
/** OKLCH → raw linear sRGB channels (may be out of gamut). */
function oklchToLinearSrgbChannels(L, C, H) {
const hRad = H * Math.PI / 180
const a = C * Math.cos(hRad)
const b = C * Math.sin(hRad)
// OKLab → LMS (cube root form)
const l_ = L + 0.3963377774 * a + 0.2158037573 * b
const m_ = L - 0.1055613458 * a - 0.0638541728 * b
const s_ = L - 0.0894841775 * a - 1.2914855480 * b
const l = l_ * l_ * l_
const m = m_ * m_ * m_
const s = s_ * s_ * s_
return {
lr: +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
lg: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
lb: -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
}
}
/** Gamma-encode and clamp linear RGB channels → sRGB { r, g, b } 0-255. */
function gammaEncodeRgb({ lr, lg, lb }) {
return {
r: clamp(Math.round(linearToGamma(clamp(lr, 0, 1)) * 255), 0, 255),
g: clamp(Math.round(linearToGamma(clamp(lg, 0, 1)) * 255), 0, 255),
b: clamp(Math.round(linearToGamma(clamp(lb, 0, 1)) * 255), 0, 255),
}
}
/**
* OKLab → sRGB { r, g, b } (0-255). Direct conversion with gamma encode + clamp.
* Used internally for achromatic fast path.
*/
function oklabToLinearSrgb_gammaEncode(L) {
// For achromatic, a=0 b=0 so we just need L → LMS → linear RGB
const l_ = L
const m_ = L
const s_ = L
const l = l_ * l_ * l_
const m = m_ * m_ * m_
const s = s_ * s_ * s_
return gammaEncodeRgb({
lr: +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
lg: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
lb: -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
})
}
/**
* sRGB { r, g, b } (0-255) → OKLab { L, a, b }.
*/
function srgbToOklab(r, g, b) {
// sRGB → Linear sRGB
const lr = gammaToLinear(r / 255)
const lg = gammaToLinear(g / 255)
const lb = gammaToLinear(b / 255)
// Linear sRGB → LMS
const l = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb
const m = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb
const s = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb
// Cube root
const l_ = Math.cbrt(l)
const m_ = Math.cbrt(m)
const s_ = Math.cbrt(s)
// LMS → OKLab
return {
L: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
}
}
/**
* sRGB { r, g, b } (0-255) → OKLCH { l: 0-1, c: ≥0, h: 0-360 }.
*/
export function rgbToOklch(r, g, b) {
const { L, a, b: b_ } = srgbToOklab(r, g, b)
const c = Math.sqrt(a * a + b_ * b_)
let h = Math.atan2(b_, a) * 180 / Math.PI
if (h < 0)
h += 360
return {
l: round(L, 4),
c: round(c, 4),
h: round(h, 2),
}
}
/**
* OKLCH { l, c, h } → sRGB { r, g, b } (0-255).
*/
export function oklchToRgb(l, c, h) {
return oklchToSrgb(l, c, h)
}
/** sRGB gamma encode (linear → gamma). */
function linearToGamma(x) {
if (x <= 0)
return 0
if (x >= 1)
return 1
return x <= 0.0031308
? 12.92 * x
: 1.055 * (x ** (1 / 2.4)) - 0.055
}
/** sRGB gamma decode (gamma → linear). */
function gammaToLinear(x) {
if (x <= 0)
return 0
if (x >= 1)
return 1
return x <= 0.04045
? x / 12.92
: ((x + 0.055) / 1.055) ** 2.4
}
// ─── Conversions ────────────────────────────────────────────────────
/**
* RGB (0-255) → HSL { h: 0-360, s: 0-100, l: 0-100 }
*/
export function rgbToHsl(r, g, b) {
r /= 255
g /= 255
b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const d = max - min
const l = (max + min) / 2
let h = 0
let s = 0
if (d !== 0) {
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6
break
case g:
h = ((b - r) / d + 2) / 6
break
case b:
h = ((r - g) / d + 4) / 6
break
}
}
return {
h: round(h * 360, 1),
s: round(s * 100, 1),
l: round(l * 100, 1),
}
}
/**
* HSL (h: 0-360, s: 0-1, l: 0-1) → RGB { r, g, b } (0-255)
*/
function hslToRgbRaw(h, s, l) {
h = ((h % 360) + 360) % 360
const c = (1 - Math.abs(2 * l - 1)) * s
const x = c * (1 - Math.abs((h / 60) % 2 - 1))
const m = l - c / 2
let r, g, b
if (h < 60)
[r, g, b] = [c, x, 0]
else if (h < 120)
[r, g, b] = [x, c, 0]
else if (h < 180)
[r, g, b] = [0, c, x]
else if (h < 240)
[r, g, b] = [0, x, c]
else if (h < 300)
[r, g, b] = [x, 0, c]
else [r, g, b] = [c, 0, x]
return {
r: Math.round((r + m) * 255),
g: Math.round((g + m) * 255),
b: Math.round((b + m) * 255),
}
}
export function hslToRgb(h, s, l) {
return hslToRgbRaw(h, s / 100, l / 100)
}
/**
* HSL → hex string "#rrggbb"
*/
export function hslToHex(h, s, l) {
const { r, g, b } = hslToRgb(h, s, l)
return `#${[r, g, b].map(c => c.toString(16).padStart(2, '0')).join('')}`
}
// ─── Format helpers ─────────────────────────────────────────────────
/**
* Any CSS color string → shadcn bare HSL format "H S% L%"
* e.g. "222.2 84% 4.9%"
*/
export function toShadcnHsl(color) {
const { r, g, b } = parseColor(color)
const { h, s, l } = rgbToHsl(r, g, b)
return `${h} ${s}% ${l}%`
}
/**
* Any CSS color string → { h, s, l, a } (h: 0-360, s: 0-100, l: 0-100)
*/
export function toHsl(color) {
const { r, g, b, a } = parseColor(color)
const { h, s, l } = rgbToHsl(r, g, b)
return { h, s, l, a }
}
/**
* HSL object → shadcn bare format "H S% L%"
*/
export function hslToShadcn({ h, s, l }) {
return `${round(h, 1)} ${round(s, 1)}% ${round(l, 1)}%`
}
/**
* HSL object → hex "#rrggbb"
*/
export function hslObjToHex({ h, s, l }) {
return hslToHex(h, s, l)
}
// ─── OKLCH format helpers ───────────────────────────────────────────
/**
* Any CSS color string → OKLCH { l: 0-1, c: ≥0, h: 0-360, a: 0-1 }
*/
export function toOklch(color) {
const { r, g, b, a } = parseColor(color)
const oklch = rgbToOklch(r, g, b)
return { ...oklch, a }
}
/**
* Any CSS color string → CSS oklch() string "oklch(L C H)"
*/
export function toOklchCss(color) {
const { l, c, h, a } = toOklch(color)
const base = `oklch(${round(l, 4)} ${round(c, 4)} ${round(h, 2)})`
if (a < 1)
return `oklch(${round(l, 4)} ${round(c, 4)} ${round(h, 2)} / ${round(a, 3)})`
return base
}
/**
* OKLCH object → CSS oklch() string
*/
export function oklchToCss({ l, c, h, a }) {
if (a !== undefined && a < 1)
return `oklch(${round(l, 4)} ${round(c, 4)} ${round(h, 2)} / ${round(a, 3)})`
return `oklch(${round(l, 4)} ${round(c, 4)} ${round(h, 2)})`
}
/**
* OKLCH object → hex "#rrggbb"
*/
export function oklchToHex({ l, c, h }) {
const { r, g, b } = oklchToRgb(l, c, h)
return `#${[r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')}`
}
/**
* HSL object { h, s, l } → OKLCH { l, c, h }
*/
export function hslToOklch({ h, s, l }) {
const { r, g, b } = hslToRgb(h, s, l)
return rgbToOklch(r, g, b)
}
/**
* OKLCH { l, c, h } → HSL { h, s, l } (all 0-360/0-100 range)
*/
export function oklchToHsl({ l, c, h }) {
const { r, g, b } = oklchToRgb(l, c, h)
return rgbToHsl(r, g, b)
}
// ─── Color manipulation ─────────────────────────────────────────────
/**
* Adjust lightness: positive = lighter, negative = darker.
* Clamps 0-100.
*/
export function adjustLightness(hsl, amount) {
return { ...hsl, l: clamp(hsl.l + amount, 0, 100) }
}
/**
* Adjust saturation: positive = more saturated, negative = less.
* Clamps 0-100.
*/
export function adjustSaturation(hsl, amount) {
return { ...hsl, s: clamp(hsl.s + amount, 0, 100) }
}
/**
* Mix two HSL colors by ratio (0 = all colorA, 1 = all colorB).
*/
export function mixHsl(a, b, ratio = 0.5) {
return {
h: lerpHue(a.h, b.h, ratio),
s: lerp(a.s, b.s, ratio),
l: lerp(a.l, b.l, ratio),
a: lerp(a.a ?? 1, b.a ?? 1, ratio),
}
}
/**
* Mix two colors in OKLCH space (perceptually uniform).
* Inputs/outputs are OKLCH { l, c, h }.
*/
export function mixOklch(a, b, ratio = 0.5) {
return {
l: lerp(a.l, b.l, ratio),
c: lerp(a.c, b.c, ratio),
h: lerpHue(a.h, b.h, ratio),
a: lerp(a.a ?? 1, b.a ?? 1, ratio),
}
}
/**
* Adjust OKLCH lightness: positive = lighter, negative = darker.
* Clamps 0-1.
*/
export function adjustOklchLightness(oklch, amount) {
return { ...oklch, l: clamp(oklch.l + amount, 0, 1) }
}
/**
* Adjust OKLCH chroma: positive = more vivid, negative = more muted.
* Clamps ≥ 0.
*/
export function adjustOklchChroma(oklch, amount) {
return { ...oklch, c: Math.max(0, oklch.c + amount) }
}
// ─── WCAG Contrast ──────────────────────────────────────────────────
/**
* Relative luminance from HSL (WCAG 2.1 definition).
*/
export function luminance(hsl) {
const { r, g, b } = hslToRgb(hsl.h, hsl.s, hsl.l)
const [rs, gs, bs] = [r, g, b].map((c) => {
c /= 255
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
})
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs
}
/**
* WCAG contrast ratio between two HSL colors. Returns 1–21.
*/
export function contrastRatio(a, b) {
const la = luminance(a)
const lb = luminance(b)
const lighter = Math.max(la, lb)
const darker = Math.min(la, lb)
return (lighter + 0.05) / (darker + 0.05)
}
/**
* Check if contrast meets WCAG AA (4.5:1 for normal text, 3:1 for large text).
*/
export function meetsAA(fg, bg, largeText = false) {
return contrastRatio(fg, bg) >= (largeText ? 3 : 4.5)
}
/**
* Check if contrast meets WCAG AAA (7:1 for normal text, 4.5:1 for large text).
*/
export function meetsAAA(fg, bg, largeText = false) {
return contrastRatio(fg, bg) >= (largeText ? 4.5 : 7)
}
// ─── Internal helpers ───────────────────────────────────────────────
function round(n, decimals = 1) {
const f = 10 ** decimals
return Math.round(n * f) / f
}
function clamp(v, min, max) {
return Math.min(max, Math.max(min, v))
}
function lerp(a, b, t) {
return a + (b - a) * t
}
/**
* Interpolate hue on the shortest arc.
*/
function lerpHue(a, b, t) {
let diff = b - a
if (diff > 180)
diff -= 360
if (diff < -180)
diff += 360
return ((a + diff * t) % 360 + 360) % 360
}
@@ -0,0 +1,338 @@
/**
* Dark / light scheme generator.
*
* Takes a palette token map (from palette.mjs) and produces the opposite
* scheme. Preserves hue identity, adjusts lightness/saturation, and
* validates WCAG contrast.
*
* Strategy:
* Light → Dark: backgrounds go very dark, text goes light, brand adjusts for contrast
* Dark → Light: backgrounds go very light, text goes dark, brand adjusts for contrast
*/
import {
adjustLightness,
contrastRatio,
hslToRgb,
meetsAA,
rgbToHsl,
toHsl,
} from './color.mjs'
import { generatePalette } from './palette.mjs'
// ─── Main entry ─────────────────────────────────────────────────────
/**
* Given a token map (Record<string, "H S% L%">), generate the opposite scheme.
*
* @param {Record<string, string>} tokens - Token map from generatePalette()
* @param {'light'|'dark'} sourceMode - What mode the input tokens represent
* @returns {Record<string, string>} Token map for the opposite mode
*/
export function generateOppositeScheme(tokens, sourceMode) {
const bg = parseShadcnHsl(tokens.background)
const fg = parseShadcnHsl(tokens.foreground)
const brand = parseShadcnHsl(tokens.primary)
const targetIsLight = sourceMode === 'dark'
// Core strategy: flip lightness around the midpoint, keep hue + saturation close
const newBg = flipForBackground(bg, targetIsLight)
const newFg = flipForForeground(fg, targetIsLight, newBg)
const newBrand = adjustBrandForScheme(brand, targetIsLight, newBg)
// Generate full palette from derived key colors.
// Signal & content tokens are not passed — the opposite scheme
// only emits them if the source registry provided them.
return generatePalette({
background: hslToHex(newBg),
foreground: hslToHex(newFg),
brand: hslToHex(newBrand),
})
}
/**
* Generate both light and dark from any single scheme.
*
* @param {object} keyColors - { background, foreground, brand, ... }
* @returns {{ light: Record<string, string>, dark: Record<string, string> }}
*/
export function generateBothSchemes(keyColors) {
const bg = toHsl(keyColors.background)
const sourceIsLight = bg.l > 50
const primary = generatePalette(keyColors)
let opposite
if (keyColors.dark && keyColors.dark.background && sourceIsLight) {
// Explicit dark palette provided — use directly instead of algorithmic flip.
// Missing fields fall back to light-side values.
opposite = generatePalette({
background: keyColors.dark.background,
foreground: keyColors.dark.foreground || keyColors.foreground,
brand: keyColors.dark.brand || keyColors.brand,
...(keyColors.dark.surface && { surface: keyColors.dark.surface }),
...((keyColors.dark.secondaryText || keyColors.secondaryText) && {
secondaryText: keyColors.dark.secondaryText || keyColors.secondaryText,
}),
...((keyColors.dark.border) && { border: keyColors.dark.border }),
...((keyColors.dark.error || keyColors.error) && {
error: keyColors.dark.error || keyColors.error,
}),
...((keyColors.dark.link || keyColors.link) && {
link: keyColors.dark.link || keyColors.link,
}),
...((keyColors.dark.brandForeground || keyColors.brandForeground) && {
brandForeground: keyColors.dark.brandForeground || keyColors.brandForeground,
}),
...((keyColors.dark.ring) && { ring: keyColors.dark.ring }),
...((keyColors.dark.info || keyColors.info) && {
info: keyColors.dark.info || keyColors.info,
}),
...((keyColors.dark.success || keyColors.success) && {
success: keyColors.dark.success || keyColors.success,
}),
...((keyColors.dark.warning || keyColors.warning) && {
warning: keyColors.dark.warning || keyColors.warning,
}),
...((keyColors.dark.highlight || keyColors.highlight) && {
highlight: keyColors.dark.highlight || keyColors.highlight,
}),
...((keyColors.dark.diffAdded || keyColors.diffAdded) && {
diffAdded: keyColors.dark.diffAdded || keyColors.diffAdded,
}),
...((keyColors.dark.diffRemoved || keyColors.diffRemoved) && {
diffRemoved: keyColors.dark.diffRemoved || keyColors.diffRemoved,
}),
...(keyColors.fonts && { fonts: keyColors.fonts }),
})
}
else {
opposite = generateOppositeScheme(primary, sourceIsLight ? 'light' : 'dark')
}
// Shared tokens are mode-independent — copy from primary to opposite
for (const key of ['font-sans', 'font-mono', 'font-serif', 'radius']) {
if (primary[key] && !opposite[key])
opposite[key] = primary[key]
}
return sourceIsLight
? { light: primary, dark: opposite }
: { light: opposite, dark: primary }
}
// ─── Lightness flipping ─────────────────────────────────────────────
/**
* Flip background lightness for the target scheme.
*/
function flipForBackground(bg, targetIsLight) {
if (targetIsLight) {
// Dark → Light: target L around 97-100, keep hue, reduce saturation slightly
return {
h: bg.h,
s: Math.max(bg.s * 0.6, 0),
l: mapRange(bg.l, 0, 20, 96, 100),
}
}
else {
// Light → Dark: target L around 4-10, keep hue, reduce saturation
return {
h: bg.h,
s: Math.max(bg.s * 0.8, 0),
l: mapRange(bg.l, 90, 100, 4, 10),
}
}
}
/**
* Flip foreground lightness for the target scheme.
*/
function flipForForeground(fg, targetIsLight, newBg) {
let newFg
if (targetIsLight) {
// Dark → Light: target L around 5-15
newFg = {
h: fg.h,
s: Math.min(fg.s * 1.3, 100),
l: mapRange(fg.l, 85, 100, 4, 12),
}
}
else {
// Light → Dark: target L around 90-98
newFg = {
h: fg.h,
s: Math.max(fg.s * 0.7, 0),
l: mapRange(fg.l, 0, 15, 92, 98),
}
}
// Ensure sufficient contrast
if (!meetsAA(newFg, newBg)) {
newFg = targetIsLight
? adjustLightness(newFg, -10)
: adjustLightness(newFg, 10)
}
return newFg
}
/**
* Adjust brand color for the target scheme — preserve identity, ensure contrast.
*/
function adjustBrandForScheme(brand, targetIsLight, newBg) {
let adjusted = { ...brand }
if (targetIsLight) {
// For light scheme, brand may need to be darker for contrast
if (adjusted.l > 60)
adjusted.l = 45
}
else {
// For dark scheme, brand may need to be lighter for visibility
if (adjusted.l < 40)
adjusted.l = 55
}
// Fine-tune for contrast against background
const cr = contrastRatio(adjusted, newBg)
if (cr < 3) {
adjusted = targetIsLight
? adjustLightness(adjusted, -15)
: adjustLightness(adjusted, 15)
}
return adjusted
}
// ─── Contrast validation report ─────────────────────────────────────
/**
* Validate a token map for WCAG contrast on all critical pairs.
*
* Each check has a severity:
* 'error' — text readability, will cause real accessibility issues
* 'warn' — best-practice, may not cause issues if supplemented (hover, underline)
*
* Returns an array of { pair, ratio, passAA, threshold, severity } objects.
*/
export function validateContrast(tokens) {
// [fgKey, bgKey, label, threshold, severity]
const pairs = [
// Hard: text readability
['foreground', 'background', 'Primary text', 4.5, 'error'],
['muted-foreground', 'background', 'Secondary text', 4.5, 'error'],
['primary-foreground', 'primary', 'Button text', 4.5, 'error'],
['accent-foreground', 'accent', 'Accent text', 4.5, 'error'],
['destructive-foreground', 'destructive', 'Error text', 4.5, 'error'],
['popover-foreground', 'popover', 'Popover text', 4.5, 'error'],
['link', 'background', 'Link on background', 4.5, 'error'],
// Soft: supplementary / non-text
['link', 'foreground', 'Link vs body text', 3, 'warn'],
['border', 'background', 'Border visibility', 3, 'warn'],
]
const bgL = parseShadcnHsl(tokens.background).l
const isLight = bgL > 50
// Semantic colors as text on REAL admonition header background:
// alphaBlend(semanticColor, muted, alpha) where alpha = 0.06 (light) / 0.12 (dark)
if (tokens.muted) {
const muted = parseShadcnHsl(tokens.muted)
const alpha = isLight ? 0.06 : 0.12
for (const key of ['info', 'success', 'warning']) {
if (!tokens[key])
continue
const semColor = parseShadcnHsl(tokens[key])
const headerBg = alphaBlendHsl(semColor, muted, alpha)
const ratio = contrastRatio(semColor, headerBg)
pairs.push([key, null, `${key} on header bg`, 4.5, 'error', ratio, headerBg])
}
}
return pairs.map(([fgKey, bgKey, label, threshold, severity, preRatio]) => {
let ratio
if (preRatio !== undefined) {
ratio = preRatio
}
else {
const fg = parseShadcnHsl(tokens[fgKey])
const bg = parseShadcnHsl(tokens[bgKey])
ratio = contrastRatio(fg, bg)
}
return {
pair: label,
fg: fgKey,
bg: bgKey,
ratio: Math.round(ratio * 100) / 100,
passAA: ratio >= threshold,
passAAA: ratio >= (threshold === 3 ? 4.5 : 7),
threshold,
severity,
}
})
}
// ─── Helpers ────────────────────────────────────────────────────────
/** Alpha-blend fg over bg in RGB space (same as CSS alpha compositing). */
function alphaBlendHsl(fg, bg, alpha) {
const f = hslToRgb(fg.h, fg.s, fg.l)
const b = hslToRgb(bg.h, bg.s, bg.l)
return rgbToHsl(
Math.round(f.r * alpha + b.r * (1 - alpha)),
Math.round(f.g * alpha + b.g * (1 - alpha)),
Math.round(f.b * alpha + b.b * (1 - alpha)),
)
}
/**
* Parse shadcn bare HSL "H S% L%" back to { h, s, l }.
*/
function parseShadcnHsl(str) {
const parts = str.match(/[\d.]+/g)
if (!parts || parts.length < 3)
throw new Error(`Invalid shadcn HSL: "${str}"`)
return {
h: Number.parseFloat(parts[0]),
s: Number.parseFloat(parts[1]),
l: Number.parseFloat(parts[2]),
}
}
/**
* HSL object → hex (for feeding back to generatePalette).
*/
function hslToHex({ h, s, l }) {
// Import would create circular dep, inline the conversion
s /= 100
l /= 100
const c = (1 - Math.abs(2 * l - 1)) * s
const x = c * (1 - Math.abs((h / 60) % 2 - 1))
const m = l - c / 2
let r, g, b
if (h < 60)
[r, g, b] = [c, x, 0]
else if (h < 120)
[r, g, b] = [x, c, 0]
else if (h < 180)
[r, g, b] = [0, c, x]
else if (h < 240)
[r, g, b] = [0, x, c]
else if (h < 300)
[r, g, b] = [x, 0, c]
else [r, g, b] = [c, 0, x]
const toHex = v => Math.round((v + m) * 255).toString(16).padStart(2, '0')
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
/**
* Map a value from one range to another, clamped.
*/
function mapRange(value, inMin, inMax, outMin, outMax) {
const clamped = Math.min(Math.max(value, inMin), inMax)
const ratio = (clamped - inMin) / (inMax - inMin)
return outMin + ratio * (outMax - outMin)
}
@@ -0,0 +1,318 @@
#!/usr/bin/env node
/**
* Extract expanded registry fields from awesome-design-md preview HTML files.
*
* Reads preview.html and preview-dark.html for each theme to extract:
* - dark section (background, foreground, surface, secondaryText, border)
* - error color
* - brandForeground (CTA button text)
* - ring color
*
* Usage:
* node scripts/theme-gen/extract-registry.mjs --source ../awesome-design-md/design-md
* node scripts/theme-gen/extract-registry.mjs --source ../awesome-design-md/design-md --only claude
* node scripts/theme-gen/extract-registry.mjs --source ../awesome-design-md/design-md --dry-run
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { contrastRatio, toHsl } from './color.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url))
// ─── CLI args ───────────────────────────────────────────────────────
const args = process.argv.slice(2)
const opts = {}
for (let i = 0; i < args.length; i++) {
if (args[i].startsWith('--')) {
const key = args[i].slice(2)
const next = args[i + 1]
if (!next || next.startsWith('--')) {
opts[key] = true
}
else {
opts[key] = next
i++
}
}
}
const sourceDir = opts.source || resolve(__dirname, '../../../awesome-design-md/design-md')
const registryPath = resolve(__dirname, 'registry.json')
const dryRun = opts['dry-run'] || false
const onlyId = opts.only || null
// ─── Parse CSS variables from HTML ──────────────────────────────────
function extractCssVars(html) {
const vars = {}
for (const declaration of html.split(';')) {
const tokenStart = declaration.lastIndexOf('--')
if (tokenStart === -1)
continue
const chunk = declaration.slice(tokenStart).trim()
const colonIndex = chunk.indexOf(':')
if (colonIndex === -1)
continue
const key = chunk.slice(2, colonIndex).trim()
const val = chunk.slice(colonIndex + 1).trim()
if (!key || !val)
continue
vars[key] = val
}
return vars
}
/**
* Check if a CSS color value is opaque (no alpha channel < 1).
*/
function isOpaque(val) {
if (val.startsWith('#')) {
// #rrggbbaa — check if alpha < ff
if (val.length === 9)
return val.slice(7).toLowerCase() === 'ff'
if (val.length === 5)
return val[4].toLowerCase() === 'f'
return true
}
const alphaValue = extractFunctionalAlpha(val)
if (alphaValue != null) {
const a = Number.parseFloat(alphaValue)
return a >= 0.9
}
return true
}
function extractFunctionalAlpha(val) {
const openIndex = val.indexOf('(')
const closeIndex = val.lastIndexOf(')')
if (openIndex === -1 || closeIndex === -1 || closeIndex <= openIndex)
return null
const inner = val.slice(openIndex + 1, closeIndex).trim()
if (!inner)
return null
if (inner.includes('/'))
return inner.split('/').pop()?.trim() ?? null
const parts = inner.split(',').map(part => part.trim()).filter(Boolean)
return parts.length === 4 ? parts[3] : null
}
/**
* Find a color value by trying multiple candidate variable names.
* Only returns opaque solid colors (skips rgba with alpha < 0.9).
*/
function findColor(vars, ...candidates) {
for (const name of candidates) {
const val = vars[name]
if (!val)
continue
if ((val.startsWith('#') || val.startsWith('rgb') || val.startsWith('hsl')) && isOpaque(val))
return val
}
return null
}
/**
* Extract CTA button text color from inline styles in HTML.
* Looks for buttons with the brand background color and extracts their text color.
*/
function findBrandForeground(html, brandColor) {
if (!brandColor)
return null
const brandHex = brandColor.toLowerCase()
// Look for button/CTA styles with brand background
// Pattern: background: <brand>; color: <fg>;
const brandBgRe = new RegExp(
`style="[^"]*background:\\s*(?:var\\(--[^)]*\\)|${brandHex.replace('#', '#?')})[^"]*color:\\s*(#[\\da-f]{3,8})`,
'gi',
)
// Try matching brand background specifically
const brandBgMatch = brandBgRe.exec(html)
if (brandBgMatch)
return brandBgMatch[1]
// Fallback: look for nav-cta or btn-brand class button colors
const ctaRe = /\.(?:nav-cta|btn-brand|btn-primary)\s*\{[^}]*color:\s*(?:var\(--([\w-]+)\)|(#[\da-f]{3,8}))/gi
let m
while ((m = ctaRe.exec(html)) !== null) {
if (m[2])
return m[2]
if (m[1]) {
const vars = extractCssVars(html)
const resolved = vars[m[1]]
if (resolved && resolved.startsWith('#'))
return resolved
}
}
return null
}
/**
* Extract ring/focus color from CSS variables.
*/
function findRing(vars) {
// Try specific ring variables
const ring = findColor(vars, 'color-ring-warm', 'color-ring', 'ring-warm', 'ring', 'shadow-ring-color', 'focus-ring')
if (ring)
return ring
// Try parsing ring from shadow-ring value: "rgba(...) 0px 0px 0px 1px" or "color 0px 0px 0px 1px"
for (const key of ['shadow-ring', 'shadow-ring-light']) {
const val = vars[key]
if (!val)
continue
const hexMatch = val.match(/(#[\da-f]{3,8})/i)
if (hexMatch && isOpaque(hexMatch[1]))
return hexMatch[1]
const rgbMatch = val.match(/(rgb\([^)]+\))/)
if (rgbMatch && isOpaque(rgbMatch[1]))
return rgbMatch[1]
// Skip rgba with alpha — those are overlays, not solid ring colors
}
return null
}
// ─── Main ───────────────────────────────────────────────────────────
const registry = JSON.parse(readFileSync(registryPath, 'utf-8'))
let updated = 0
let skipped = 0
for (const entry of registry) {
if (onlyId && entry.id !== onlyId)
continue
// Skip Claude — already manually updated
if (entry.id === 'claude') {
skipped++
continue
}
const themeDir = resolve(sourceDir, entry.source)
let lightHtml, darkHtml
try {
lightHtml = readFileSync(resolve(themeDir, 'preview.html'), 'utf-8')
darkHtml = readFileSync(resolve(themeDir, 'preview-dark.html'), 'utf-8')
}
catch {
console.log(` skip ${entry.id} (no preview files at ${entry.source})`)
skipped++
continue
}
const lightVars = extractCssVars(lightHtml)
const darkVars = extractCssVars(darkHtml)
const changes = []
// ── radius ──
if (!entry.radius) {
// Extract from card class border-radius (best proxy for container panels)
const cardRadiusMatch = lightHtml.match(/\.card\s*\{[^}]*border-radius:\s*([\d.]+)px/i)
|| lightHtml.match(/\.card[^{]*\{[^}]*border-radius:\s*([\d.]+)px/i)
if (cardRadiusMatch) {
const px = Number.parseFloat(cardRadiusMatch[1])
// Only accept reasonable container radii (4-20px)
if (px >= 4 && px <= 20) {
const rem = Math.round(px / 16 * 1000) / 1000
entry.radius = `${rem}rem`
changes.push(`radius=${px}px`)
}
}
}
// ── error ──
if (!entry.colors.error) {
const error = findColor(lightVars, 'color-error', 'error', 'neg', 'danger', 'destructive', 'color-danger', 'color-destructive', 'red', 'color-red')
if (error) {
entry.colors.error = error
changes.push(`error=${error}`)
}
}
// ── brandForeground ──
if (!entry.colors.brandForeground) {
const brandFg = findBrandForeground(lightHtml, entry.colors.brand)
if (brandFg) {
// Sanity check: must have reasonable contrast against brand (≥ 2:1)
try {
const cr = contrastRatio(toHsl(brandFg), toHsl(entry.colors.brand))
if (cr >= 2) {
entry.colors.brandForeground = brandFg
changes.push(`brandFg=${brandFg}`)
}
}
catch { /* skip unparseable */ }
}
}
// ── ring ──
if (!entry.colors.ring) {
const ring = findRing(lightVars)
if (ring) {
entry.colors.ring = ring
changes.push(`ring=${ring}`)
}
}
// ── dark section ──
if (!entry.dark) {
const dark = {}
const darkBg = findColor(darkVars, 'bg-page', 'bg', 'color-bg', 'background')
const darkFg = findColor(darkVars, 'text-primary', 'color-foreground', 'fg', 'white')
const darkSurface = findColor(darkVars, 'bg-card', 'surface', 'bg-surface', 'bg-panel', 'card')
const darkSecondary = findColor(darkVars, 'text-secondary', 'secondary', 'color-secondary')
const darkBorder = findColor(darkVars, 'border-color', 'border', 'border-subtle', 'color-border')
const darkError = findColor(darkVars, 'color-error', 'error', 'neg', 'danger', 'destructive')
const darkRing = findRing(darkVars)
if (darkBg)
dark.background = darkBg
if (darkFg)
dark.foreground = darkFg
if (darkSurface)
dark.surface = darkSurface
if (darkSecondary)
dark.secondaryText = darkSecondary
if (darkBorder)
dark.border = darkBorder
if (darkError && darkError !== entry.colors.error)
dark.error = darkError
if (darkRing)
dark.ring = darkRing
// Only add dark section if we got at least background
if (dark.background) {
entry.dark = dark
changes.push(`dark={bg:${dark.background} fg:${dark.foreground || '-'} surface:${dark.surface || '-'} border:${dark.border || '-'}}`)
}
}
if (changes.length > 0) {
const native = entry.native === 'dark' ? '◐' : '◑'
console.log(` ${native} ${entry.id.padEnd(15)} ${changes.join(', ')}`)
updated++
}
else {
skipped++
}
}
console.log(`\n${updated} updated, ${skipped} skipped`)
if (!dryRun) {
writeFileSync(registryPath, `${JSON.stringify(registry, null, 2)}\n`)
console.log(`Written → ${registryPath}`)
}
else {
console.log('(dry run — no files written)')
}
@@ -0,0 +1,389 @@
/**
* Layered palette generator.
*
* From a small set of key colors extracted from a DESIGN.md, derive the full
* --ms-* token map that markstream-vue expects.
*
* Input (all optional except background + foreground + brand):
* { background, foreground, brand, surface?, secondaryText?, border? }
*
* Output: { light: { ...tokens }, dark: { ...tokens } }
* where each token value is a shadcn bare HSL string "H S% L%".
*/
import {
adjustLightness,
contrastRatio,
hslToShadcn,
mixHsl,
toHsl,
} from './color.mjs'
// ─── Main entry ─────────────────────────────────────────────────────
/**
* Generate the full --ms-* token set from key colors + typography.
*
* @param {object} input
* @param {string} input.background - Page background (hex/css)
* @param {string} input.foreground - Primary text (hex/css)
* @param {string} input.brand - Brand / accent color (hex/css)
* @param {string} [input.surface] - Card / surface background
* @param {string} [input.secondaryText] - Secondary text color
* @param {string} [input.border] - Border color
* @param {string} [input.error] - Error / destructive color
* @param {string} [input.link] - Link color
* @param {string} [input.brandForeground] - Text on brand buttons (overrides auto)
* @param {string} [input.ring] - Focus ring color
* @param {string} [input.info] - Info / note semantic color
* @param {string} [input.success] - Success / tip semantic color
* @param {string} [input.warning] - Warning semantic color
* @param {string} [input.highlight] - Highlight / mark color
* @param {string} [input.diffAdded] - Diff added color
* @param {string} [input.diffRemoved] - Diff removed color
* @param {string} [input.radius] - Default component border-radius
* @param {object} [input.fonts] - Typography overrides
* @param {string} [input.fonts.sans] - Sans-serif font stack
* @param {string} [input.fonts.mono] - Monospace font stack
* @param {string} [input.fonts.serif] - Serif font stack (for heading variants)
* @returns {Record<string, string>} Map of token name → value
*/
export function generatePalette(input) {
const bg = toHsl(input.background)
const fg = toHsl(input.foreground)
const brand = toHsl(input.brand)
const isLight = bg.l > 50
const surface = input.surface ? toHsl(input.surface) : deriveSurface(bg, isLight)
// User-provided values pass through unchanged (trust the designer).
// Only derived values get contrast-corrected.
const secondaryText = input.secondaryText
? toHsl(input.secondaryText)
: deriveSecondaryText(fg, bg, isLight)
const border = input.border
? toHsl(input.border)
: deriveBorder(bg, fg, isLight)
const error = input.error ? toHsl(input.error) : deriveError(isLight)
const link = input.link ? toHsl(input.link) : deriveLink(brand, bg, fg, isLight)
// ── Bridge tokens (shadcn-compatible) ──
// muted must be computed before semantic colors (used for admonition header bg)
const muted = deriveMuted(bg, fg, isLight)
const mutedFg = secondaryText
const secondary = surface
const secondaryFg = isLight
? adjustLightness(fg, 10)
: adjustLightness(fg, -10)
const accent = deriveAccent(bg, brand, isLight)
const accentFg = isLight
? adjustLightness(fg, 5)
: adjustLightness(fg, -5)
const primary = brand
const primaryFg = input.brandForeground
? toHsl(input.brandForeground)
: derivePrimaryForeground(brand)
const destructive = error
const destructiveFg = derivePrimaryForeground(error)
const ring = input.ring
? toHsl(input.ring)
: fg
const popover = bg
const popoverFg = fg
// ── Extension tokens (markstream-specific) ──
// Signal & content colors are only emitted when explicitly provided.
// When absent, the library defaults in index.css apply.
const extensionTokens = {}
if (input.info) {
const c = toHsl(input.info)
extensionTokens.info = c
extensionTokens['info-foreground'] = derivePrimaryForeground(c)
}
if (input.success) {
const c = toHsl(input.success)
extensionTokens.success = c
extensionTokens['success-foreground'] = derivePrimaryForeground(c)
}
if (input.warning) {
const c = toHsl(input.warning)
extensionTokens.warning = c
extensionTokens['warning-foreground'] = derivePrimaryForeground(c)
}
if (input.diffAdded)
extensionTokens['diff-added'] = toHsl(input.diffAdded)
if (input.diffRemoved)
extensionTokens['diff-removed'] = toHsl(input.diffRemoved)
if (input.highlight) {
const c = toHsl(input.highlight)
extensionTokens.highlight = c
extensionTokens['highlight-foreground'] = derivePrimaryForeground(c)
}
const colorTokens = toTokenMap({
// Bridge
'background': bg,
'foreground': fg,
'muted': muted,
'muted-foreground': mutedFg,
'secondary': secondary,
'secondary-foreground': secondaryFg,
'accent': accent,
'accent-foreground': accentFg,
'primary': primary,
'primary-foreground': primaryFg,
'destructive': destructive,
'destructive-foreground': destructiveFg,
'border': border,
'ring': ring,
'popover': popover,
'popover-foreground': popoverFg,
'radius': null, // not a color — handled separately
// Extension (pass-through only)
...extensionTokens,
'link': link,
})
// Font tokens (not color, pass through as-is)
const fonts = input.fonts || {}
if (fonts.sans)
colorTokens['font-sans'] = fonts.sans
if (fonts.mono)
colorTokens['font-mono'] = fonts.mono
if (fonts.serif)
colorTokens['font-serif'] = fonts.serif
// Radius token
if (input.radius)
colorTokens.radius = input.radius
return colorTokens
}
// ─── Core contrast helper ───────────────────────────────────────────
/**
* Iteratively adjust lightness until the color meets a target contrast ratio
* against a reference color. Preserves hue and saturation.
*
* @param {object} color - HSL color to adjust
* @param {object} reference - HSL color to measure contrast against
* @param {number} target - Minimum contrast ratio (e.g. 4.5 for AA)
* @param {boolean} darken - Direction: true = make darker, false = make lighter
* @returns {object} Adjusted HSL color guaranteed to meet target (or at limit)
*/
function ensureContrast(color, reference, target, darken) {
// Add small epsilon to avoid floating-point near-misses at the boundary
const effectiveTarget = target + 0.02
// Coarse pass: step by 1 to find the region
let c = { ...color }
const coarseStep = darken ? -1 : 1
for (let i = 0; i < 100; i++) {
if (contrastRatio(c, reference) >= effectiveTarget)
return c
c = adjustLightness(c, coarseStep)
if (c.l <= 0 || c.l >= 100)
return c
}
return c
}
// ─── Derivation helpers ─────────────────────────────────────────────
/**
* Derive surface color: slightly offset from background.
*/
function deriveSurface(bg, isLight) {
return isLight
? adjustLightness(bg, -3)
: adjustLightness(bg, 5)
}
/**
* Derive muted background: between bg and fg, close to bg side.
*/
function deriveMuted(bg, fg, isLight) {
return isLight
? adjustLightness(bg, -4)
: adjustLightness(bg, 8)
}
/**
* Derive secondary text: mix fg/bg, then iterate to guarantee ≥ 4.5:1 against bg.
*/
function deriveSecondaryText(fg, bg, isLight) {
const seed = mixHsl(fg, bg, 0.35)
return ensureContrast(seed, bg, 4.5, isLight)
}
/**
* Derive border: must meet WCAG non-text contrast (≥ 3:1) against background.
* Starts from a subtle mix, then pushes until 3:1 is reached.
*/
function deriveBorder(bg, fg, isLight) {
const seed = isLight ? mixHsl(bg, fg, 0.1) : mixHsl(bg, fg, 0.12)
return ensureContrast(seed, bg, 3, isLight)
}
/**
* Derive accent: bg tinted with brand, subtle.
*/
function deriveAccent(bg, brand, isLight) {
return isLight
? mixHsl(bg, brand, 0.06)
: mixHsl(bg, brand, 0.1)
}
/**
* Derive error color (default red if not provided).
*/
function deriveError(isLight) {
return isLight
? { h: 0, s: 84.2, l: 60.2 }
: { h: 0, s: 62.8, l: 30.6 }
}
/**
* Derive link color from brand. Targets:
* 1. ≥ 4.5:1 against background (AA text — hard requirement)
* 2. ≥ 3:1 against foreground (color-only link distinguishability — best-effort)
*
* Sweeps H/S/L to find the best feasible link. If both constraints are
* mathematically impossible (e.g. very dark fg + very light bg with blue hue),
* prioritizes bg contrast and maximizes fg contrast within that constraint.
*/
function deriveLink(brand, bg, fg, isLight) {
const h = brand.h
const isBlueish = h >= 180 && h <= 290
const seedH = isBlueish ? brand.h : 212
const seedS = isBlueish ? Math.min(brand.s, 100) : 100
const seedL = isLight ? 38 : 68
// Sweep S and L. Two-tier strategy:
// Tier 1: find candidates meeting BOTH bg≥4.5 AND fg≥3, pick closest to seed.
// Tier 2: if none found, pick the L closest to seed that meets bg≥4.5.
// (fg shortfall will be honestly reported by validateContrast)
let tier1 = null
let tier1Dist = Infinity
let tier2 = null
let tier2Dist = Infinity
// Always sweep nearby hues — even blue brands may need a hue shift for contrast
const hueSet = new Set([seedH])
for (let dh = -15; dh <= 15; dh += 5) {
hueSet.add(((seedH + dh) % 360 + 360) % 360)
}
const hues = [...hueSet]
for (const sh of hues) {
for (let s = seedS; s >= 40; s -= 5) {
for (let l = 0; l <= 100; l++) {
const candidate = { h: sh, s, l }
const crBg = contrastRatio(candidate, bg)
if (crBg < 4.5)
continue
const dist = Math.abs(l - seedL) + Math.abs(s - seedS) * 0.1 + Math.abs(sh - seedH) * 0.2
// Tier 2: any bg-passing candidate
if (dist < tier2Dist) {
tier2Dist = dist
tier2 = candidate
}
// Tier 1: also meets fg≥3
const crFg = contrastRatio(candidate, fg)
if (crFg >= 3 && dist < tier1Dist) {
tier1Dist = dist
tier1 = candidate
}
}
}
}
return tier1 || tier2 || ensureContrast({ h: seedH, s: seedS, l: seedL }, bg, 4.5, isLight)
}
/**
* Derive primary foreground: white or dark text depending on brand lightness.
* Tries tinted white/dark first for warmth, falls back to pure white/black.
* Always picks whichever option has the best contrast.
*/
function derivePrimaryForeground(brand) {
const candidates = [
{ h: 210, s: 40, l: 98 }, // tinted white
{ h: 222.2, s: 47.4, l: 11.2 }, // tinted dark
{ h: 0, s: 0, l: 100 }, // pure white
{ h: 0, s: 0, l: 0 }, // pure black
]
let best = candidates[0]
let bestCr = 0
for (const c of candidates) {
const cr = contrastRatio(c, brand)
if (cr > bestCr) {
bestCr = cr
best = c
}
}
return best
}
// ─── Output formatting ──────────────────────────────────────────────
/**
* Convert named HSL objects to { tokenName: "H S% L%" } map.
*/
function toTokenMap(obj) {
const result = {}
for (const [key, val] of Object.entries(obj)) {
if (val === null)
continue
result[key] = hslToShadcn(val)
}
return result
}
// ─── Token metadata (for documentation / validation) ────────────────
/**
* All bridge token names that map to shadcn variables.
*/
export const BRIDGE_TOKENS = [
'background',
'foreground',
'muted',
'muted-foreground',
'secondary',
'secondary-foreground',
'accent',
'accent-foreground',
'primary',
'primary-foreground',
'destructive',
'destructive-foreground',
'border',
'ring',
'popover',
'popover-foreground',
]
/**
* All extension token names specific to markstream-vue.
* Signal & content tokens are optional — only emitted when the registry provides them.
* When absent, the library defaults in src/index.css apply.
*/
export const EXTENSION_TOKENS = [
'info',
'info-foreground',
'success',
'success-foreground',
'warning',
'warning-foreground',
'diff-added',
'diff-removed',
'highlight',
'highlight-foreground',
'link',
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Theme Registry",
"description": "Curated design tokens extracted from DESIGN.md files for markstream-vue theme generation.",
"type": "array",
"items": {
"type": "object",
"required": ["id", "name", "source", "colors"],
"properties": {
"id": {
"type": "string",
"description": "Kebab-case identifier, used as CSS data-theme value and filename",
"pattern": "^[a-z0-9-]+$"
},
"name": {
"type": "string",
"description": "Display name (e.g. 'Claude', 'Linear')"
},
"source": {
"type": "string",
"description": "Origin DESIGN.md directory name in awesome-design-md"
},
"category": {
"type": "string",
"enum": ["ai", "devtool", "infra", "design", "fintech", "enterprise"],
"description": "Classification for documentation"
},
"native": {
"type": "string",
"enum": ["light", "dark"],
"description": "The scheme the source design is built around. The opposite scheme will be auto-generated."
},
"colors": {
"type": "object",
"required": ["background", "foreground", "brand"],
"properties": {
"background": { "type": "string", "description": "Page background" },
"foreground": { "type": "string", "description": "Primary text color" },
"brand": { "type": "string", "description": "Primary brand / accent / CTA color" },
"surface": { "type": "string", "description": "Card / elevated surface background" },
"secondaryText": { "type": "string", "description": "Secondary / muted text color" },
"border": { "type": "string", "description": "Default border color" },
"error": { "type": "string", "description": "Error / destructive color" },
"link": { "type": "string", "description": "Link text color" },
"brandForeground": { "type": "string", "description": "Text on brand/CTA buttons (overrides auto contrast pick)" },
"ring": { "type": "string", "description": "Focus ring color" },
"info": { "type": "string", "description": "Info / note semantic color" },
"success": { "type": "string", "description": "Success / tip semantic color" },
"warning": { "type": "string", "description": "Warning semantic color" },
"highlight": { "type": "string", "description": "Highlight / mark color" },
"diffAdded": { "type": "string", "description": "Diff added indicator color" },
"diffRemoved": { "type": "string", "description": "Diff removed indicator color" }
},
"additionalProperties": false
},
"radius": {
"type": "string",
"description": "Default component border-radius (CSS value, e.g. '0.5rem', '6px')"
},
"dark": {
"type": "object",
"description": "Explicit dark-scheme colors. When dark.background is present, used instead of auto-generating opposite scheme.",
"properties": {
"background": { "type": "string" },
"foreground": { "type": "string" },
"brand": { "type": "string" },
"surface": { "type": "string" },
"secondaryText": { "type": "string" },
"border": { "type": "string" },
"error": { "type": "string" },
"link": { "type": "string" },
"brandForeground": { "type": "string" },
"ring": { "type": "string" },
"info": { "type": "string" },
"success": { "type": "string" },
"warning": { "type": "string" },
"highlight": { "type": "string" },
"diffAdded": { "type": "string" },
"diffRemoved": { "type": "string" }
},
"additionalProperties": false
},
"fonts": {
"type": "object",
"properties": {
"sans": { "type": "string", "description": "Sans-serif font stack (CSS font-family value)" },
"mono": { "type": "string", "description": "Monospace font stack" },
"serif": { "type": "string", "description": "Serif font stack (for heading variants)" }
},
"additionalProperties": false
}
},
"additionalProperties": false
}
}
@@ -0,0 +1,125 @@
#!/usr/bin/env node
/**
* Extracts language-relevant SVGs from the material-icon-theme npm package
* and writes them to src/icon-themes/material/svg/.
*
* Usage: node scripts/vendor-material-icons.mjs
*/
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, '..')
const SOURCE = join(ROOT, 'node_modules/material-icon-theme/icons')
const TARGET = join(ROOT, 'src/icon-themes/material/svg')
// Mapping: our language identifier → material-icon-theme filename (without .svg)
const ICON_MAP = {
// Core languages (~27)
javascript: 'javascript',
typescript: 'typescript',
jsx: 'react',
tsx: 'react_ts',
html: 'html',
css: 'css',
scss: 'sass',
json: 'json',
python: 'python',
ruby: 'ruby',
go: 'go',
java: 'java',
kotlin: 'kotlin',
c: 'c',
cpp: 'cpp',
csharp: 'csharp',
php: 'php',
shell: 'console',
powershell: 'powershell',
sql: 'database',
yaml: 'yaml',
markdown: 'markdown',
xml: 'xml',
rust: 'rust',
vue: 'vue',
text: 'document',
plain: 'document',
// Extended languages (~33)
ada: 'ada',
applescript: 'applescript',
assembly: 'assembly',
clojure: 'clojure',
cobol: 'cobol',
crystal: 'crystal',
dart: 'dart',
dlang: 'd',
docker: 'docker',
elixir: 'elixir',
erlang: 'erlang',
fortran: 'fortran',
groovy: 'groovy',
haskell: 'haskell',
julia: 'julia',
lisp: 'lisp',
lua: 'lua',
nim: 'nim',
objectivec: 'c',
objectivecpp: 'cpp',
ocaml: 'ocaml',
perl: 'perl',
prolog: 'prolog',
r: 'r',
scala: 'scala',
solidity: 'solidity',
svelte: 'svelte',
svg: 'xml',
swift: 'swift',
terraform: 'terraform',
vbnet: 'csharp',
mermaid: 'markdown', // no dedicated mermaid icon, use markdown
}
// Ensure target directory exists
mkdirSync(TARGET, { recursive: true })
let copied = 0
let missing = 0
for (const [lang, materialName] of Object.entries(ICON_MAP)) {
const srcFile = join(SOURCE, `${materialName}.svg`)
const dstFile = join(TARGET, `${lang}.svg`)
if (!existsSync(srcFile)) {
console.warn(` MISS: ${lang} → ${materialName}.svg not found`)
missing++
continue
}
// Read SVG and ensure width/height attributes exist for proper inline rendering
const svg = readFileSync(srcFile, 'utf-8')
writeFileSync(dstFile, svg)
copied++
}
// Also copy a generic code icon for fallback
const fallbackSrc = join(SOURCE, 'console.svg')
const fallbackDst = join(TARGET, '_fallback.svg')
if (existsSync(fallbackSrc)) {
let svg = readFileSync(fallbackSrc, 'utf-8')
// Only check the <svg> opening tag for width attribute
const svgTag = svg.match(/<svg[^>]*>/)?.[0] ?? ''
if (!svgTag.includes(' width=')) {
svg = svg.replace('<svg ', '<svg width="16" height="16" ')
}
writeFileSync(fallbackDst, svg)
}
// Copy license
const licenseSrc = join(ROOT, 'node_modules/material-icon-theme/LICENSE')
if (existsSync(licenseSrc)) {
copyFileSync(licenseSrc, join(dirname(TARGET), 'LICENSE-material-icon-theme'))
}
console.log(`\nDone: ${copied} icons copied, ${missing} missing`)
console.log(`Target: ${TARGET}`)