diff --git a/src/templates/job/index.ts b/src/templates/job/index.ts index 0818384..bc72cc1 100644 --- a/src/templates/job/index.ts +++ b/src/templates/job/index.ts @@ -10,3 +10,4 @@ export { default as jobOpenRoles } from './open-roles'; export { default as jobInterviewProcess } from './interview-process'; export { default as jobBenefits } from './benefits'; export { default as jobInternship } from './internship'; +export { default as jobReferral } from './referral'; diff --git a/src/templates/job/referral.ts b/src/templates/job/referral.ts new file mode 100644 index 0000000..72399bf --- /dev/null +++ b/src/templates/job/referral.ts @@ -0,0 +1,218 @@ +import type { TemplateDefinition } from '../types'; +import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils'; + +// An omitted field falls back to its default, but a blank one is hidden. +function optionalText(value: unknown, fallback: string, maxLength: number): string { + return truncate((value === undefined ? fallback : String(value)).trim(), maxLength); +} + +const template: TemplateDefinition = { + id: 'job-referral', + name: 'Referral Bonus', + category: 'job', + description: 'Ask people to refer a friend for an open role, with the role, the company, a large referral bonus and how to refer', + tags: ['job', 'referral', 'hiring', 'bonus', 'recruiting'], + fields: [ + commonFields.title, + { key: 'company', label: 'Company', type: 'text', defaultValue: 'Acme', placeholder: 'Company name', group: 'Content' }, + { key: 'bonus', label: 'Referral Bonus', type: 'text', defaultValue: '$2,000', placeholder: '$2,000', group: 'Content' }, + { key: 'bonusLabel', label: 'Bonus Label', type: 'text', defaultValue: 'referral bonus', placeholder: 'referral bonus', group: 'Content' }, + { key: 'note', label: 'How to Refer', type: 'text', defaultValue: 'Send their name to jobs@acme.com', placeholder: 'Send their name to jobs@acme.com', group: 'Content' }, + commonFields.siteName, + commonFields.bgColor, + commonFields.accentColor, + commonFields.textColor, + ], + defaults: { + title: 'Senior Backend Engineer', + company: 'Acme', + bonus: '$2,000', + bonusLabel: 'referral bonus', + note: 'Send their name to jobs@acme.com', + siteName: 'acme.com', + bgColor: '#0a0a0a', + accentColor: '#E07A5F', + textColor: '#FFFFFF', + }, + render: (params) => { + const rawRole = String(params.title || 'Senior Backend Engineer').trim() || 'Senior Backend Engineer'; + const roleSize = autoFontSize(rawRole, [ + { maxLen: 22, size: 64 }, + { maxLen: 36, size: 54 }, + { maxLen: 50, size: 44 }, + { maxLen: 60, size: 38 }, + ]); + const role = truncate(rawRole, 60); + const company = optionalText(params.company, 'Acme', 40); + const bonus = optionalText(params.bonus, '$2,000', 14); + // The label is drawn inside the bonus row, so it goes when the bonus does. + const bonusLabel = optionalText(params.bonusLabel, 'referral bonus', 24); + const bonusSize = autoFontSize(bonus, [ + { maxLen: 8, size: 128 }, + { maxLen: 11, size: 104 }, + { maxLen: 14, size: 84 }, + ]); + const note = optionalText(params.note, 'Send their name to jobs@acme.com', 70); + const siteName = optionalText(params.siteName, 'acme.com', 50); + const bgColor = String(params.bgColor || '#0a0a0a'); + const accentColor = String(params.accentColor || '#E07A5F'); + const textColor = String(params.textColor || '#FFFFFF'); + // The label and the bonus are drawn in the accent on the page background, + // so the accent is darkened when that background is light. + const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor; + + return { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + width: '1200px', + height: '630px', + boxSizing: 'border-box', + backgroundColor: bgColor, + fontFamily: 'Inter, sans-serif', + padding: '64px 80px', + }, + children: [ + // Label, role and company + { + type: 'div', + props: { + style: { display: 'flex', flexDirection: 'column' }, + children: [ + { + type: 'span', + props: { + style: { + fontSize: '20px', + fontWeight: 800, + color: accentInk, + letterSpacing: '0.14em', + textTransform: 'uppercase' as const, + }, + children: 'Know someone?', + }, + }, + { + type: 'h1', + props: { + style: { + fontSize: `${roleSize}px`, + fontWeight: 700, + color: textColor, + lineHeight: 1.1, + margin: 0, + marginTop: '16px', + }, + children: role, + }, + }, + ...(company + ? [{ + type: 'span', + props: { + style: { + fontSize: '30px', + fontWeight: 500, + color: `${textColor}CC`, + marginTop: '12px', + }, + children: `at ${company}`, + }, + }] + : []), + ], + }, + }, + // Bonus, its label and how to refer + { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + justifyContent: 'center', + }, + children: [ + ...(bonus + ? [{ + type: 'div', + props: { + style: { + display: 'flex', + flexWrap: 'wrap' as const, + alignItems: 'baseline', + columnGap: '20px', + }, + children: [ + { + type: 'span', + props: { + style: { + fontSize: `${bonusSize}px`, + fontWeight: 700, + color: accentInk, + lineHeight: 1, + fontFamily: 'JetBrains Mono, monospace', + }, + children: bonus, + }, + }, + ...(bonusLabel + ? [{ + type: 'span', + props: { + style: { + fontSize: '28px', + fontWeight: 600, + color: `${textColor}99`, + }, + children: bonusLabel, + }, + }] + : []), + ], + }, + }] + : []), + ...(note + ? [{ + type: 'span', + props: { + style: { + fontSize: '26px', + fontWeight: 500, + color: textColor, + marginTop: bonus ? '28px' : '0px', + }, + children: note, + }, + }] + : []), + ], + }, + }, + // Site name + ...(siteName + ? [{ + type: 'span', + props: { + style: { + fontSize: '18px', + fontWeight: 600, + color: `${textColor}80`, + letterSpacing: '0.06em', + }, + children: siteName, + }, + }] + : []), + ], + }, + }; + }, +}; + +export default template; diff --git a/src/templates/registry.ts b/src/templates/registry.ts index 7cf361a..4e60e3c 100644 --- a/src/templates/registry.ts +++ b/src/templates/registry.ts @@ -305,11 +305,12 @@ import jobOpenRoles from './job/open-roles'; import jobInterviewProcess from './job/interview-process'; import jobBenefits from './job/benefits'; import jobInternship from './job/internship'; +import jobReferral from './job/referral'; [ jobListingCard, jobTechStackRole, jobRemoteBadge, jobCompanyCulture, jobSalaryHighlight, jobTeamPhoto, jobStartupHiring, jobDeveloperRole, - jobOpenRoles, jobInterviewProcess, jobBenefits, jobInternship, + jobOpenRoles, jobInterviewProcess, jobBenefits, jobInternship, jobReferral, ].forEach(registerTemplate); // Tutorial diff --git a/tests/templates/job-referral.test.ts b/tests/templates/job-referral.test.ts new file mode 100644 index 0000000..6579467 --- /dev/null +++ b/tests/templates/job-referral.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest'; +import referral from '@/templates/job/referral'; +import { getTemplate } from '@/templates/registry'; +import { isLightColor, mixColors } from '@/templates/utils'; +import type { SatoriElement } from '@/templates/types'; + +function elements(node: unknown): SatoriElement[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (!node || typeof node !== 'object' || !('type' in node)) return []; + + const element = node as SatoriElement; + return [element, ...elements(element.props.children)]; +} + +function textContent(node: unknown): string[] { + if (typeof node === 'string') return [node]; + if (Array.isArray(node)) return node.flatMap(textContent); + if (node && typeof node === 'object' && 'props' in node) { + return textContent((node as SatoriElement).props.children); + } + return []; +} + +const render = (overrides: Record = {}) => + referral.render({ ...referral.defaults, ...overrides }); + +const span = (tree: SatoriElement, text: string) => + elements(tree).find((element) => element.type === 'span' && textContent(element)[0] === text); + +describe('job-referral', () => { + it('is registered in the job category and re-exported', async () => { + expect(getTemplate('job-referral')?.category).toBe('job'); + const exports = await import('@/templates/job'); + expect(exports.jobReferral.id).toBe('job-referral'); + }); + + it('has the fields the issue asks for, with the defaults it names', () => { + expect(referral.fields.map((field) => field.key)).toEqual([ + 'title', 'company', 'bonus', 'bonusLabel', 'note', 'siteName', 'bgColor', 'accentColor', 'textColor', + ]); + expect(referral.defaults).toMatchObject({ + company: 'Acme', + bonus: '$2,000', + bonusLabel: 'referral bonus', + note: 'Send their name to jobs@acme.com', + }); + }); + + it('is 1200 by 630 and every div is flex', () => { + const tree = render(); + expect(tree.props.style).toMatchObject({ width: '1200px', height: '630px' }); + for (const element of elements(tree).filter((el) => el.type === 'div')) { + expect(element.props.style?.display).toBe('flex'); + } + }); + + it('renders the label, role, company, bonus, its label, the note and the site name in order', () => { + expect(textContent(render())).toEqual([ + 'Know someone?', + 'Senior Backend Engineer', + 'at Acme', + '$2,000', + 'referral bonus', + 'Send their name to jobs@acme.com', + 'acme.com', + ]); + }); + + it('hides a blank company, bonus, note or site name, and a field of only spaces', () => { + expect(textContent(render({ company: '' }))).not.toContain('at Acme'); + expect(textContent(render({ company: ' ' })).some((text) => text.startsWith('at '))).toBe(false); + expect(textContent(render({ note: ' ' }))).not.toContain('Send their name to jobs@acme.com'); + expect(textContent(render({ siteName: ' ' }))).not.toContain('acme.com'); + expect(textContent(render({ siteName: '' }))).toEqual([ + 'Know someone?', + 'Senior Backend Engineer', + 'at Acme', + '$2,000', + 'referral bonus', + 'Send their name to jobs@acme.com', + ]); + }); + + it('hides the bonus label together with a blank bonus', () => { + for (const bonus of ['', ' ']) { + expect(textContent(render({ bonus }))).toEqual([ + 'Know someone?', + 'Senior Backend Engineer', + 'at Acme', + 'Send their name to jobs@acme.com', + 'acme.com', + ]); + } + }); + + it('keeps the bonus when only its label is blank', () => { + expect(textContent(render({ bonusLabel: ' ' }))).toEqual([ + 'Know someone?', + 'Senior Backend Engineer', + 'at Acme', + '$2,000', + 'Send their name to jobs@acme.com', + 'acme.com', + ]); + }); + + it('renders only the label and the role when every optional field is blank', () => { + expect(textContent(render({ company: '', bonus: '', note: '', siteName: '' }))).toEqual([ + 'Know someone?', + 'Senior Backend Engineer', + ]); + }); + + it('falls back to the defaults when optional fields are omitted, and to the default role when the title is blank', () => { + expect(textContent(referral.render({}))).toEqual(textContent(render())); + expect(textContent(render({ title: ' ' }))[1]).toBe('Senior Backend Engineer'); + }); + + it('shrinks a long role with autoFontSize and then truncates it', () => { + const sizeOf = (title: string) => + elements(render({ title })).find((element) => element.type === 'h1')?.props.style?.fontSize; + + expect(sizeOf('Backend Engineer')).toBe('64px'); + expect(sizeOf('Senior Staff Backend Engineer, Payments')).toBe('44px'); + + const longRole = 'Principal Distributed Systems Engineer for the Payments Reliability and Fraud Platform'; + const heading = elements(render({ title: longRole })).find((element) => element.type === 'h1'); + expect(textContent(heading)[0]).toHaveLength(60); + expect(textContent(heading)[0]).toMatch(/…$/); + expect(heading?.props.style?.fontSize).toBe('38px'); + }); + + it('truncates a long company, bonus, label and note', () => { + const tree = render({ + company: 'The International Association of Extremely Long Company Names', + bonus: '$1,000,000 USD paid in full', + bonusLabel: 'paid after ninety days of employment', + note: 'Send their name, their CV and a short note about why they fit to the recruiting team at jobs@acme.com', + }); + const texts = textContent(tree); + expect(texts[2]).toHaveLength('at '.length + 40); + expect(texts[2]).toMatch(/…$/); + expect(texts[3]).toHaveLength(14); + expect(texts[3]).toMatch(/…$/); + expect(texts[4].length).toBeLessThanOrEqual(24); + expect(texts[4]).toMatch(/…$/); + expect(texts[5].length).toBeLessThanOrEqual(70); + expect(texts[5]).toMatch(/…$/); + }); + + it('makes a longer bonus smaller so it still fits', () => { + const sizeOf = (bonus: string) => span(render({ bonus }), bonus)?.props.style?.fontSize; + expect(sizeOf('$500')).toBe('128px'); + expect(sizeOf('$10,000 USD')).toBe('104px'); + expect(sizeOf('£12,000 total')).toBe('84px'); + }); + + it('keeps the label and the bonus readable on a light background', () => { + const dark = render(); + expect(span(dark, 'Know someone?')?.props.style?.color).toBe('#E07A5F'); + expect(span(dark, '$2,000')?.props.style?.color).toBe('#E07A5F'); + + const light = render({ bgColor: '#ffffff', textColor: '#111111', accentColor: '#fde047' }); + expect(isLightColor('#ffffff')).toBe(true); + const ink = mixColors('#fde047', '#000000', 0.45); + expect(ink).toBe('#8b7b27'); + expect(span(light, 'Know someone?')?.props.style?.color).toBe(ink); + expect(span(light, '$2,000')?.props.style?.color).toBe(ink); + expect(elements(light).find((element) => element.type === 'h1')?.props.style?.color).toBe('#111111'); + expect(span(light, 'Send their name to jobs@acme.com')?.props.style?.color).toBe('#111111'); + }); +});