docs(readme): playground GIF, used-by grid, and docs homepage sync

Add data/used-by.json with build:used-by for README and docs homepage, playground demo capture, and shared home-section layout. Used by lists products with site logos; Financial Support keeps org and individual sponsors.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Yang Jun
2026-06-22 19:54:46 +08:00
co-authored by Cursor
parent 18258eedc3
commit ff52a00ac4
19 changed files with 719 additions and 140 deletions
+115
View File
@@ -0,0 +1,115 @@
const fs = require('fs')
const path = require('path')
const root = path.resolve(__dirname, '..')
const readmePath = path.join(root, 'README.md')
const dataPath = path.join(root, 'data/used-by.json')
const outDir = path.join(root, 'docs/themes/navy/layout/partial')
const LINK_STYLE = 'display: inline-block; vertical-align: middle; margin: 8px;'
const IMG_STYLE = 'vertical-align: middle;'
const PER_ROW = 7
function escapeHtml (str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function websiteFavicon (url) {
return 'https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url='
+ encodeURIComponent(url) + '&size=128'
}
async function githubAvatar (url) {
try {
const { hostname, pathname } = new URL(url)
if (hostname !== 'github.com') return
const owner = pathname.split('/').filter(Boolean)[0]
if (!owner) return
const res = await fetch(`https://api.github.com/users/${encodeURIComponent(owner)}`, {
headers: { 'User-Agent': 'liquidjs-build-used-by' }
})
if (!res.ok) return
const data = await res.json()
if (!data.avatar_url) return
const base = data.avatar_url.split('?')[0]
return `${base}?s=128`
} catch {}
}
async function resolveLogo (entry) {
if (entry.logo) return entry.logo
const gh = await githubAvatar(entry.url)
if (gh) return gh
return websiteFavicon(entry.url)
}
function imgTag ({ name, src, width, height, imgStyle }) {
const alt = escapeHtml(name)
const href = escapeHtml(src)
const dims = []
if (width) dims.push(`width="${width}"`)
if (height) dims.push(`height="${height}"`)
else if (!width) dims.push('height="80"')
const style = imgStyle ? `${IMG_STYLE}${imgStyle}` : IMG_STYLE
return `<img src="${href}" ${dims.join(' ')} style="${style}" alt="${alt}" title="${alt}"/>`
}
function linkTag (entry, logo) {
const href = escapeHtml(entry.url)
const img = imgTag({
name: entry.name,
src: logo,
width: entry.width,
height: entry.height,
imgStyle: entry.imgStyle
})
return ` <a href="${href}" style="${LINK_STYLE}">${img}</a>`
}
async function renderHtml (entries) {
const logos = await Promise.all(entries.map(resolveLogo))
const links = entries.map((entry, i) => linkTag(entry, logos[i]))
const lines = ['<p align="center" style="line-height: 2.5;">']
links.forEach((link, i) => {
if (i > 0 && i % PER_ROW === 0 && links.length - i > 2) lines.push(' <br/>')
lines.push(link)
})
lines.push('</p>')
return lines.join('\n')
}
function transformUsedBy (html) {
return html
.replace(/<br \/>.*?<\/td>/g, '</a></td>')
.replace(/\n/g, '')
.replace(/<\/tr>\s*<tr>/g, '')
}
function patchReadme (html) {
const readme = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n')
const begin = '<!-- USED-BY-BEGIN -->'
const end = '<!-- USED-BY-END -->'
const re = new RegExp(`${begin}[\\s\\S]*?${end}`)
if (!re.test(readme)) {
throw new Error(`README.md is missing ${begin}${end}`)
}
const next = readme.replace(re, `${begin}\n${html}\n${end}`)
fs.writeFileSync(readmePath, next)
}
const entries = JSON.parse(fs.readFileSync(dataPath, 'utf8'))
async function main () {
const html = await renderHtml(entries)
patchReadme(html)
fs.writeFileSync(path.join(outDir, 'used-by.swig'), transformUsedBy(html))
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
+331
View File
@@ -0,0 +1,331 @@
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, '.tmp/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 CHAR_MS = 55
const LINE_HEIGHT = 21
const WINDOW_CHROME = 36
const GRID_GAP = 16
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((r) => setTimeout(r, 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, fullHtml }) {
await page.addStyleTag({
content: `
#editors .capture-pane {
margin: 0;
box-sizing: border-box;
font: 14px/1.5 "Source Code Pro", ui-monospace, Monaco, Menlo, Consolas, monospace;
white-space: pre;
overflow: hidden;
color: #24292f;
background: #fff;
}
#editors .capture-cursor {
color: #24292f;
}
#editors .capture-json .json-key { color: #953800; }
#editors .capture-json .json-string { color: #cf222e; }
`
})
await page.evaluate((opts) => {
const {
captureWidth,
fullTemplate,
fullContext,
fullHtml,
lineHeight,
windowChrome,
gridGap,
editorPad
} = opts
function paneHeight (lines) {
return windowChrome + editorPad * 2 + lines * lineHeight + 4
}
function escapeHtml (str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
function highlightJson (text) {
return escapeHtml(text)
.replace(/"([^"]+)":/g, '<span class="json-key">"$1":</span>')
.replace(/: "([^"]*)"/g, ': <span class="json-string">"$1"</span>')
}
function mountPane (shell, className, html) {
shell.innerHTML = ''
shell.style.paddingTop = '0'
shell.style.overflow = 'hidden'
const pre = document.createElement('pre')
pre.className = 'capture-pane ' + className
pre.style.height = '100%'
pre.style.padding = `${editorPad}px`
if (html) pre.innerHTML = html
shell.appendChild(pre)
return pre
}
document.querySelector('#playground .version')?.remove()
document.querySelector('#playground h1')?.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'
window.__captureEngine = new liquidjs.Liquid({ memoryLimit: 1e5, renderLimit: 1e5 })
window.__captureContext = JSON.parse(fullContext)
window.__captureLastHtml = ''
const templateLineCount = fullTemplate.split('\n').length
const contextLineCount = fullContext.split('\n').length
const outputLineCount = Math.max(fullHtml.split('\n').length, 1)
const templateEditorH = paneHeight(templateLineCount)
const contextEditorH = paneHeight(contextLineCount)
const outputEditorH = paneHeight(outputLineCount)
const leftRow1 = templateEditorH
const leftRow2 = contextEditorH
const rightCol = leftRow1 + gridGap + leftRow2
const gridH = rightCol
const editors = document.querySelector('#editors')
editors.classList.remove('inner')
editors.style.margin = '0 auto'
editors.style.width = captureWidth + 'px'
editors.style.maxWidth = captureWidth + 'px'
editors.style.height = gridH + 'px'
editors.style.minHeight = gridH + 'px'
editors.style.overflow = 'visible'
editors.style.display = 'grid'
editors.style.gridTemplateColumns = '1fr 1fr'
editors.style.gridTemplateRows = `${leftRow1}px ${leftRow2}px`
editors.style.gridGap = gridGap + 'px'
editors.style.alignItems = 'start'
document.querySelectorAll('.editor-wrapper').forEach((wrapper) => {
wrapper.style.overflow = 'visible'
wrapper.style.minHeight = '0'
})
document.querySelector('.area-output').style.gridRow = '1 / -1'
document.querySelector('.area-output').style.gridColumn = '2'
const tplShell = document.querySelector('#editorEl')
const ctxShell = document.querySelector('#dataEl')
const tplPane = tplShell.closest('.pane-window')
const ctxPane = ctxShell.closest('.pane-window')
tplShell.closest('.editor-wrapper').style.height = templateEditorH + 'px'
ctxShell.closest('.editor-wrapper').style.height = contextEditorH + 'px'
tplPane.style.height = templateEditorH + 'px'
ctxPane.style.height = contextEditorH + 'px'
tplShell.style.height = (templateEditorH - windowChrome) + 'px'
ctxShell.style.height = (contextEditorH - windowChrome) + 'px'
window.__captureTpl = mountPane(tplShell, 'capture-liquid', '')
window.__captureCtx = mountPane(ctxShell, 'capture-json', highlightJson(fullContext))
const code = document.querySelector('#previewCode')
code.textContent = ''
if (window.Prism) {
delete code.dataset.highlighted
window.Prism.highlightElement(code)
}
const outputPane = document.querySelector('.area-output .pane-window')
const outputShell = document.querySelector('.output-preview')
const outputWrap = document.querySelector('.area-output')
outputWrap.style.height = rightCol + 'px'
outputPane.style.height = rightCol + 'px'
outputShell.style.height = (rightCol - windowChrome) + 'px'
outputShell.style.flex = 'none'
outputShell.style.overflow = 'hidden'
outputShell.style.paddingTop = '0'
const pre = outputShell.querySelector('pre.highlight')
pre.style.minHeight = '0'
pre.style.height = '100%'
pre.style.padding = editorPad + 'px'
pre.style.margin = '0'
pre.style.boxSizing = 'border-box'
pre.style.border = 'none'
pre.style.boxShadow = 'none'
}, {
captureWidth: CAPTURE_WIDTH,
fullTemplate,
fullContext,
fullHtml,
lineHeight: LINE_HEIGHT,
windowChrome: WINDOW_CHROME,
gridGap: GRID_GAP,
editorPad: EDITOR_PAD
})
await page.waitForTimeout(200)
}
async function setCaptureFrame (page, templateText) {
await page.evaluate(async (tpl) => {
function escapeHtml (str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
window.__captureTpl.innerHTML =
escapeHtml(tpl) + '<span class="capture-cursor">▎</span>'
let html = window.__captureLastHtml || ''
try {
html = await window.__captureEngine.parseAndRender(tpl, window.__captureContext)
window.__captureLastHtml = html
} catch {}
const code = document.querySelector('#previewCode')
code.textContent = html
if (window.Prism) {
delete code.dataset.highlighted
window.Prism.highlightElement(code)
}
}, templateText)
}
async function screenshotEditors (page, file) {
await page.locator('#editors').screenshot({
path: file,
animations: 'disabled',
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 main () {
await run('npm', ['run', 'build:docs-liquid'], { cwd: root })
await run('npm', ['run', 'build:contributors'], { cwd: root })
await run('npm', ['run', 'build:used-by'], { 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())
const fullHtml = await page.evaluate(() => document.querySelector('#previewCode').textContent)
await prepareCapture(page, { fullTemplate, fullContext, fullHtml })
const framePaths = []
let frameIndex = 0
for (let i = 0; i <= fullTemplate.length; i++) {
const partial = fullTemplate.slice(0, i)
await setCaptureFrame(page, partial)
await page.waitForTimeout(CHAR_MS)
const file = join(framesDir, `frame-${String(frameIndex).padStart(3, '0')}.png`)
framePaths.push(file)
await screenshotEditors(page, file)
frameIndex++
}
await encodeGif()
await mkdir(join(root, '.tmp'), { recursive: true })
await copyFile(framePaths[framePaths.length - 1], join(root, '.tmp/playground-gif-last-frame.png'))
await browser.close()
console.log(`Wrote ${outPath} (${framePaths.length} frames @ ${FPS} fps, char typing + live output)`)
} finally {
server.kill()
await rm(framesDir, { recursive: true, force: true })
}
}
main().catch((err) => {
console.error(err)
process.exit(1)
})