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
17 changes: 6 additions & 11 deletions app/components/WeeklyEmailPreview.vue
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
<script setup lang="ts">
/**
* The digest, shown rather than described.
*
* The card is the real email, server-rendered through the same `renderWeekly`
* the Monday send uses, so the homepage cannot claim something the email does
* not do. It carries the current week's trending rows and no watched section,
* because an anonymous visitor watches nothing and inventing one would put
* fabricated commits against real people's repositories.
* A digest example rendered from real public source changes.
* Personal subscriptions never enter the anonymous preview.
*/

import type { WeeklyDemoResponse } from '~~/server/api/weekly/demo.get'
import type { DigestDemoResponse } from '#layers/identity/server/api/digest/demo.get'

// 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.
const { data: demo } = await useFetch<WeeklyDemoResponse>('/api/weekly/demo', {
key: 'home-weekly-demo-v1',
const { data: demo } = await useFetch<DigestDemoResponse>('/api/digest/demo', {
key: 'home-digest-demo-v1',
})

const hasDemo = computed(() => !!demo.value?.card && (demo.value?.rowCount ?? 0) > 0)
Expand All @@ -38,7 +33,7 @@ const card = computed(() =>
-->
<div v-if="hasDemo" class="home-weekly-demo">
<p class="data-label">
In your inbox
Example digest Β· real source changes
</p>
<div class="home-weekly-frame" aria-hidden="true" inert>
<!-- eslint-disable-next-line vue/no-v-html -->
Expand Down
4 changes: 2 additions & 2 deletions app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -580,9 +580,9 @@ function recentUpdateDescription(item: RecentUpdateCard): string {
class="min-h-11 justify-center"
/>
<UButton
to="/weekly/preview"
to="/digest/preview"
external
label="See this week's digest"
label="See an example digest"
color="neutral"
variant="outline"
trailing-icon="i-lucide-arrow-up-right"
Expand Down
54 changes: 54 additions & 0 deletions layers/identity/server/api/digest/demo.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { defineApiHandler } from '#shared/server/handler'
import {
loadPublicDigestChanges,
publicDigestSkillSchema,
publicDigestUpdatesSchema,
} from '../../utils/public-digest'
import { renderWeekly } from '../../utils/weekly-template'

/** A sample watch list built from real public changes, never personal subscriptions. */
export interface DigestDemoResponse {
card: { light: string, dark: string }
html: string
subject: string
windowEnd: number
rowCount: number
}

export default defineCachedEventHandler(defineApiHandler({ handler: async () => {
const windowEnd = Math.floor(Date.now() / 1000)
const windowStart = windowEnd - 7 * 24 * 60 * 60
const changes = await loadPublicDigestChanges({
windowStart,
windowEnd,
loadUpdates: async () => publicDigestUpdatesSchema.parse(await $fetch('/api/feed/recent-updates')),
loadSkill: async item => publicDigestSkillSchema.parse(await $fetch(
`/api/v1/skills/${encodeURIComponent(item.owner)}/${encodeURIComponent(item.repo)}/${encodeURIComponent(item.name)}`,
)),
})
return { changes, windowStart, windowEnd }
}, presenter: ({ changes, windowStart, windowEnd }): DigestDemoResponse => {
const render = (theme: 'light' | 'dark') => renderWeekly({
edition: 'digest',
changeSource: 'watches',
theme,
login: null,
windowStart,
windowEnd,
likedChanges: changes,
likedOverflow: 0,
trackedCount: changes.length,
trending: [],
siteUrl: 'https://skilld.dev',
settingsUrl: 'https://skilld.dev/me',
unsubscribeUrl: 'https://skilld.dev/me',
})
const light = render('light')
return {
card: { light: light.card, dark: render('dark').card },
html: light.html,
subject: light.subject,
windowEnd,
rowCount: changes.length,
}
} }), { maxAge: 3600, swr: true, name: 'public-digest-demo-v1' })
6 changes: 6 additions & 0 deletions layers/identity/server/routes/digest/preview.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default defineCachedEventHandler(async (event) => {
const demo = await $fetch<{ html: string }>('/api/digest/demo')
setHeader(event, 'content-type', 'text/html; charset=utf-8')
setHeader(event, 'x-robots-tag', 'noindex, follow')
return demo.html
}, { maxAge: 3600, swr: true, name: 'public-digest-preview-v1' })
1 change: 1 addition & 0 deletions layers/identity/server/utils/digest-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export function renderDigest(input: DigestRenderInput): DigestRender {

return renderWeekly({
edition: 'digest',
changeSource: 'watches',
recipientName: input.recipientName ?? null,
countClicks: input.countClicks ?? false,
windowStart: input.windowStart,
Expand Down
62 changes: 62 additions & 0 deletions layers/identity/server/utils/public-digest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { WeeklyLikedChange } from './weekly-template'
import { z } from 'zod'

const updateSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('skill'),
owner: z.string(),
repo: z.string(),
name: z.string(),
occurredAt: z.number(),
changeSummary: z.string().nullable(),
}),
z.object({ kind: z.literal('repo') }),
])

export const publicDigestUpdatesSchema = z.object({ items: z.array(updateSchema) })
export const publicDigestSkillSchema = z.object({
description: z.string().nullable(),
sourceUrl: z.string().url(),
sourceGone: z.boolean(),
sourceCommit: z.string().nullable(),
})

type PublicDigestUpdate = Extract<z.infer<typeof updateSchema>, { kind: 'skill' }>

export interface PublicDigestDependencies {
windowStart: number
windowEnd: number
loadUpdates: () => Promise<z.infer<typeof publicDigestUpdatesSchema>>
loadSkill: (update: PublicDigestUpdate) => Promise<z.infer<typeof publicDigestSkillSchema>>
}

/** Public changes only. Never read an account's watches for an anonymous example. */
export async function loadPublicDigestChanges(deps: PublicDigestDependencies): Promise<WeeklyLikedChange[]> {
const updates = await deps.loadUpdates()
const recent = updates.items.filter((item): item is PublicDigestUpdate =>
item.kind === 'skill'
&& item.occurredAt >= deps.windowStart
&& item.occurredAt <= deps.windowEnd,
).slice(0, 5)
const changes = await Promise.all(recent.map(async (item): Promise<WeeklyLikedChange | null> => {
const skill = await deps.loadSkill(item)
if (skill.sourceGone)
return null
return {
owner: item.owner,
repo: item.repo,
name: item.name,
slug: item.name,
description: skill.description,
changeCount: 1,
changedAt: item.occurredAt,
commitMessages: item.changeSummary ? [item.changeSummary] : [],
sourceUrl: skill.sourceUrl,
// Feed hashes identify blobs. The detail API identifies revision commits.
changeUrl: skill.sourceCommit
? `https://github.com/${encodeURIComponent(item.owner)}/${encodeURIComponent(item.repo)}/commit/${encodeURIComponent(skill.sourceCommit)}`
: skill.sourceUrl.replace('/blob/', '/commits/'),
}
}))
return changes.filter((item): item is WeeklyLikedChange => item !== null)
}
25 changes: 18 additions & 7 deletions layers/identity/server/utils/weekly-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ export interface WeeklyLikedChange {
export interface WeeklyRenderInput {
/** Product label. The default keeps the public weekly preview unchanged. */
edition?: 'weekly' | 'digest'
/** Watched Repositories are separate from liked Skills. */
changeSource?: 'likes' | 'watches'
/**
* Who it is addressed to, or null when nobody.
*
Expand Down Expand Up @@ -578,6 +580,8 @@ function subjectFor(input: WeeklyRenderInput): string {
* subject did not.
*/
function preheader(input: WeeklyRenderInput): string {
if (input.changeSource === 'watches')
return 'Source changes from the repos you watch.'
const trendingNames = nameList(input.trending.map(skill => skill.canonicalName), 0)
if ((input.edition ?? 'weekly') === 'weekly') {
return input.trending.length
Expand All @@ -598,6 +602,8 @@ function preheader(input: WeeklyRenderInput): string {
}

function greeting(input: WeeklyRenderInput): string {
if (input.changeSource === 'watches')
return input.likedChanges.length ? 'Source files changed in the repos you watch.' : 'No source changes this week.'
if ((input.edition ?? 'weekly') === 'weekly')
return 'Here are this week’s trending Skills.'
const liked = input.likedChanges.length + input.likedOverflow
Expand Down Expand Up @@ -638,6 +644,7 @@ export function renderWeekly(input: WeeklyRenderInput): WeeklyRender {
const subject = subjectFor(input)
const edition = input.edition ?? 'weekly'
const trendsOnly = edition === 'weekly'
const changeLabel = input.changeSource === 'watches' ? 'Repos you watch' : 'Skills you like'
const empty = !input.likedChanges.length && !input.trackedCount && !input.trending.length

// Only the HTML is counted. Plain-text clients show the raw URL and often
Expand Down Expand Up @@ -675,7 +682,7 @@ export function renderWeekly(input: WeeklyRenderInput): WeeklyRender {

const overflowRow = input.likedOverflow
? `<tr><td class="weekly-border" style="padding:12px 0 0;border-top:1px solid ${t.border};font-family:${MONO};font-size:14px;line-height:20px;color:${t.muted};">
<a href="${esc(track(`${input.siteUrl}/me/likes`, 'overflow'))}" style="display:inline-block;padding:12px 0;color:${t.muted};text-decoration:underline;text-underline-offset:3px;">+${input.likedOverflow} more ${plural(input.likedOverflow, 'Skill', 'Skills')} you like were updated</a>
<a href="${esc(track(input.changeSource === 'watches' ? input.settingsUrl : `${input.siteUrl}/me/likes`, 'overflow'))}" style="display:inline-block;padding:12px 0;color:${t.muted};text-decoration:underline;text-underline-offset:3px;">+${input.likedOverflow} more ${plural(input.likedOverflow, 'Skill', 'Skills')} ${input.changeSource === 'watches' ? 'from repos you watch' : 'you like'} were updated</a>
</td></tr>`
: ''

Expand Down Expand Up @@ -713,11 +720,11 @@ export function renderWeekly(input: WeeklyRenderInput): WeeklyRender {
: `<tr><td class="weekly-border" style="padding:22px 0 4px;margin-top:16px;border-top:1px solid ${t.border};font-family:${SANS};font-size:14px;line-height:1.6;color:${t.body};">No new trending Skills this week.</td></tr>`
: empty
? `<tr><td class="weekly-border" style="padding:22px 0 4px;margin-top:16px;border-top:1px solid ${t.border};font-family:${SANS};font-size:14px;line-height:1.6;color:${t.body};">
Like a few Skills and they will show up here the week they change.
${input.changeSource === 'watches' ? 'Watch a repo to see its Skill changes here.' : 'Like a few Skills and they will show up here the week they change.'}
<a href="${esc(track(`${input.siteUrl}/skills`, 'cta'))}" style="display:inline-block;padding:12px 0;line-height:20px;color:${t.accent};text-decoration:underline;text-underline-offset:3px;">Browse the registry</a>.
</td></tr>`
: `${input.likedChanges.length || input.trackedCount
? `${sectionLabel(t, 'Skills you like')}${likedRows}${overflowRow}${quietRow}`
? `${sectionLabel(t, changeLabel)}${likedRows}${overflowRow}${quietRow}`
: ''}${input.trending.length
? `${sectionLabel(t, 'Trending this week')}${trendingRows}${likePrompt}`
: ''}`
Expand Down Expand Up @@ -792,6 +799,7 @@ export function renderWeekly(input: WeeklyRenderInput): WeeklyRender {
export function renderWeeklyText(input: WeeklyRenderInput): string {
const now = input.windowEnd
const trendsOnly = (input.edition ?? 'weekly') === 'weekly'
const changeLabel = input.changeSource === 'watches' ? 'REPOS YOU WATCH' : 'SKILLS YOU LIKE'
const lines: string[] = [
`skilld ${input.edition ?? 'weekly'} ${formatWindow(input.windowStart, input.windowEnd)}`,
'',
Expand All @@ -800,10 +808,10 @@ export function renderWeeklyText(input: WeeklyRenderInput): string {
]

if (!trendsOnly && !input.likedChanges.length && input.trackedCount)
lines.push('', 'SKILLS YOU LIKE', '', trackedLine(input.trackedCount))
lines.push('', changeLabel, '', trackedLine(input.trackedCount))

if (!trendsOnly && input.likedChanges.length) {
lines.push('', 'SKILLS YOU LIKE', '')
lines.push('', changeLabel, '')
for (const change of input.likedChanges) {
lines.push(`- ${change.owner}/${change.repo} ${change.name}`)
const subjects = commitSubjects(change.commitMessages)
Expand All @@ -821,8 +829,11 @@ export function renderWeeklyText(input: WeeklyRenderInput): string {
lines.push(` Source: ${change.sourceUrl ?? skillUrl(input.siteUrl, change)}`)
lines.push('')
}
if (input.likedOverflow)
lines.push(`+${input.likedOverflow} more you like were updated: ${input.siteUrl}/me/likes`, '')
if (input.likedOverflow) {
lines.push(input.changeSource === 'watches'
? `+${input.likedOverflow} more from repos you watch were updated: ${input.settingsUrl}`
: `+${input.likedOverflow} more you like were updated: ${input.siteUrl}/me/likes`, '')
}
}

if (input.trending.length) {
Expand Down
26 changes: 26 additions & 0 deletions test/unit/digest-template.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { renderDigest } from '../../layers/identity/server/utils/digest-template'

describe('watched Repository digest', () => {
it('describes watched source changes in HTML and plain text', () => {
const result = renderDigest({
login: 'example',
windowStart: 90,
windowEnd: 110,
unsubscribeUrl: 'https://skilld.dev/me',
entries: [{
owner: 'example',
repo: 'skills',
skillNames: ['review'],
changeCount: 1,
skills: [{ name: 'review', description: null, changeCount: 1, changedAt: 100, commitMessages: ['Add keyboard checks'], sourceUrl: 'https://github.com/example/skills/blob/main/review/SKILL.md', changeUrl: 'https://github.com/example/skills/commit/abc' }],
}],
})
for (const output of [result.html, result.text]) {
expect(output).toContain('Add keyboard checks')
expect(output).toContain('repos you watch')
expect(output).not.toContain('Skills you like')
expect(output).not.toContain('plus trending Skills')
}
})
})
3 changes: 2 additions & 1 deletion test/unit/email-clicks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,8 @@ describe('counted email links', () => {
expect(links.length).toBeGreaterThan(0)
expect(links.map(String)).toEqual(counted(digest('bo').html).map(String))
expect(new Set(links.map(url => url.pathname))).toEqual(new Set(['/api/e/digest']))
expect(links.map(url => url.searchParams.get('to'))).toContain('/me/likes')
expect(links.map(url => url.searchParams.get('to'))).toContain('/me')
expect(links.map(url => url.searchParams.get('to'))).not.toContain('/me/likes')
})

it('leaves previews uncounted', () => {
Expand Down
66 changes: 66 additions & 0 deletions test/unit/public-digest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import { loadPublicDigestChanges } from '../../layers/identity/server/utils/public-digest'

describe('public digest example', () => {
const update = {
kind: 'skill' as const,
owner: 'example',
repo: 'skills',
name: 'review',
occurredAt: 100,
sha: 'abc',
changeSummary: 'Add keyboard checks',
}
const skill = { description: 'Review interfaces', sourceUrl: 'https://github.com/example/skills/blob/main/review/SKILL.md', sourceGone: false, sourceCommit: 'def' }

it('shows real source changes without account or trending data', async () => {
const result = await loadPublicDigestChanges({
windowStart: 90,
windowEnd: 110,
loadUpdates: async () => ({ items: [update] }),
loadSkill: async () => skill,
})
expect(result).toEqual([{
owner: 'example',
repo: 'skills',
name: 'review',
slug: 'review',
description: 'Review interfaces',
changeCount: 1,
changedAt: 100,
commitMessages: ['Add keyboard checks'],
sourceUrl: skill.sourceUrl,
changeUrl: 'https://github.com/example/skills/commit/def',
}])
})

it('uses file history when no revision commit is available', async () => {
const result = await loadPublicDigestChanges({
windowStart: 90,
windowEnd: 110,
loadUpdates: async () => ({ items: [update] }),
loadSkill: async () => ({ ...skill, sourceCommit: null }),
})
expect(result[0]?.changeUrl).toBe('https://github.com/example/skills/commits/main/review/SKILL.md')
})

it('omits removed sources and changes outside the example window', async () => {
const calls: string[] = []
const result = await loadPublicDigestChanges({
windowStart: 90,
windowEnd: 110,
loadUpdates: async () => ({ items: [
{ ...update, occurredAt: 80 },
{ ...update, occurredAt: 120 },
update,
{ kind: 'repo' as const },
] }),
loadSkill: async (item) => {
calls.push(item.name)
return { ...skill, sourceGone: true }
},
})
expect(result).toEqual([])
expect(calls).toEqual(['review'])
})
})
2 changes: 1 addition & 1 deletion test/unit/weekly-email-preview-inert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import WeeklyEmailPreview from '../../app/components/WeeklyEmailPreview.vue'

const CARD = '<table><tr><td><a href="https://skilld.dev/me">Settings</a></td></tr></table>'

registerEndpoint('/api/weekly/demo', () => ({
registerEndpoint('/api/digest/demo', () => ({
card: { light: CARD, dark: CARD },
rowCount: 3,
}))
Expand Down