From 3183013512381817cdf888fe3fca846f115eb7ec Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Tue, 27 Oct 2026 18:56:50 +0530 Subject: [PATCH] Added tempalte today i learned Signed-off-by: Anurag Yadav --- src/templates/registry.ts | 3 +- src/templates/tutorial/index.ts | 1 + src/templates/tutorial/til.ts | 275 ++++++++++++++++++++++++++++++++ tests/templates/til.test.ts | 160 +++++++++++++++++++ 4 files changed, 438 insertions(+), 1 deletion(-) create mode 100644 src/templates/tutorial/til.ts create mode 100644 tests/templates/til.test.ts diff --git a/src/templates/registry.ts b/src/templates/registry.ts index f3bf9ce..e0dd5cb 100644 --- a/src/templates/registry.ts +++ b/src/templates/registry.ts @@ -323,10 +323,11 @@ import tutorialBeforeAfter from './tutorial/before-after'; import tutorialGlossary from './tutorial/glossary'; import tutorialPrerequisites from './tutorial/prerequisites'; import tutorialErrorExplained from './tutorial/error-explained'; +import tutorialTil from './tutorial/til'; [ tutorialCourseCard, tutorialStepByStep, tutorialVideoLesson, tutorialDifficultyBadge, tutorialPlatformBranded, tutorialCodeTutorial, tutorialWorkshopBanner, tutorialCertification, tutorialQuizCard, tutorialCheatSheet, tutorialBeforeAfter, tutorialGlossary, tutorialPrerequisites, - tutorialErrorExplained, + tutorialErrorExplained, tutorialTil, ].forEach(registerTemplate); diff --git a/src/templates/tutorial/index.ts b/src/templates/tutorial/index.ts index 78e8354..008aa8e 100644 --- a/src/templates/tutorial/index.ts +++ b/src/templates/tutorial/index.ts @@ -12,3 +12,4 @@ export { default as tutorialGlossary } from './glossary'; export { default as tutorialPrerequisites } from './prerequisites'; export { default as tutorialBeforeAfter } from './before-after'; export { default as tutorialErrorExplained } from './error-explained'; +export { default as tutorialTil } from './til'; diff --git a/src/templates/tutorial/til.ts b/src/templates/tutorial/til.ts new file mode 100644 index 0000000..9aecb04 --- /dev/null +++ b/src/templates/tutorial/til.ts @@ -0,0 +1,275 @@ +import type { TemplateDefinition } from '../types'; +import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils'; + +const DEFAULT_TITLE = 'git switch - takes you back to the branch you were just on'; +const DEFAULT_CODE = 'git switch -'; + +const template: TemplateDefinition = { + id: 'tutorial-til', + name: 'Today I Learned', + category: 'tutorial', + description: 'A card for sharing quick "Today I Learned" discoveries and commands', + tags: ['til', 'today-i-learned', 'tutorial', 'tip', 'code'], + fields: [ + { + ...commonFields.title, + label: 'Title', + defaultValue: DEFAULT_TITLE, + placeholder: 'What you learned', + }, + { + key: 'code', + label: 'Code / Command', + type: 'text', + defaultValue: DEFAULT_CODE, + placeholder: 'git switch -', + group: 'Content', + }, + { + key: 'tag', + label: 'Tag', + type: 'text', + defaultValue: 'git', + placeholder: 'git', + group: 'Content', + }, + { + ...commonFields.date, + defaultValue: 'Oct 27, 2026', + placeholder: 'Oct 27, 2026', + }, + commonFields.siteName, + commonFields.bgColor, + commonFields.accentColor, + commonFields.textColor, + ], + defaults: { + title: DEFAULT_TITLE, + code: DEFAULT_CODE, + tag: 'git', + date: 'Oct 27, 2026', + siteName: 'codercops.com', + bgColor: '#0f172a', + accentColor: '#38bdf8', + textColor: '#f8fafc', + }, + render: (params) => { + const rawTitle = + typeof params.title === 'string' && params.title.trim() ? params.title.trim() : DEFAULT_TITLE; + const title = truncate(rawTitle, 130); + const titleSize = autoFontSize(rawTitle, [ + { maxLen: 35, size: 54 }, + { maxLen: 65, size: 44 }, + { maxLen: 95, size: 36 }, + { maxLen: 130, size: 30 }, + ]); + + const code = typeof params.code === 'string' ? truncate(params.code.trim(), 75) : ''; + const tag = typeof params.tag === 'string' ? truncate(params.tag.trim(), 24) : ''; + const date = typeof params.date === 'string' ? truncate(params.date.trim(), 32) : ''; + const siteName = typeof params.siteName === 'string' ? truncate(params.siteName.trim(), 40) : ''; + + const bgColor = String(params.bgColor || '#0f172a'); + const accentColor = String(params.accentColor || '#38bdf8'); + const textColor = String(params.textColor || '#f8fafc'); + + const lightBg = isLightColor(bgColor); + const accentInk = lightBg ? mixColors(accentColor, '#000000', 0.45) : accentColor; + const bodyText = lightBg && isLightColor(textColor) ? '#0f172a' : textColor; + const mutedText = lightBg ? '#475569' : `${textColor}99`; + + const pillBg = lightBg ? mixColors(bgColor, bodyText, 0.06) : `${bodyText}14`; + const pillBorder = lightBg ? mixColors(bgColor, bodyText, 0.2) : `${bodyText}28`; + const pillText = lightBg ? mixColors(bgColor, bodyText, 0.75) : mutedText; + + const codeBoxBg = lightBg ? mixColors(bgColor, '#000000', 0.05) : `${bodyText}0D`; + const codeBoxBorder = lightBg ? mixColors(bgColor, '#000000', 0.15) : `${bodyText}26`; + + const hasFooter = Boolean(date || siteName); + + return { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + width: '1200px', + height: '630px', + padding: '58px 72px', + boxSizing: 'border-box', + backgroundColor: bgColor, + fontFamily: 'Inter, sans-serif', + color: bodyText, + }, + children: [ + { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + }, + children: [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + gap: '14px', + }, + children: [ + { + type: 'span', + props: { + style: { + fontSize: '20px', + fontWeight: 700, + color: accentInk, + letterSpacing: '0.08em', + textTransform: 'uppercase', + }, + children: 'Today I learned', + }, + }, + ...(tag + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + padding: '4px 12px', + borderRadius: '9999px', + backgroundColor: pillBg, + border: `1px solid ${pillBorder}`, + }, + children: { + type: 'span', + props: { + style: { + fontSize: '15px', + fontWeight: 600, + color: pillText, + fontFamily: 'JetBrains Mono, monospace', + }, + children: tag, + }, + }, + }, + }, + ] + : []), + ], + }, + }, + { + type: 'h1', + props: { + style: { + fontSize: `${titleSize}px`, + lineHeight: 1.2, + fontWeight: 800, + color: bodyText, + margin: '36px 0 0', + maxWidth: '1050px', + letterSpacing: '-0.02em', + wordBreak: 'break-word', + }, + children: title, + }, + }, + ...(code + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + alignSelf: 'flex-start', + marginTop: '34px', + padding: '18px 24px', + borderRadius: '12px', + backgroundColor: codeBoxBg, + border: `1px solid ${codeBoxBorder}`, + boxSizing: 'border-box', + maxWidth: '1050px', + }, + children: { + type: 'span', + props: { + style: { + fontSize: '22px', + lineHeight: 1.35, + color: accentInk, + fontFamily: 'JetBrains Mono, monospace', + wordBreak: 'break-word', + }, + children: code, + }, + }, + }, + }, + ] + : []), + ], + }, + }, + ...(hasFooter + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + justifyContent: + date && siteName ? 'space-between' : date ? 'flex-start' : 'flex-end', + width: '100%', + }, + children: [ + ...(date + ? [ + { + type: 'span', + props: { + style: { + fontSize: '18px', + fontWeight: 500, + color: mutedText, + }, + children: date, + }, + }, + ] + : []), + ...(siteName + ? [ + { + type: 'span', + props: { + style: { + fontSize: '18px', + fontWeight: 600, + color: mutedText, + }, + children: siteName, + }, + }, + ] + : []), + ], + }, + }, + ] + : []), + ], + }, + }; + }, +}; + +export default template; diff --git a/tests/templates/til.test.ts b/tests/templates/til.test.ts new file mode 100644 index 0000000..d47a06c --- /dev/null +++ b/tests/templates/til.test.ts @@ -0,0 +1,160 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import satori from 'satori'; +import { describe, expect, it } from 'vitest'; +import til from '@/templates/tutorial/til'; +import { getTemplate, getTemplatesByCategory } from '@/templates/registry'; +import type { SatoriElement } from '@/templates/types'; + +const fonts = [ + { + name: 'Inter', + data: readFileSync(join(process.cwd(), 'public/fonts/Inter-Regular.woff')), + weight: 400 as const, + style: 'normal' as const, + }, + { + name: 'Inter', + data: readFileSync(join(process.cwd(), 'public/fonts/Inter-Bold.woff')), + weight: 700 as const, + style: 'normal' as const, + }, + { + name: 'JetBrains Mono', + data: readFileSync(join(process.cwd(), 'public/fonts/JetBrainsMono-Regular.woff')), + weight: 400 as const, + style: 'normal' as const, + }, +]; + +function isSatoriElement(value: unknown): value is SatoriElement { + return typeof value === 'object' && value !== null && 'type' in value && 'props' in value; +} + +function getChildren(element: SatoriElement): SatoriElement[] { + const children = element.props.children; + return Array.isArray(children) ? children.filter(isSatoriElement) : isSatoriElement(children) ? [children] : []; +} + +function getAllElements(element: SatoriElement): SatoriElement[] { + return [element, ...getChildren(element).flatMap(getAllElements)]; +} + +function texts(node: unknown): string[] { + if (typeof node === 'string') return [node]; + if (Array.isArray(node)) return node.flatMap(texts); + if (node && typeof node === 'object' && 'props' in node) { + return texts((node as SatoriElement).props.children); + } + return []; +} + +const render = (overrides: Record = {}) => + til.render({ ...til.defaults, ...overrides }); +const serialized = (overrides: Record = {}) => JSON.stringify(render(overrides)); + +describe('tutorial-til', () => { + it('is registered in tutorial category and re-exported', async () => { + expect(getTemplate('tutorial-til')?.category).toBe('tutorial'); + expect(getTemplatesByCategory('tutorial')).toContain(til); + const exports = await import('@/templates/tutorial'); + expect(exports.tutorialTil.id).toBe('tutorial-til'); + }); + + it('renders default label, tag pill, title, code command, date, and site name', () => { + const rendered = texts(render()); + expect(rendered).toContain('Today I learned'); + expect(rendered).toContain('git'); + expect(rendered).toContain('git switch - takes you back to the branch you were just on'); + expect(rendered).toContain('git switch -'); + expect(rendered).toContain('Oct 27, 2026'); + expect(rendered).toContain('codercops.com'); + }); + + it('hides empty code, tag, date, and site name cleanly when empty or only spaces', () => { + const tree = serialized({ + title: 'A useful shortcut for switching branches', + code: ' ', + tag: ' ', + date: '', + siteName: ' ', + }); + expect(tree).not.toContain('git switch -'); + expect(tree).not.toContain('Oct 27, 2026'); + expect(tree).not.toContain('codercops.com'); + const all = getAllElements(render({ code: ' ', tag: ' ', date: '', siteName: '' })); + expect(all.some((el) => el.type === 'span' && el.props.children === 'git')).toBe(false); + }); + + it('shrinks long title with autoFontSize and truncates cleanly', () => { + const longTitle = + 'In Python 3.10 and newer you can use pattern matching with match-case statements to cleanly destructure complex nested dictionaries and tuples in one go'; + const element = render({ title: longTitle }); + const all = getAllElements(element); + const titleEl = all.find((el) => el.type === 'h1'); + expect(titleEl).toBeDefined(); + expect(titleEl?.props.style?.fontSize).toBe('30px'); + const content = String(titleEl?.props.children); + expect(content).toContain('…'); + expect(content.length).toBeLessThanOrEqual(130); + }); + + it('truncates long commands inside its box without running off', () => { + const longCode = + 'git log --graph --pretty=format:"%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset" --abbrev-commit'; + const element = render({ code: longCode }); + const all = getAllElements(element); + const codeBox = all.find( + (el) => + el.type === 'div' && + el.props.style?.fontFamily === undefined && + el.props.style?.borderRadius === '12px' && + el.props.style?.maxWidth === '1050px', + ); + expect(codeBox).toBeDefined(); + + const codeSpan = all.find( + (el) => + el.type === 'span' && + el.props.style?.fontFamily === 'JetBrains Mono, monospace' && + el.props.style?.fontSize === '22px', + ); + expect(codeSpan).toBeDefined(); + const content = String(codeSpan?.props.children); + expect(content).toContain('…'); + expect(content.length).toBeLessThanOrEqual(75); + }); + + it('stays readable on a light background with appropriate text and accent colors', () => { + const lightTree = render({ + bgColor: '#ffffff', + textColor: '#111827', + accentColor: '#38bdf8', + }); + const all = getAllElements(lightTree); + + const labelSpan = all.find( + (el) => el.type === 'span' && el.props.children === 'Today I learned', + ); + expect(labelSpan?.props.style?.color).toBe('#1f6888'); + + const codeSpan = all.find( + (el) => + el.type === 'span' && + el.props.style?.fontFamily === 'JetBrains Mono, monospace' && + el.props.style?.fontSize === '22px', + ); + expect(codeSpan?.props.style?.color).toBe('#1f6888'); + }); + + it('uses flex display on every div for Satori compatibility', () => { + const element = render(til.defaults); + const divs = getAllElements(element).filter((child) => child.type === 'div'); + expect(divs.every((child) => child.props.style?.display === 'flex')).toBe(true); + }); + + it('renders a valid SVG image via Satori', async () => { + const element = render(til.defaults); + await expect(satori(element as any, { width: 1200, height: 630, fonts })).resolves.toMatch(/^