Skip to content

Entry factories

An Entry is a Zod schema, a form and a defaults object. You can write one by hand, and for anything specific to your site you should. These factories exist for the entries that come out the same on every site.

They return ordinary entries. Nothing downstream can tell a composed entry from a hand-written one: not the form reader, the revision diff, the assistant, or the MCP server.

Factory Import from Use for
seoEntry @ouncepage/core/seo Browser title, meta description, keywords
openGraphEntry @ouncepage/core/seo The card shown when a link is pasted
menuEntry @ouncepage/core/chrome Any navigation menu
socialEntry @ouncepage/core/chrome Profile links with an icon
announcementEntry @ouncepage/core/chrome A site-wide message with a toggle
footerEntry @ouncepage/core/chrome One rich text block
faqEntry @ouncepage/core/faq Repeating questions and answers
listSection @ouncepage/core/shapes Any section that is a heading plus a repeating list

Every one takes optional title and blurb to override what the admin shows above the form. The blurb is not decoration: the assistant reads it to decide whether a section is the right home for what it has been asked to write.


seoEntry(config: { defaults: Seo; title?: string; blurb?: string }): Entry

Fields: title, description, keywords (a stringlist), and a live search result preview.

seo: seoEntry({
defaults: {
title: 'Example | A company that does things',
description: 'One sentence, about 155 characters, that reads like a person wrote it.',
keywords: ['example', 'things'],
},
}),

A page overrides any of these through its own SEO tab. The setting is the fallback, not the value.

openGraphEntry(config: { defaults: OpenGraph; title?: string; blurb?: string }): Entry

Fields: image (cropped to 1200x630 by the photo editor), title, description, and a live card preview.

Leaving title or description empty falls through to the SEO entry’s values, which is usually what you want. Two sets of nearly identical copy drift apart.

menuEntry(config: {
title: string;
blurb?: string;
singular?: string;
defaults?: Menu;
}): Entry

Schema: { enabled, items: { label, page, anchor, href, enabled }[] }.

An item points at either a page or an external address, never both, and the schema refuses one that sets neither. page is a page id, resolved to that page’s current slug when the menu renders, so renaming a page keeps its links working. anchor is an element id on that page, and only applies with page. href is validated as a safe URL, so https://, mailto:, tel:, / and # pass and nothing else does.

navigation: menuEntry({
title: 'Main navigation',
defaults: {
items: [
{ label: 'Home', page: 1, anchor: '', href: '', enabled: true },
{ label: 'Story', page: 1, anchor: 'ounce-ourStory', href: '', enabled: true },
{ label: 'Handbook', page: null, anchor: '', href: 'https://example.com', enabled: true },
],
},
}),

enabled on the menu hides it everywhere at once without losing the links. enabled on an item hides that one link. Both are stripped before the template sees the menu, so a template never has to filter.

See Navigation for the regions a menu can fill and the menu a single page can define for itself.

Menus have no per-item history. Paths into a menu are index based, so a trail would follow position rather than link and mislead after any reorder.

socialEntry<const Icon extends string>(config: {
networks: readonly { value: Icon; label: string }[];
defaults: { heading: string; items: { link, label, icon: Icon, enabled }[] };
title?: string;
blurb?: string;
}): Entry

networks does two jobs. It fills the icon select in the admin, and it becomes a literal union in the type, so a template that indexes a lookup by network.icon typechecks and a typo in defaults is a compile error.

social: socialEntry({
networks: [
{ value: 'facebook', label: 'Facebook' },
{ value: 'instagram', label: 'Instagram' },
],
defaults: {
heading: 'Keep up with us',
items: [
{ link: 'https://instagram.com/example', label: 'Instagram', icon: 'instagram', enabled: true },
],
},
}),
const paths: Record<'facebook' | 'instagram', string> = { facebook: '...', instagram: '...' };
// network.icon is 'facebook' | 'instagram', so this index is safe
<path d={paths[network.icon]} />

Passing an empty networks throws at config time. Zod would otherwise build an enum with no members and fail every save with “Invalid option: expected one of”, a message that stops mid-sentence because there is nothing to list.

