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.
Choosing one
Section titled “Choosing one”| 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
Section titled “seoEntry”seoEntry(config: { defaults: Seo; title?: string; blurb?: string }): EntryFields: 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
Section titled “openGraphEntry”openGraphEntry(config: { defaults: OpenGraph; title?: string; blurb?: string }): EntryFields: 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
Section titled “menuEntry”menuEntry(config: { title: string; blurb?: string; singular?: string; defaults?: Menu;}): EntrySchema: { 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
Section titled “socialEntry”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;}): Entrynetworks 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
Section titled “announcementEntry”announcementEntry(config?: { defaults?: Announcement; title?: string; blurb?: string; linkHelp?: string;}): EntrySchema: { 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
Section titled “footerEntry”footerEntry(config?: { defaults?: Footer; title?: string; blurb?: string }): EntrySchema: { 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
Section titled “faqEntry”faqEntry(config?: { defaults?: typeof FAQ_DEFAULTS; title?: string; blurb?: string; singular?: string; linkHelp?: string;}): EntryBuilt 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>))}listSection
Section titled “listSection”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: ...;}): EntryYou 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.
Item schema rules
Section titled “Item schema rules”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.
The generics
Section titled “The generics”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.