From b894c353b7222d46cff49a2c320103b811cbd8f0 Mon Sep 17 00:00:00 2001 From: anurag629 <78868769+anurag629@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:10:38 +0530 Subject: [PATCH 1/2] fix: keep a cleared field empty in the downloaded image Clearing an optional field in the editor hid it in the preview, but the download and API URLs left the empty value out, so the API put the default back. The editor now sends a cleared field as key= when its default isn't empty, and the API keeps an optional text field that's sent empty. Leaving a param out still gives the default, and empty templates, image URLs, required fields, colors and numbers still fall back like before. --- src/components/editor/useEditorState.ts | 19 ++++---- src/lib/api-validation.ts | 15 +++++- src/lib/url-builder.ts | 26 ++++++++++ src/pages/api-docs.astro | 1 + src/pages/api/og.ts | 5 +- tests/api/og.test.ts | 63 +++++++++++++++++++++++++ tests/lib/url-builder.test.ts | 26 ++++++++++ 7 files changed, 141 insertions(+), 14 deletions(-) create mode 100644 tests/lib/url-builder.test.ts diff --git a/src/components/editor/useEditorState.ts b/src/components/editor/useEditorState.ts index 517d613..e2c89bd 100644 --- a/src/components/editor/useEditorState.ts +++ b/src/components/editor/useEditorState.ts @@ -1,5 +1,7 @@ import { useReducer, useMemo, useCallback } from 'react'; import type { TemplateDefinition, TemplateCategory } from '@/templates/types'; +import { getTemplate } from '@/templates/registry'; +import { buildOgQuery } from '@/lib/url-builder'; export interface EditorState { templateId: string; @@ -80,17 +82,12 @@ export function useEditorState(initialTemplate: TemplateDefinition) { dispatch({ type: 'SET_SEARCH', query }); }, []); - // Build query string shared by both URLs - const queryString = useMemo(() => { - const params = new URLSearchParams(); - params.set('template', state.templateId); - for (const [key, value] of Object.entries(state.params)) { - if (value !== undefined && value !== null && value !== '') { - params.set(key, String(value)); - } - } - return params.toString(); - }, [state.templateId, state.params]); + // Build query string shared by both URLs. A field cleared in the editor goes + // in as `key=`, so the downloaded image matches the preview. + const queryString = useMemo( + () => buildOgQuery(state.templateId, state.params, getTemplate(state.templateId)?.defaults), + [state.templateId, state.params] + ); // Production URL for sharing / meta tags const apiUrl = useMemo( diff --git a/src/lib/api-validation.ts b/src/lib/api-validation.ts index 7e8053e..f314ec4 100644 --- a/src/lib/api-validation.ts +++ b/src/lib/api-validation.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import type { TemplateField } from '@/templates/types'; // An empty value counts as missing, so `?imageUrl=` (a URL built from CMS data // for a post with no cover image) falls back to the default instead of failing. @@ -130,7 +131,8 @@ const ogQuerySchemaKeys = new Set(Object.keys(ogQuerySchema.shape)); export function mergeOgParams( params: OGQueryParams, defaults: Record, - searchParams: URLSearchParams + searchParams: URLSearchParams, + fields: TemplateField[] = [] ): Record { const merged: Record = { ...defaults }; @@ -147,5 +149,16 @@ export function mergeOgParams( } }); + // An optional text field sent empty (`?subscriberCount=`) stays empty instead + // of falling back to its default. The editor sends that when someone clears + // a field, so the image matches the preview. Leaving the param out still + // gives the default, and every other empty value (the template, image URLs, + // required fields, colors, numbers) falls back like before. + for (const field of fields) { + if ((field.type === 'text' || field.type === 'textarea') && !field.required && searchParams.get(field.key) === '') { + merged[field.key] = ''; + } + } + return merged; } diff --git a/src/lib/url-builder.ts b/src/lib/url-builder.ts index f8b1caa..85a017d 100644 --- a/src/lib/url-builder.ts +++ b/src/lib/url-builder.ts @@ -15,6 +15,32 @@ export function buildOgUrl(params: Record): string { return url.toString(); } +/** + * The query string for the editor's image URLs. Empty values are left out, + * except a field the user cleared while its default isn't empty: that one is + * kept as `key=`, so the API leaves it empty instead of using the default. + */ +export function buildOgQuery( + templateId: string, + params: Record, + defaults: Record = {} +): string { + const query = new URLSearchParams(); + query.set('template', templateId); + + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + if (value === '') { + const fallback = defaults[key]; + if (fallback !== undefined && fallback !== '') query.set(key, ''); + continue; + } + query.set(key, String(value)); + } + + return query.toString(); +} + /** * Build a thumbnail URL for a template. */ diff --git a/src/pages/api-docs.astro b/src/pages/api-docs.astro index 535949b..5d0f8d5 100644 --- a/src/pages/api-docs.astro +++ b/src/pages/api-docs.astro @@ -124,6 +124,7 @@ import '@/styles/api-docs.css';