defaults.heading also becomes the schema default, so a stored row that predates the field gets your heading rather than an empty string.

announcementEntry(config?: {
defaults?: Announcement;
title?: string;
blurb?: string;
linkHelp?: string;
}): Entry

Schema: { enabled: toggle, message: text, button: { link, label } }. Defaults to off with empty text, so announcementEntry() on its own is valid.

linkHelp replaces the help text under the button’s link input. Use it for a site convention, for example telling editors that #book reaches the booking link.

The toggle is the point. Turning an announcement off keeps the text, so next month’s campaign starts from last month’s wording instead of a blank field.

footerEntry(config?: { defaults?: Footer; title?: string; blurb?: string }): Entry

Schema: { text }, rendered as rich text.

The form’s blurb tells editors that {year} inserts the current year, but the substitution is your template’s job and the engine does not do it for you. One line:

const text = footer.text.replaceAll('{year}', String(new Date().getFullYear()));

Skip it and editors see a literal {year} on the live site, having been told by the admin that it would work.

The sanitiser allows a[href], so an editor can link the privacy page from the footer without any code. Give footer a an underline rule in your stylesheet, because it inherits whatever color the surrounding footer text uses.

faqEntry(config?: {
defaults?: typeof FAQ_DEFAULTS;
title?: string;
blurb?: string;
singular?: string;
linkHelp?: string;
}): Entry

Built on listSection. Gives you an eyebrow, a heading, an intro, a button and an items list of { question, answer }, where the answer is rich text.

This exists because of a real gap. Asked to build an FAQ, an assistant will correctly refuse to repurpose an unrelated section, and prose blocks render one full-width section per block rather than a compact list. faqEntry is the shape that was missing, not a new field type. It needs no migration.

Your template owns the accordion. A <details> and <summary> pair needs no JavaScript:

{faq.items.map((item) => (
<details>
<summary>{item.question}</summary>
<div set:html={item.answer} />
</details>
))}

The shape the last few are built from, and the one to reach for when a section is a heading plus a repeating list of anything.

listSection(config: {
title: string;
blurb?: string;
eyebrow?: { label?, help?, ai? };
heading?: { label?, help?, ai? };
intro?: { label?, rows?, help?, ai? };
list: {
name: string; // the key the array is stored under
label: string; // the repeater's heading in the admin
singular: string; // the word on the Add button
titleField: string; // which item field names the collapsed row
schema: ZodRawShape; // the item's fields, without enabled
fields: Field[]; // the item's inputs
};
extras?: { schema?: ZodRawShape; fields?: Field[] };
button?: { label?, textLabel?, linkLabel?, linkHelp?, ai? };
defaults: ...;
}): Entry

You always get eyebrow, heading, intro and button. heading is required, the rest default to empty. enabled is added to every item for you, so an editor can hide one without deleting it.

benefits: listSection({
title: 'Benefits',
blurb: 'Why someone should join. One card per reason.',
list: {
name: 'features',
label: 'Benefits',
singular: 'benefit',
titleField: 'title',
schema: { title: required, description: text.default('') },
fields: [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'description', label: 'Description', type: 'textarea', rows: 3 },
],
},
defaults: {
eyebrow: '',
heading: 'Why join',
intro: '',
button: { link: '', label: '' },
features: [],
},
}),

extras adds fields beside the list, for a section that needs one more thing: a background image, a layout select. Both halves are optional and the schema half flows into the inferred type.

Give every non-identifying item field a .default(). Appending an item writes a whole new object. A field with no default and no value rejects the write. title: required failing is correct, because an untitled item is not useful. description failing is not.

Do not put a dot in a field name. See Silent failures.

listSection is declared with const Name extends string and Extra extends ZodRawShape | undefined = undefined. Both are load-bearing.

Without const, Name widens to string and the returned type has an index signature instead of your list’s actual key. Without the | undefined default, Extra falls back to its ZodRawShape constraint when you omit it, and that constraint’s index signature flattens z.infer all the way to {}. The conditional Extra extends ZodRawShape ? Extra : {} inside the shape type is what keeps both cases correct.

There are two casts at the zod boundary. They are not ideal and they are known.