name: Vim Theme Preview on: pull_request: paths: - 'home/.vimrc' permissions: contents: write pull-requests: write jobs: preview: name: Generate theme preview runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - name: Install Playwright run: | npm install playwright npx playwright install chromium --with-deps - name: Generate HTML preview and take screenshots uses: actions/github-script@v7 with: script: | const fs = require('fs'); // === GENERATE HTML PREVIEW === const vimrc = fs.readFileSync('home/.vimrc', 'utf8'); // Parse highlight definitions const highlights = {}; const highlightRegex = /^highlight\s+(\w+)\s+(.+)$/gm; let match; while ((match = highlightRegex.exec(vimrc)) !== null) { const [, group, attrs] = match; const parsed = {}; const guifg = attrs.match(/guifg=(#?\w+)/); const guibg = attrs.match(/guibg=(#?\w+)/); const gui = attrs.match(/gui=(\S+)/); if (guifg) parsed.fg = guifg[1]; if (guibg) parsed.bg = guibg[1]; if (gui) parsed.style = gui[1]; highlights[group] = parsed; } function toStyle(group) { const h = highlights[group] || {}; let style = ''; if (h.fg && h.fg !== 'NONE') style += `color:${h.fg};`; if (h.bg && h.bg !== 'NONE') style += `background:${h.bg};`; if (h.style) { if (h.style.includes('bold')) style += 'font-weight:bold;'; if (h.style.includes('italic')) style += 'font-style:italic;'; if (h.style.includes('underline')) style += 'text-decoration:underline;'; } return style; } const html = `
Python import os from typing import Optional @dataclass class User: # User model name: str age: int = 0 active: bool = True def process(data: Optional[str]) → float: if data is None: raise ValueError("No data") return 3.14
TypeScript import { useState } from 'react'; interface Props { name: string; count?: number; } const Button = ({ name, count = 0 }: Props) => { const [active, setActive] = useState(false); return ( <button onClick={() => setActive(true)}> {name}: {count} </button> ); };
Go package main import ( "fmt" "errors" ) type User struct { Name string Active bool } func process(id int) (*User, error) { if id < 0 { return nil, errors.New("invalid") } return &User{Active: true}, nil }
Ruby require 'json' class User attr_accessor :name, :active def initialize(name) @name = name @active = true end def greet "Hello, #{@name}!" end end users = [1, 2, 3].map { |id| User.new("user_#{id}") }
Markdown # Main Heading ## Section Title ### Subsection Regular paragraph text with **bold** and *italic* text. - List item one - List item two Here's a [link to docs](https://example.com) in the text. Inline \`code\` and a code block: \`\`\`javascript const x = 42; \`\`\`
Diff diff --git a/file.txt b/file.txt index abc123..def456 100644 @@ -1,5 +1,5 @@ unchanged line -removed line +added line another unchanged
Messages Warning: File has been modified outside of Vim E488: Trailing characters: xyz Press ENTER or type command to continue Save changes? [Y]es, [N]o, [C]ancel: -- INSERT -- TODO: Fix this later`; fs.writeFileSync('/tmp/preview.html', html); console.log('Generated /tmp/preview.html'); - name: Take screenshots run: | node << 'EOF' const { chromium } = require('playwright'); const fs = require('fs'); (async () => { fs.mkdirSync('/tmp/screenshots', { recursive: true }); const browser = await chromium.launch(); const page = await browser.newPage(); // Use tall viewport to ensure all elements render with correct bounding boxes await page.setViewportSize({ width: 1200, height: 4000 }); await page.goto('file:///tmp/preview.html'); await page.waitForLoadState('networkidle'); const sections = ['python', 'typescript', 'go', 'ruby', 'markdown', 'diff', 'ui']; for (const id of sections) { const heading = await page.$(`#${id}`); const pre = await page.$(`#${id} + pre`); if (!heading || !pre) { console.log(`Skipped: ${id} (not found)`); continue; } // Create a wrapper div for combined screenshot const headingBox = await heading.boundingBox(); const preBox = await pre.boundingBox(); if (!headingBox || !preBox) continue; const padding = 16; const clip = { x: Math.max(0, Math.min(headingBox.x, preBox.x) - padding), y: Math.max(0, headingBox.y - padding), width: Math.max(headingBox.width, preBox.width) + padding * 2, height: (preBox.y + preBox.height) - headingBox.y + padding * 2 }; await page.screenshot({ path: `/tmp/screenshots/${id}.png`, clip }); console.log(`Captured: ${id}.png`); } await browser.close(); })(); EOF - name: Upload images and create PR comment uses: actions/github-script@v7 with: script: | const fs = require('fs'); const { owner, repo } = context.repo; const prNumber = context.issue.number; const sha = context.payload.pull_request.head.sha.slice(0, 7); const files = fs.readdirSync('/tmp/screenshots').filter(f => f.endsWith('.png')); const labels = { python: 'Python', typescript: 'TypeScript', go: 'Go', ruby: 'Ruby', markdown: 'Markdown', diff: 'Git Diff', ui: 'UI Elements' }; // Create release for this commit const tag = `vim-preview-${sha}`; const { data: release } = await github.rest.repos.createRelease({ owner, repo, tag_name: tag, name: `Preview ${sha}`, body: `PR #${prNumber}`, prerelease: true }); // Upload images const urls = {}; for (const file of files) { const { data: asset } = await github.rest.repos.uploadReleaseAsset({ owner, repo, release_id: release.id, name: file, data: fs.readFileSync(`/tmp/screenshots/${file}`) }); urls[file.replace('.png', '')] = asset.browser_download_url; } // Hide previous preview comments const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: prNumber }); for (const c of comments) { if (c.user.type === 'Bot' && c.body.includes('')) { await github.graphql(`mutation { minimizeComment(input: {subjectId: "${c.node_id}", classifier: OUTDATED}) { clientMutationId } }`); } } // Create new comment const fullSha = context.payload.pull_request.head.sha; const commitUrl = `https://github.com/${owner}/${repo}/commit/${fullSha}`; const workflowUrl = `https://github.com/${owner}/${repo}/actions/runs/${context.runId}`; let body = `\n## Vim Theme Preview\n\nCommit: ${commitUrl}\n\n`; for (const [name, url] of Object.entries(urls)) { body += `