Image height in pixels, 105–1260 (default: 630). If width is omitted, it is derived automatically to preserve the 1200:630 aspect ratio. If both dimensions are provided, their aspect ratio must match 1200:630 (returns 400 Bad Request otherwise).

+

Each template also takes its own fields as parameters (see /api/templates). Leave a parameter out to get the template's default. An optional text field sent empty, like subscriberCount=, stays empty, so it's hidden.

Examples

diff --git a/src/pages/api/og.ts b/src/pages/api/og.ts index 58f8f23..5ee869f 100644 --- a/src/pages/api/og.ts +++ b/src/pages/api/og.ts @@ -71,8 +71,9 @@ export const GET: APIRoute = async ({ request, locals }) => { try { // Merge template defaults with provided params. Schema params override - // defaults when non-empty; template-specific query params override too. - const mergedParams = mergeOgParams(params, template.defaults, url.searchParams); + // defaults when non-empty; template-specific query params override too, + // and an optional text field sent empty stays empty. + const mergedParams = mergeOgParams(params, template.defaults, url.searchParams, template.fields); const normalizedParams = normalizeColorFields(template, mergedParams); const element = template.render(normalizedParams); diff --git a/tests/api/og.test.ts b/tests/api/og.test.ts index 0711879..f438345 100644 --- a/tests/api/og.test.ts +++ b/tests/api/og.test.ts @@ -7,6 +7,7 @@ import { } from '@/lib/api-validation'; import { getTemplate, getAllTemplates } from '@/templates/registry'; import { normalizeColorFields } from '@/templates/utils'; +import type { TemplateField } from '@/templates/types'; describe('OG API Validation', () => { it('should validate valid params', () => { @@ -323,6 +324,47 @@ describe('mergeOgParams', () => { expect(merged.quote).toBe('Talk is cheap, show me the code.'); expect(merged.author).toBe(template!.defaults.author); }); + + describe('with the template fields', () => { + const fieldDefaults: Record = { + ...defaults, + title: 'Default title', + imageUrl: 'https://example.com/cover.png', + }; + const fields: TemplateField[] = [ + { key: 'title', label: 'Title', type: 'text', defaultValue: 'Default title', required: true, group: 'Content' }, + { key: 'quote', label: 'Quote', type: 'textarea', defaultValue: 'Default quote', group: 'Content' }, + { key: 'author', label: 'Author', type: 'text', defaultValue: 'Default Author', group: 'Content' }, + { key: 'bgColor', label: 'Background', type: 'color', defaultValue: '#0a0a0a', group: 'Style' }, + { key: 'imageUrl', label: 'Image', type: 'image', defaultValue: 'https://example.com/cover.png', group: 'Style' }, + ]; + + function mergeWithFields(query: string) { + const searchParams = new URLSearchParams(query); + const parsed = parseSearchParams(searchParams, ogQuerySchema); + if (!parsed.success) throw new Error(parsed.error); + return mergeOgParams(parsed.data, fieldDefaults, searchParams, fields); + } + + it('keeps an optional text field empty when it is sent empty', () => { + const merged = mergeWithFields('quote=&author='); + expect(merged.quote).toBe(''); + expect(merged.author).toBe(''); + }); + + it('still uses the default when the param is left out', () => { + const merged = mergeWithFields(''); + expect(merged.quote).toBe('Default quote'); + expect(merged.author).toBe('Default Author'); + }); + + it('falls back to the default for empty required fields, colors and images', () => { + const merged = mergeWithFields('title=&bgColor=&imageUrl='); + expect(merged.title).toBe('Default title'); + expect(merged.bgColor).toBe('#0a0a0a'); + expect(merged.imageUrl).toBe('https://example.com/cover.png'); + }); + }); }); // og-engine pulls in the resvg wasm binary through a Vite-only `?module` @@ -548,3 +590,24 @@ describe('OG API empty parameters fall back to the defaults', () => { expect(res.status).toBe(404); }); }); + +describe('OG API cleared fields', () => { + it('keeps an optional field the editor cleared empty, and uses the default when it is left out', async () => { + const template = getTemplate('newsletter-subscribe-card')!; + const render = vi.spyOn(template, 'render'); + + try { + const cleared = await GET(makeContext(`${BASE}?template=newsletter-subscribe-card&subscriberCount=`)); + expect(cleared.status).toBe(200); + expect(render).toHaveBeenLastCalledWith(expect.objectContaining({ subscriberCount: '' })); + + const omitted = await GET(makeContext(`${BASE}?template=newsletter-subscribe-card`)); + expect(omitted.status).toBe(200); + expect(render).toHaveBeenLastCalledWith( + expect.objectContaining({ subscriberCount: template.defaults.subscriberCount }) + ); + } finally { + render.mockRestore(); + } + }); +}); diff --git a/tests/lib/url-builder.test.ts b/tests/lib/url-builder.test.ts new file mode 100644 index 0000000..94eadf0 --- /dev/null +++ b/tests/lib/url-builder.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { buildOgQuery } from '@/lib/url-builder'; + +describe('buildOgQuery', () => { + const defaults = { title: 'Weekly Dev', subscriberCount: '12,400', frequency: '' }; + + it('puts the template first and keeps filled-in values', () => { + const query = buildOgQuery('newsletter-subscribe-card', { title: 'Hello', subscriberCount: '900' }, defaults); + expect(query).toBe('template=newsletter-subscribe-card&title=Hello&subscriberCount=900'); + }); + + it('keeps a cleared field as key= when its default is not empty', () => { + const query = buildOgQuery('newsletter-subscribe-card', { title: 'Hello', subscriberCount: '' }, defaults); + expect(new URLSearchParams(query).has('subscriberCount')).toBe(true); + expect(new URLSearchParams(query).get('subscriberCount')).toBe(''); + }); + + it('leaves out empty values whose default is empty too, and null or undefined ones', () => { + const query = buildOgQuery('newsletter-subscribe-card', { title: 'Hello', frequency: '', extra: undefined, other: null }, defaults); + expect(query).toBe('template=newsletter-subscribe-card&title=Hello'); + }); + + it('without defaults, leaves every empty value out like before', () => { + expect(buildOgQuery('blog-minimal-dark', { title: 'Hi', description: '' })).toBe('template=blog-minimal-dark&title=Hi'); + }); +}); From f9de776e50ad6ce98f46ad89c2a7e85e6277b7d2 Mon Sep 17 00:00:00 2001 From: anurag629 <78868769+anurag629@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:09:03 +0530 Subject: [PATCH 2/2] test: expect a cleared API Endpoint status to stay empty in the download --- tests/templates/api-endpoint.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/templates/api-endpoint.test.ts b/tests/templates/api-endpoint.test.ts index 64d89f4..97a6266 100644 --- a/tests/templates/api-endpoint.test.ts +++ b/tests/templates/api-endpoint.test.ts @@ -38,7 +38,7 @@ function luminance(hex: string) { function serverParams(query: URLSearchParams) { const parsed = parseSearchParams(query, ogQuerySchema); if (!parsed.success) throw new Error(parsed.error); - return mergeOgParams(parsed.data, apiEndpoint.defaults, query); + return mergeOgParams(parsed.data, apiEndpoint.defaults, query, apiEndpoint.fields); } function downloadParams(params: Record) { @@ -174,16 +174,16 @@ describe('saas-api-endpoint', () => { expect(apiEndpoint.render(download.params)).toEqual(apiEndpoint.render(state.params)); }); - it('preserves supplied status text and the existing cleared-status fallback', () => { + it('preserves supplied status text and keeps a cleared status empty, like the preview', () => { const supplied = downloadParams({ ...apiEndpoint.defaults, responseStatus: '201 Created' }); expect(supplied.params.responseStatus).toBe('201 Created'); expect(apiEndpoint.render(supplied.params)).toEqual(tree({ responseStatus: '201 Created' })); const cleared = downloadParams({ ...apiEndpoint.defaults, responseStatus: '' }); - expect(cleared.query.has('responseStatus')).toBe(false); - expect(cleared.params.responseStatus).toBe('200 OK'); - // Existing templates restore omitted status defaults on download too. - expect(apiEndpoint.render(cleared.params)).toEqual(tree()); + expect(cleared.query.get('responseStatus')).toBe(''); + expect(cleared.params.responseStatus).toBe(''); + // A status cleared in the editor stays empty in the download, the same as in the preview. + expect(apiEndpoint.render(cleared.params)).toEqual(tree({ responseStatus: '' })); }); }); });