diff --git a/src/templates/registry.ts b/src/templates/registry.ts index 9474864..8015256 100644 --- a/src/templates/registry.ts +++ b/src/templates/registry.ts @@ -320,9 +320,11 @@ import tutorialCheatSheet from './tutorial/cheat-sheet'; import tutorialBeforeAfter from './tutorial/before-after'; import tutorialGlossary from './tutorial/glossary'; import tutorialPrerequisites from './tutorial/prerequisites'; +import tutorialErrorExplained from './tutorial/error-explained'; [ tutorialCourseCard, tutorialStepByStep, tutorialVideoLesson, tutorialDifficultyBadge, tutorialPlatformBranded, tutorialCodeTutorial, tutorialWorkshopBanner, tutorialCertification, tutorialQuizCard, tutorialCheatSheet, tutorialBeforeAfter, tutorialGlossary, tutorialPrerequisites, + tutorialErrorExplained, ].forEach(registerTemplate); diff --git a/src/templates/tutorial/error-explained.ts b/src/templates/tutorial/error-explained.ts new file mode 100644 index 0000000..4474b16 --- /dev/null +++ b/src/templates/tutorial/error-explained.ts @@ -0,0 +1,343 @@ +import type { SatoriElement, TemplateDefinition } from '../types'; +import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils'; + +const DEFAULT_TITLE = "TypeError: Cannot read properties of undefined (reading 'map')"; + +const template: TemplateDefinition = { + id: 'tutorial-error-explained', + name: 'Error Explained', + category: 'tutorial', + description: 'Explain a technical error message with what it means and how to fix it', + tags: ['error', 'debug', 'troubleshooting', 'tutorial', 'code'], + fields: [ + { + ...commonFields.title, + label: 'Error Message', + defaultValue: DEFAULT_TITLE, + placeholder: DEFAULT_TITLE, + }, + { + key: 'language', + label: 'Language', + type: 'text', + defaultValue: 'JavaScript', + placeholder: 'JavaScript', + group: 'Content', + }, + { + key: 'meaning', + label: 'Meaning', + type: 'text', + defaultValue: "You called .map() on something that isn't an array yet", + placeholder: "You called .map() on something that isn't an array yet", + group: 'Content', + }, + { + key: 'fix', + label: 'Fix', + type: 'text', + defaultValue: 'Start the state as [] or check before mapping', + placeholder: 'Start the state as [] or check before mapping', + group: 'Content', + }, + commonFields.siteName, + commonFields.bgColor, + commonFields.accentColor, + commonFields.textColor, + ], + defaults: { + title: DEFAULT_TITLE, + language: 'JavaScript', + meaning: "You called .map() on something that isn't an array yet", + fix: 'Start the state as [] or check before mapping', + 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, 120); + const titleSize = autoFontSize(rawTitle, [ + { maxLen: 40, size: 36 }, + { maxLen: 70, size: 30 }, + { maxLen: 95, size: 25 }, + { maxLen: 120, size: 21 }, + ]); + + const language = typeof params.language === 'string' ? truncate(params.language.trim(), 24) : ''; + const meaning = typeof params.meaning === 'string' ? truncate(params.meaning.trim(), 140) : ''; + const fix = typeof params.fix === 'string' ? truncate(params.fix.trim(), 140) : ''; + 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 errorBoxBg = lightBg ? mixColors('#ef4444', bgColor, 0.92) : mixColors('#ef4444', bgColor, 0.88); + const errorBoxBorder = lightBg ? mixColors('#ef4444', bgColor, 0.55) : mixColors('#ef4444', bgColor, 0.65); + const errorTextColor = lightBg ? '#991b1b' : '#fca5a5'; + + 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 explanationRows: SatoriElement[] = [ + ...(meaning + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'flex-start', + gap: '20px', + }, + children: [ + { + type: 'span', + props: { + style: { + fontSize: '18px', + fontWeight: 700, + color: mutedText, + textTransform: 'uppercase', + letterSpacing: '0.08em', + flexShrink: 0, + width: '105px', + marginTop: '4px', + }, + children: 'Meaning', + }, + }, + { + type: 'span', + props: { + style: { + fontSize: '25px', + lineHeight: 1.4, + color: bodyText, + flex: 1, + }, + children: meaning, + }, + }, + ], + }, + }, + ] + : []), + ...(fix + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'flex-start', + gap: '20px', + }, + children: [ + { + type: 'span', + props: { + style: { + fontSize: '18px', + fontWeight: 700, + color: accentInk, + textTransform: 'uppercase', + letterSpacing: '0.08em', + flexShrink: 0, + width: '105px', + marginTop: '4px', + }, + children: 'Fix', + }, + }, + { + type: 'span', + props: { + style: { + fontSize: '25px', + fontWeight: 500, + lineHeight: 1.4, + color: bodyText, + flex: 1, + }, + children: fix, + }, + }, + ], + }, + }, + ] + : []), + ]; + + return { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + width: '1200px', + height: '630px', + padding: '60px 72px', + boxSizing: 'border-box', + backgroundColor: bgColor, + fontFamily: 'Inter, sans-serif', + color: bodyText, + }, + children: [ + { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + flex: 1, + }, + children: [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + gap: '16px', + marginBottom: '26px', + }, + children: [ + { + type: 'span', + props: { + style: { + fontSize: '18px', + fontWeight: 700, + color: accentInk, + letterSpacing: '0.08em', + textTransform: 'uppercase', + }, + children: 'Error explained', + }, + }, + ...(language + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + padding: '5px 14px', + borderRadius: '9999px', + backgroundColor: pillBg, + border: `1px solid ${pillBorder}`, + }, + children: { + type: 'span', + props: { + style: { + fontSize: '15px', + fontWeight: 500, + color: pillText, + fontFamily: 'JetBrains Mono, monospace', + }, + children: language, + }, + }, + }, + }, + ] + : []), + ], + }, + }, + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + padding: '28px 32px', + borderRadius: '14px', + backgroundColor: errorBoxBg, + border: `1px solid ${errorBoxBorder}`, + boxSizing: 'border-box', + overflow: 'hidden', + }, + children: { + type: 'span', + props: { + style: { + fontSize: `${titleSize}px`, + fontFamily: 'JetBrains Mono, monospace', + fontWeight: 600, + lineHeight: 1.35, + color: errorTextColor, + wordBreak: 'break-word', + }, + children: title, + }, + }, + }, + }, + ...(explanationRows.length > 0 + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + gap: '20px', + marginTop: '32px', + }, + children: explanationRows, + }, + }, + ] + : []), + ], + }, + }, + ...(siteName + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + paddingTop: '16px', + }, + children: { + type: 'span', + props: { + style: { + fontSize: '18px', + fontWeight: 500, + color: mutedText, + }, + children: siteName, + }, + }, + }, + }, + ] + : []), + ], + }, + }; + }, +}; + +export default template; diff --git a/src/templates/tutorial/index.ts b/src/templates/tutorial/index.ts index 0c13e54..78e8354 100644 --- a/src/templates/tutorial/index.ts +++ b/src/templates/tutorial/index.ts @@ -11,3 +11,4 @@ export { default as tutorialCheatSheet } from './cheat-sheet'; 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'; diff --git a/tests/templates/error-explained.test.ts b/tests/templates/error-explained.test.ts new file mode 100644 index 0000000..3e824e0 --- /dev/null +++ b/tests/templates/error-explained.test.ts @@ -0,0 +1,158 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import satori from 'satori'; +import { describe, expect, it } from 'vitest'; +import errorExplained from '@/templates/tutorial/error-explained'; +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 = {}) => + errorExplained.render({ ...errorExplained.defaults, ...overrides }); +const serialized = (overrides: Record = {}) => JSON.stringify(render(overrides)); + +describe('tutorial-error-explained', () => { + it('is registered in tutorial category and re-exported', async () => { + expect(getTemplate('tutorial-error-explained')?.category).toBe('tutorial'); + expect(getTemplatesByCategory('tutorial')).toContain(errorExplained); + const exports = await import('@/templates/tutorial'); + expect(exports.tutorialErrorExplained.id).toBe('tutorial-error-explained'); + }); + + it('renders default error message, language pill, meaning, and fix', () => { + const rendered = texts(render()); + expect(rendered).toContain('Error explained'); + expect(rendered).toContain('JavaScript'); + expect(rendered).toContain("TypeError: Cannot read properties of undefined (reading 'map')"); + expect(rendered).toContain('Meaning'); + expect(rendered).toContain("You called .map() on something that isn't an array yet"); + expect(rendered).toContain('Fix'); + expect(rendered).toContain('Start the state as [] or check before mapping'); + }); + + it('renders site name when provided', () => { + const tree = serialized({ siteName: 'codercops.com' }); + expect(tree).toContain('codercops.com'); + }); + + it('hides empty language, meaning, fix, and site name cleanly when empty or only spaces', () => { + const tree = serialized({ + language: ' ', + meaning: ' ', + fix: '', + siteName: ' ', + }); + expect(tree).not.toContain('JavaScript'); + expect(tree).not.toContain('Meaning'); + expect(tree).not.toContain('Fix'); + expect(tree).not.toContain('codercops.com'); + }); + + it('shrinks long error messages with autoFontSize and truncates inside the box', () => { + const longError = + 'Uncaught TypeError: Failed to execute fetch on Window: Failed to parse URL from /api/v1/users/undefined/preferences/notifications?enabled=true'; + const element = render({ title: longError }); + const all = getAllElements(element); + const errorSpan = all.find( + (el) => el.type === 'span' && el.props.style?.wordBreak === 'break-word', + ); + + expect(errorSpan).toBeDefined(); + expect(errorSpan?.props.style?.fontSize).toBe('21px'); + const content = String(errorSpan?.props.children); + expect(content).toContain('…'); + expect(content.length).toBeLessThanOrEqual(120); + }); + + it('stays readable on a light background with appropriate text and box colors', () => { + const lightTree = render({ + bgColor: '#ffffff', + textColor: '#111827', + accentColor: '#38bdf8', + }); + const all = getAllElements(lightTree); + + const errorSpan = all.find( + (el) => el.type === 'span' && el.props.style?.wordBreak === 'break-word', + ); + expect(errorSpan?.props.style?.color).toBe('#991b1b'); + + const labelSpan = all.find( + (el) => el.type === 'span' && el.props.children === 'Error explained', + ); + expect(labelSpan?.props.style?.color).toBe('#1f6888'); + }); + + it('uses flex display on every div for Satori compatibility', () => { + const element = render(errorExplained.defaults); + const divs = getAllElements(element).filter((child) => child.type === 'div'); + expect(divs.every((child) => child.props.style?.display === 'flex')).toBe(true); + }); + + it('aligns explanation labels with the first line using flex-start', () => { + const element = render({ + meaning: + 'You called .map() on a value that is still undefined, usually because the data has not loaded yet', + }); + const all = getAllElements(element); + const meaningLabel = all.find( + (el) => el.type === 'span' && el.props.children === 'Meaning', + ); + expect(meaningLabel?.props.style?.marginTop).toBe('4px'); + + const rows = all.filter( + (el) => + el.type === 'div' && + el.props.style?.gap === '20px' && + el.props.style?.alignItems === 'flex-start', + ); + expect(rows.length).toBe(2); + }); + + it('renders a valid SVG image via Satori', async () => { + const element = render(errorExplained.defaults); + await expect(satori(element as any, { width: 1200, height: 630, fonts })).resolves.toMatch(/^