diff --git a/.github/pr-assets/preview-line-numbers.png b/.github/pr-assets/preview-line-numbers.png new file mode 100644 index 00000000..9357ce15 Binary files /dev/null and b/.github/pr-assets/preview-line-numbers.png differ diff --git a/apps/preview/app/src/components/code/code-block.tsx b/apps/preview/app/src/components/code/code-block.tsx index 17cb98d2..549108af 100644 --- a/apps/preview/app/src/components/code/code-block.tsx +++ b/apps/preview/app/src/components/code/code-block.tsx @@ -1,6 +1,7 @@ import { Check, Copy, Download } from 'iconoir-react'; import beautify from 'js-beautify'; import { useMemo, useState } from 'react'; +import type { ShikiTransformer } from 'shiki'; import { createHighlighterCoreSync } from 'shiki/core'; import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; import shikiHtmlLang from 'shiki/langs/html.mjs'; @@ -15,6 +16,31 @@ const shiki = createHighlighterCoreSync({ themes: [shikiGithubLightTheme, shikiGithubDarkHighContrastTheme] }); +const lineNumberTransformer: ShikiTransformer = { + name: 'jsx-email-preview-line-numbers', + line(node, line) { + const { children } = node; + node.children = [ + { + type: 'element', + tagName: 'span', + properties: { + ariaHidden: 'true', + className: ['code-line-number'], + dataLineNumber: String(line) + }, + children: [{ type: 'text', value: String(line) }] + }, + { + type: 'element', + tagName: 'span', + properties: { className: ['code-line-content'] }, + children + } + ]; + } +}; + interface CodeBlockProps { code: string; fileName: string; @@ -26,17 +52,28 @@ export function formatCode(code: string, lang: CodeBlockProps['lang']) { return code; } +export function codeToLineNumberedHtml( + code: string, + lang: CodeBlockProps['lang'], + theme: 'github-light' | 'github-dark-high-contrast' +) { + if (lang === 'text') return escapeHtml(code); + return shiki.codeToHtml(code, { + lang, + theme, + transformers: [lineNumberTransformer] + }); +} + export function CodeBlock({ code, fileName, lang }: CodeBlockProps) { const [copied, setCopied] = useState(false); const visibleCode = useMemo(() => formatCode(code, lang), [code, lang]); const html = useMemo(() => { - if (lang === 'text') return escapeHtml(visibleCode); - return shiki.codeToHtml(visibleCode, { - lang, - theme: document.documentElement.classList.contains('dark') - ? 'github-dark-high-contrast' - : 'github-light' - }); + const theme = document.documentElement.classList.contains('dark') + ? 'github-dark-high-contrast' + : 'github-light'; + + return codeToLineNumberedHtml(visibleCode, lang, theme); }, [lang, visibleCode]); async function copy() { @@ -68,7 +105,7 @@ export function CodeBlock({ code, fileName, lang }: CodeBlockProps) {
@@ -76,8 +113,14 @@ export function CodeBlock({ code, fileName, lang }: CodeBlockProps) { } function escapeHtml(value: string) { - return `
${value
-    .replaceAll('&', '&')
-    .replaceAll('<', '<')
-    .replaceAll('>', '>')}
`; + const lines = value.split('\n'); + return `
${lines
+    .map((line, index) => {
+      const lineNumber = index + 1;
+      return `${line
+        .replaceAll('&', '&')
+        .replaceAll('<', '<')
+        .replaceAll('>', '>')}`;
+    })
+    .join('')}
`; } diff --git a/apps/preview/app/src/index.css b/apps/preview/app/src/index.css index 100de19a..a3fe8e75 100644 --- a/apps/preview/app/src/index.css +++ b/apps/preview/app/src/index.css @@ -683,6 +683,31 @@ body { padding: 0; } +.card-code code { + display: grid; + min-width: max-content; +} + +.card-code .line { + display: grid; + grid-template-columns: 2.5rem minmax(0, 1fr); + min-height: 1.25rem; +} + +.card-code .code-line-number { + border-right: 1px solid var(--border); + color: var(--text-subtle); + padding-right: 0.75rem; + text-align: right; + user-select: none; +} + +.card-code .code-line-content { + padding-left: 1rem; + padding-right: 1rem; + white-space: pre; +} + .code-block-wrap { position: relative; } diff --git a/apps/preview/app/test/components/code/code-block.test.ts b/apps/preview/app/test/components/code/code-block.test.ts new file mode 100644 index 00000000..4fffceed --- /dev/null +++ b/apps/preview/app/test/components/code/code-block.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { codeToLineNumberedHtml, formatCode } from '../../../src/components/code/code-block'; + +describe('codeToLineNumberedHtml', () => { + it('adds aligned line number markup to highlighted TSX code', () => { + const html = codeToLineNumberedHtml( + ['const subject = "Hi";', '{subject}'].join('\n'), + 'tsx', + 'github-light' + ); + + expect(html).toContain('class="line"'); + expect(html).toContain('class="code-line-number"'); + expect(html).toContain('data-line-number="1"'); + expect(html).toContain('data-line-number="2"'); + expect(html).toContain('class="code-line-content"'); + }); +}); + +describe('formatCode', () => { + it('formats HTML before it is highlighted', () => { + expect(formatCode('

Hello

', 'html')).toBe( + ['
', '

Hello

', '
'].join('\n') + ); + }); +}); diff --git a/apps/preview/scripts/pack-preview.ts b/apps/preview/scripts/pack-preview.ts index 96e9fbb0..70d94203 100644 --- a/apps/preview/scripts/pack-preview.ts +++ b/apps/preview/scripts/pack-preview.ts @@ -68,6 +68,7 @@ const importHeader = `import React, { import ReactDOM from 'react-dom/client'; import { createPortal } from 'react-dom'; import { createRoot } from 'react-dom/client'; +import type { ShikiTransformer } from 'shiki'; import { Check, Copy, diff --git a/scripts/agents/capture-preview-line-numbers.ts b/scripts/agents/capture-preview-line-numbers.ts new file mode 100644 index 00000000..d465b374 --- /dev/null +++ b/scripts/agents/capture-preview-line-numbers.ts @@ -0,0 +1,40 @@ +import { chromium } from 'playwright'; + +const defaultUrl = 'http://localhost:8789'; +const defaultPath = '/tmp/preview-line-numbers.png'; + +function getArg(name: string, fallback: string) { + const index = process.argv.indexOf(name); + return index === -1 ? fallback : (process.argv[index + 1] ?? fallback); +} + +async function main() { + const url = getArg('--url', defaultUrl); + const path = getArg('--path', defaultPath); + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ + deviceScaleFactor: 2, + viewport: { height: 900, width: 1280 } + }); + + await page.goto(`${url}?lineNumbers=${Date.now()}`, { waitUntil: 'networkidle' }); + await page.getByRole('button', { exact: true, name: 'Google Play Policy Update' }).click(); + await page.waitForTimeout(900); + await page.getByRole('tab', { exact: true, name: 'JSX' }).click(); + await page.waitForTimeout(300); + + const card = page.locator('main article').filter({ hasText: 'Google Play Policy Update' }); + await card.screenshot({ path }); + + const lineNumbers = await card.locator('.code-line-number').count(); + const firstLine = await card.locator('.code-line-number').first().textContent(); + const hasCodeContent = (await card.locator('.code-line-content').count()) > 0; + await browser.close(); + + console.log(JSON.stringify({ firstLine, hasCodeContent, lineNumbers, path }, null, 2)); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +});