Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion app/components/WeeklyEmailPreview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@
*/

import type { DigestDemoResponse } from '#layers/identity/server/api/digest/demo.get'
import { compactWeeklyPreview, weeklyPreviewCss } from '#shared/weekly-preview'

useHead({ style: [{ key: 'weekly-preview', innerHTML: weeklyPreviewCss }] })

// A failed demo costs the section its picture, never its copy or its CTA:
// `data` stays null on error and `hasDemo` gates the whole frame.
// Only the card and its row count reach the payload. The answer also carries
// the whole email as `html`, which this preview never renders.
const { data: demo } = await useFetch('/api/digest/demo', {
key: 'home-digest-demo-v2',
transform: ({ card, rowCount }: DigestDemoResponse) => ({ card, rowCount }),
transform: ({ card, rowCount }: DigestDemoResponse) => ({ card: compactWeeklyPreview(card), rowCount }),
})

const hasDemo = computed(() => !!demo.value?.card && (demo.value?.rowCount ?? 0) > 0)
Expand Down
49 changes: 49 additions & 0 deletions shared/weekly-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/** Browser preview styles. Unknown email styles keep their inline fallback. */
const styles = [
'width:100%;max-width:600px;background:var(--email-surface);border:1px solid var(--email-border);border-radius:8px;',
'padding:22px 20px 26px;',
'border-bottom:1px solid var(--email-border);padding-bottom:14px;',
'font-family:\'IBM Plex Mono\',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;font-weight:600;color:var(--email-text);letter-spacing:-0.01em;',
'display:inline-block;padding:11px 0;color:var(--email-text);text-decoration:none;',
'display:inline-block;width:22px;height:22px;margin-right:9px;border:0;border-radius:5px;vertical-align:middle;',
'vertical-align:middle;',
'color:var(--email-faint);font-weight:400;',
'font-family:\'IBM Plex Mono\',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;color:var(--email-faint);font-variant-numeric:tabular-nums;',
'padding:22px 0 8px;',
'margin:0;font-family:\'Plus Jakarta Sans\',-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;font-size:20px;font-weight:600;line-height:1.35;color:var(--email-text);',
'margin-top:6px;font-family:\'Plus Jakarta Sans\',-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Helvetica,Arial,sans-serif;font-size:14px;line-height:1.6;color:var(--email-body);',
'padding:28px 0 10px;',
'margin:0;font-family:\'IBM Plex Mono\',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:1.5;letter-spacing:0.08em;text-transform:uppercase;color:var(--email-faint);',
'padding:14px 0;border-top:1px solid var(--email-border);',
'width:44px;padding-right:12px;',
'width:36px;height:36px;border-radius:18px;display:block;border:1px solid var(--email-border);background:var(--email-quote);',
'display:inline-block;padding:0 0 3px;font-family:\'IBM Plex Mono\',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:15px;line-height:1.45;font-weight:600;color:var(--email-text);text-decoration:underline;text-decoration-color:var(--email-borderStrong);text-underline-offset:3px;',
'margin-top:6px;',
'width:16px;font-family:\'IBM Plex Mono\',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:1.55;color:var(--email-faint);',
'font-family:\'IBM Plex Mono\',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:1.55;color:var(--email-body);',
'margin-top:8px;font-family:\'IBM Plex Mono\',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:1.55;color:var(--email-muted);font-variant-numeric:tabular-nums;',
'margin-top:4px;font-family:\'IBM Plex Mono\',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;',
'display:inline-block;padding:12px 12px 12px 0;color:var(--email-accent);text-decoration:underline;text-underline-offset:3px;',
'height:3px;background:var(--email-mark);font-size:0;line-height:0;',
'padding:16px 20px 20px;border-top:1px solid var(--email-border);font-family:\'IBM Plex Mono\',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:1.7;color:var(--email-faint);',
'display:inline-block;padding:12px 0;color:var(--email-muted);text-decoration:underline;text-underline-offset:3px;',
] as const

