diff --git a/bin/capture-readme-playground.mjs b/bin/capture-readme-playground.mjs new file mode 100644 index 000000000..284f5b872 --- /dev/null +++ b/bin/capture-readme-playground.mjs @@ -0,0 +1,306 @@ +/* global liquidjs */ +import { spawn, execFile } from 'node:child_process' +import { access, copyFile, mkdir, rm } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { chromium } from 'playwright' + +const execFileAsync = promisify(execFile) +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const docsDir = join(root, 'docs') +const framesDir = join(root, '.local/playground-gif-frames') +const outPath = join(docsDir, 'source/playground-demo.gif') +const url = 'http://127.0.0.1:4001/playground.html' + +const CAPTURE_WIDTH = 980 +const VIEWPORT_WIDTH = 1100 +const VIEWPORT_HEIGHT = 1000 +const DEVICE_SCALE = 2 +const FPS = 12 +const LAST_FRAME_HOLD = 2.5 +const ANIM_PHASE_MS = 425 +const FRAMES_PER_KEY = 2 + +const LINE_HEIGHT = 22 +const PANE_HEAD = 36 +const GRID_GAP = 12 +const EDITOR_PAD = 16 + +function run (cmd, args, opts = {}) { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: 'inherit', shell: true, ...opts }) + child.on('error', reject) + child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} exited ${code}`)))) + }) +} + +async function waitForServer (maxMs = 120000) { + const start = Date.now() + while (Date.now() - start < maxMs) { + try { + const res = await fetch(url) + if (res.ok) return + } catch {} + await new Promise((resolve) => setTimeout(resolve, 500)) + } + throw new Error(`Timed out waiting for ${url}`) +} + +async function ensureDocsDeps () { + try { + await access(join(docsDir, 'node_modules/hexo/package.json')) + } catch { + await run('npm', ['ci'], { cwd: docsDir }) + } +} + +async function prepareCapture (page, { fullTemplate, fullContext }) { + await page.addStyleTag({ + content: ` + #playground .capture-pane { + margin: 0; + box-sizing: border-box; + font: 14px/1.55 "Source Code Pro", ui-monospace, Monaco, Menlo, Consolas, monospace; + white-space: pre-wrap; + overflow: hidden; + color: #24292f; + background: transparent; + } + #playground .capture-cursor { + color: #24292f; + } + #playground .capture-json .json-key { color: #953800; } + #playground .capture-json .json-string { color: #cf222e; } + ` + }) + + await page.evaluate((opts) => { + const { + captureWidth, + fullTemplate, + fullContext, + lineHeight, + paneHead, + gridGap, + editorPad + } = opts + + function paneHeight (lines) { + return paneHead + editorPad * 2 + lines * lineHeight + 4 + } + + function escapeHtml (str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + } + + function highlightJson (text) { + return escapeHtml(text) + .replace(/"([^"]+)":/g, '"$1":') + .replace(/: "([^"]*)"/g, ': "$1"') + } + + function mountPane (editorEl, className, html) { + const paneBody = editorEl.closest('.pane-body') + editorEl.remove() + const pre = document.createElement('pre') + pre.className = 'capture-pane ' + className + pre.style.flex = '1 1 auto' + pre.style.minHeight = '0' + pre.style.margin = '0' + if (html) pre.innerHTML = html + paneBody.appendChild(pre) + return pre + } + + function setIndicator (area, state) { + const el = document.querySelector(area + ' .pane-indicator') + if (el) el.dataset.state = state + } + + document.querySelector('#playground .playground-hero')?.remove() + document.querySelector('.loader')?.remove() + document.querySelector('#playground > .wrapper')?.style.setProperty('margin-bottom', '0') + document.body.style.background = '#fff' + document.querySelector('#playground').style.background = '#fff' + document.documentElement.style.colorScheme = 'light' + + window.__captureEngine = new liquidjs.Liquid({ memoryLimit: 1e5, renderLimit: 1e5 }) + window.__captureContext = JSON.parse(fullContext) + window.__captureLastHtml = '' + window.__captureSetIndicators = setIndicator + + const templateLineCount = fullTemplate.split('\n').length + const contextLineCount = fullContext.split('\n').length + const templateEditorH = paneHeight(templateLineCount) + const contextEditorH = paneHeight(contextLineCount) + const rightCol = templateEditorH + gridGap + contextEditorH + + const editors = document.querySelector('#editors') + editors.classList.remove('inner', 'hide') + editors.style.margin = '0 auto' + editors.style.width = captureWidth + 'px' + editors.style.maxWidth = captureWidth + 'px' + + const workspace = document.querySelector('.playground-workspace') + workspace.style.height = rightCol + 'px' + workspace.style.minHeight = rightCol + 'px' + workspace.style.maxHeight = rightCol + 'px' + workspace.style.overflow = 'visible' + workspace.style.gridTemplateRows = `${templateEditorH}px ${contextEditorH}px` + + document.querySelector('.area-tpl').style.height = templateEditorH + 'px' + document.querySelector('.area-data').style.height = contextEditorH + 'px' + document.querySelector('.area-output').style.height = rightCol + 'px' + + window.__captureTpl = mountPane(document.querySelector('#editorEl'), 'capture-liquid', '') + window.__captureCtx = mountPane(document.querySelector('#dataEl'), 'capture-json', highlightJson(fullContext)) + window.__captureOut = mountPane(document.querySelector('#previewEl'), 'capture-output', '') + + setIndicator('.area-tpl', 'idle') + setIndicator('.area-data', 'idle') + setIndicator('.area-output', 'idle') + }, { + captureWidth: CAPTURE_WIDTH, + fullTemplate, + fullContext, + lineHeight: LINE_HEIGHT, + paneHead: PANE_HEAD, + gridGap: GRID_GAP, + editorPad: EDITOR_PAD + }) + + await page.waitForTimeout(200) +} + +async function setCaptureFrame (page, templateText, { settled = false } = {}) { + await page.evaluate(async ({ tpl, settled }) => { + function escapeHtml (str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + } + + const setIndicator = window.__captureSetIndicators + if (settled) { + setIndicator('.area-tpl', 'idle') + setIndicator('.area-data', 'idle') + setIndicator('.area-output', 'ok') + } else { + setIndicator('.area-tpl', 'active') + setIndicator('.area-data', 'idle') + setIndicator('.area-output', 'pending') + } + + window.__captureTpl.innerHTML = + escapeHtml(tpl) + (settled ? '' : '') + + let html = window.__captureLastHtml || '' + try { + html = await window.__captureEngine.parseAndRender(tpl, window.__captureContext) + if (html !== '' || !window.__captureLastHtml) { + window.__captureLastHtml = html + } + } catch {} + + window.__captureOut.textContent = window.__captureLastHtml || html + }, { tpl: templateText, settled }) +} + +async function screenshotWorkspace (page, file) { + await page.locator('.playground-workspace').screenshot({ + path: file, + type: 'png', + scale: 'device' + }) +} + +async function encodeGif () { + await execFileAsync('ffmpeg', [ + '-y', + '-framerate', String(FPS), + '-i', join(framesDir, 'frame-%03d.png'), + '-vf', `tpad=stop_mode=clone:stop_duration=${LAST_FRAME_HOLD},split[s0][s1];[s0]palettegen=max_colors=256:stats_mode=full[p];[s1][p]paletteuse=dither=floyd_steinberg`, + '-loop', '0', + outPath + ], { stdio: 'inherit' }) +} + +async function captureFrame (page, framesDir, framePaths, frameIndexRef, templateText, opts) { + await setCaptureFrame(page, templateText, opts) + for (let phase = 0; phase < FRAMES_PER_KEY; phase++) { + if (phase > 0) await page.waitForTimeout(ANIM_PHASE_MS) + const file = join(framesDir, `frame-${String(frameIndexRef.value).padStart(3, '0')}.png`) + framePaths.push(file) + await screenshotWorkspace(page, file) + frameIndexRef.value++ + } +} + +async function main () { + await run('npm', ['run', 'build:docs-liquid'], { cwd: root }) + await run('npm', ['run', 'build:contributors'], { cwd: root }) + await ensureDocsDeps() + await rm(framesDir, { recursive: true, force: true }) + await mkdir(framesDir, { recursive: true }) + + const server = spawn('npx', ['hexo', 'server', '-p', '4001'], { + cwd: docsDir, + shell: true, + stdio: 'ignore' + }) + + try { + await waitForServer() + + const browser = await chromium.launch() + const page = await browser.newPage({ + viewport: { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT }, + deviceScaleFactor: DEVICE_SCALE + }) + await page.emulateMedia({ colorScheme: 'light' }) + await page.goto(url, { waitUntil: 'networkidle' }) + + const allowCookies = page.getByRole('button', { name: 'Allow all cookies' }) + if (await allowCookies.count()) { + await allowCookies.click() + } + + await page.waitForSelector('#editors:not(.hide)', { timeout: 60000 }) + await page.waitForFunction(() => { + return window.ace?.edit?.('editorEl')?.getValue().includes('name | capitalize') + }) + + const fullTemplate = await page.evaluate(() => window.ace.edit('editorEl').getValue()) + const fullContext = await page.evaluate(() => window.ace.edit('dataEl').getValue()) + + await prepareCapture(page, { fullTemplate, fullContext }) + + const framePaths = [] + const frameIndexRef = { value: 0 } + + for (let i = 0; i <= fullTemplate.length; i++) { + const partial = fullTemplate.slice(0, i) + const settled = i === fullTemplate.length + await captureFrame(page, framesDir, framePaths, frameIndexRef, partial, { settled }) + } + + await encodeGif() + await mkdir(join(root, '.local'), { recursive: true }) + await copyFile(framePaths[framePaths.length - 1], join(root, '.local/playground-gif-last-frame.png')) + await browser.close() + console.log(`Wrote ${outPath} (${framePaths.length} frames @ ${FPS} fps)`) + } finally { + server.kill() + await rm(framesDir, { recursive: true, force: true }) + } +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/docs/source/playground-demo.gif b/docs/source/playground-demo.gif index 19802b5bf..56e351ff9 100644 Binary files a/docs/source/playground-demo.gif and b/docs/source/playground-demo.gif differ diff --git a/package.json b/package.json index 6bd02c13a..9a1bfd6d1 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "build:changelog": "node bin/build-changelog.js", "build:docs-hexo": "cd docs && npm ci && npm run build && shx cp CNAME public/", "serve:docs": "cd docs && npm run start", - "dev:docs": "run-s prepare:docs serve:docs" + "dev:docs": "run-s prepare:docs serve:docs", + "capture:readme-playground": "node bin/capture-readme-playground.mjs" }, "bin": { "liquidjs": "./bin/liquid.js",