Hello
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";', 'Hello
Hello
', '