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
19 changes: 8 additions & 11 deletions src/components/editor/useEditorState.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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(
Expand Down
15 changes: 14 additions & 1 deletion src/lib/api-validation.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -130,7 +131,8 @@ const ogQuerySchemaKeys = new Set(Object.keys(ogQuerySchema.shape));
export function mergeOgParams(
params: OGQueryParams,
defaults: Record<string, string | number | boolean>,
searchParams: URLSearchParams
searchParams: URLSearchParams,
fields: TemplateField[] = []
): Record<string, string | number | boolean> {
const merged: Record<string, string | number | boolean> = { ...defaults };

Expand All @@ -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;
}
26 changes: 26 additions & 0 deletions src/lib/url-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,32 @@ export function buildOgUrl(params: Record<string, any>): 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<string, any>,
defaults: Record<string, string | number | boolean> = {}
): 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.
*/
Expand Down
1 change: 1 addition & 0 deletions src/pages/api-docs.astro
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ import '@/styles/api-docs.css';
<p class="param-desc">Image height in pixels, 105–1260 (default: 630). If <code>width</code> 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).</p>
</div>
</div>
<p class="endpoint-desc">Each template also takes its own fields as parameters (see <code>/api/templates</code>). Leave a parameter out to get the template's default. An optional text field sent empty, like <code>subscriberCount=</code>, stays empty, so it's hidden.</p>

<h4>Examples</h4>
<div class="code-block">
Expand Down
5 changes: 3 additions & 2 deletions src/pages/api/og.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,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);
Expand Down
63 changes: 63 additions & 0 deletions tests/api/og.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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<string, string | number | boolean> = {
...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`
Expand Down Expand Up @@ -598,3 +640,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();
}
});
});
26 changes: 26 additions & 0 deletions tests/lib/url-builder.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
12 changes: 6 additions & 6 deletions tests/templates/api-endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | number | boolean>) {
Expand Down Expand Up @@ -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: '' }));
});
});
});
Loading