const classes = new Map<string, string>(styles.map((style, index) => [style, `wp${index}`]))

export const weeklyPreviewCss = styles.map((style, index) => `.weekly-preview .wp${index}{${style}}`).join('')

/** Share repeated browser styles while preserving every email element and attribute. */
export function compactWeeklyPreview(card: string): string {
return card.replace(/<([a-z][\w-]*)\b([^<>]*?)\sstyle="([^"]*)"([^<>]*)>/gi, (tag, name: string, before: string, style: string, after: string) => {
const className = classes.get(style)
if (!className)
return tag
const selfClosing = /\/\s*$/.test(after)
const attributes = `${before}${after.replace(/\/\s*$/, '')}`
const styled = /\bclass="[^"]*"/.test(attributes)
? attributes.replace(/\bclass="([^"]*)"/, (_match, value: string) => `class="${value} ${className}"`)
: `${attributes} class="${className}"`
return `<${name}${styled}${selfClosing ? ' /' : ''}>`
})
}
18 changes: 17 additions & 1 deletion test/unit/weekly-template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { WeeklyRenderInput, WeeklyTrendingSkill } from '../../layers/identi
import { describe, expect, it } from 'vitest'
import { formatWindow, renderWeekly, renderWeeklyPreview } from '../../layers/identity/server/utils/weekly-template'
import { SPARK_BARS } from '../../shared/braille-spark'
import { compactWeeklyPreview } from '../../shared/weekly-preview'

const WINDOW_END = 1_755_648_000
const WINDOW_START = WINDOW_END - 7 * 86_400
Expand Down Expand Up @@ -40,19 +41,34 @@ function trending(overrides: Partial<WeeklyTrendingSkill> = {}): WeeklyTrendingS
}

describe('weekly template', () => {
it('keeps a multi-row browser preview smaller than the delivered email card', () => {
const source = input({ likedChanges: [likedChange(), likedChange(), likedChange()] })
const email = renderWeekly(source).card
const preview = compactWeeklyPreview(renderWeeklyPreview(source))
expect(Buffer.byteLength(JSON.stringify(preview))).toBeLessThan(Buffer.byteLength(JSON.stringify(email)) * 0.65)
})
it('keeps preview content and links while email retains inline colours', () => {
const source = input({ trending: [trending()] })
const email = renderWeekly(source).card
const preview = renderWeeklyPreview(source)
const preview = compactWeeklyPreview(renderWeeklyPreview(source))
const emailElement = document.createElement('div')
const previewElement = document.createElement('div')
emailElement.innerHTML = email
previewElement.innerHTML = preview
expect(previewElement.textContent?.replace(/\s+/g, '')).toBe(emailElement.textContent?.replace(/\s+/g, ''))
expect([...previewElement.querySelectorAll('a')].map(a => a.href)).toEqual([...emailElement.querySelectorAll('a')].map(a => a.href))
expect([...previewElement.querySelectorAll('img')].map(image => [image.src, image.width, image.height, image.alt])).toEqual([...emailElement.querySelectorAll('img')].map(image => [image.src, image.width, image.height, image.alt]))
expect(preview).toContain('var(--email-')
expect(email).not.toContain('var(--email-')
})
it('keeps new preview styles as inline fallbacks', () => {
const card = '<table><tr><td style="color:rebeccapurple;padding:19px">Future layout</td></tr></table>'
const element = document.createElement('div')
element.innerHTML = compactWeeklyPreview(card)
expect(element.querySelector('td')?.style.color).toBe('rebeccapurple')
expect(element.querySelector('td')?.style.padding).toBe('19px')
expect(element.textContent).toBe('Future layout')
})
it('states the star count once for a star-only row', () => {
const { html, text } = renderWeekly(input({
trending: [trending({ reason: { _tag: 'popular', stars: 46_712 }, stars: 46_712 })],
Expand Down
Loading