Manifest: widget summary
A widget summary is one object in the root manifest’s widgets[] array. It is the entire
per-widget contract the platform stores: the slug that forms the keyword, the bundle paths,
whether the widget server-renders, where its config schema lives, and how the dashboard
presents it. Central caches the ROOT manifest and ships it with every render — your summary is
the only per-widget record the renderer ever sees.
Shape: Suite only. widgets[] exists in the multi-widget (suite) manifest shape and
nowhere else. In the single-widget (v1) shape the same information lives on root fields; see
The two manifest shapes for the discriminator and
Manifest: root fields for the root table. Requires
@homerunner-next/widget-core 0.11.0+ (see Install and versions).
| Suite summary field | v1 root equivalent |
|---|---|
widgets[].slug | widgetType (and the plugin id) |
widgets[].ssr | ssr (required; never false) |
widgets[].assets | assets |
widgets[].manifest → sub-manifest | inline root configSchema / uiSchema |
widgets[].category / slots / pages | no equivalent — v1 plugins are always content |
A summary’s keyword is plugin:{pluginSlug}:{widgetSlug}. Slugs only need to be unique inside
your plugin. Keyword grammar and asset URL resolution live in
Keywords, assets and URLs.
Enforcement levels used below — publish ERROR, publish warning, silent runtime truncation, advisory (unvalidated) — are defined once in Limits and error index.
Field index
Error strings are quoted verbatim from the dashboard’s submission audit
(src/lib/plugin-zip.ts); {slug} is your widget slug and widgets[N] the array index.
| Field | Type | Required (suite entry) | Rule | On violation |
|---|---|---|---|---|
slug | string | Required | /^[a-z0-9][a-z0-9-]*$/, unique in the plugin, not dist / widgets / media | publish ERROR — manifest widgets[N] needs a lowercase-alphanumeric-with-hyphens "slug". · manifest widget slug "{slug}" is reserved. · manifest declares widget slug "{slug}" more than once. |
name | string | Required | non-empty; shown in the picker and every admin surface | publish ERROR — manifest widget "{slug}" is missing "name". |
description | string | Optional | free text, shown under the name in the picker | advisory (unvalidated) — no length cap, no format check |
category | "content" | "layout" | Optional (default content) | exactly one of the two literals | publish ERROR — manifest widget "{slug}" has an unknown "category" (content | layout). |
ssr | { url: string } | false | Required | ssr.url a confined relative path, or the literal false for a CSR-only widget | publish ERROR — manifest widget "{slug}" needs "ssr.url" (relative path) or an explicit "ssr": false for CSR-only widgets. |
assets.js | string | Required | confined relative path to the client IIFE | publish ERROR — manifest widget "{slug}" needs a relative "assets.js" path inside the version prefix. |
assets.css | string | Optional | confined relative path | publish ERROR — manifest widget "{slug}" has an invalid "assets.css" path. |
assets.fonts | string[] | Optional (0.12.0+) | ≤ 8 entries, each an absolute http(s) URL or a confined relative path | publish ERROR — manifest widget "{slug}" "assets.fonts" must be a list of at most 8 stylesheets. · … entries must be absolute http(s) URLs or relative paths inside the version prefix. Extra/invalid entries are also dropped by silent runtime truncation |
manifest | string | Optional, in practice required | confined relative path to widgets/{slug}.manifest.json; the file must ship in the built zip | publish ERROR (shape) — manifest widget "{slug}" has an invalid sub-manifest path. · publish ERROR (missing at publish) — widgets/{slug}.manifest.json (widget "{slug}"'s sub-manifest) is missing from the zip or invalid JSON. |
icon | string | Optional (0.12.0+) | media/<file> (FLAT, no subdirectory) or an absolute http(s) URL, ≤ 2048 chars; a media/ file must ship in the built zip | publish ERROR — widget "{slug}" icon must be a "media/<file>" path or an absolute http(s) URL. · Declared widget "{slug}" icon {ref} is missing from the zip. |
readme | string | Optional (0.12.0+) | media/<file>.md or media/<dir>/<file>.md (exactly one optional directory), ≤ 255 chars, file ≤ 64 KB, must ship in the built zip | publish ERROR — widget "{slug}" readme must be a "media/<file>.md" (or "media/<dir>/<file>.md") path. · Declared widget "{slug}" readme {ref} is missing from the zip. · widget "{slug}" readme {ref} is {n} KB — the limit is 64 KB. |
expectedHeight | number | Optional | 0 <= n <= 10000 (px) | publish ERROR — manifest widget "{slug}" has an invalid "expectedHeight" (number, 0-10000 px). |
mediaSafeAuto | boolean | Optional | strict true only; opts into parse-time auto scheme stamping and carries a CSS obligation | advisory (unvalidated) at publish; anything other than true is read as false |
externalFetch | string[] | Optional | hosts the SSR sandbox will allow; non-string entries are dropped at resolve | advisory (unvalidated) at publish — enforced only at SSR runtime |
slots | {name, accepts?, prefill?}[] | Layouts only | requires category: "layout"; inner shape rules in Layout widgets | publish ERROR — manifest widget "{slug}" declares "slots" but is not a layout. · manifest widget "{slug}" needs a "slots" list. |
pages | PluginPageKey[] | Layouts only (0.12.0+) | requires category: "layout", non-empty, values from the six page keys; see Layout widgets | publish ERROR — manifest widget "{slug}" declares "pages" but is not a layout. · manifest widget "{slug}" needs a non-empty "pages" list. · manifest widget "{slug}" has unknown page(s) in "pages": … (pdp | listings | checkout | confirmation | collection | misc). |
deprecated | boolean | Optional | truthy hides the widget from the picker; existing placements keep rendering | advisory (unvalidated) at publish |
replacedBy | string | Optional | declared in the SDK type only | advisory (unvalidated) — read by nothing (see Known gaps) |
Maximum 24 widgets per plugin: manifest declares {n} widgets — the limit is 24 per plugin.
(publish ERROR). Every hard number in the book is collected in
Limits and error index.
Confined relative paths
assets.js, assets.css, relative assets.fonts entries, ssr.url and manifest must all be
confined relative paths — paths that cannot escape the published version prefix
{env}/{slug}/{version}/.
// src/lib/plugin-zip.ts — isConfinedRelativePath (the submission audit's rule)
function isConfinedRelativePath(value: unknown): value is string {
if (typeof value !== "string" || value === "") return false;
if (value.startsWith("/") || /^[a-z]+:/i.test(value)) return false;
// WHATWG URL parsing treats "\\" as "/" for http(s) — a backslash segment
// would otherwise slip past the "/"-only traversal check.
if (value.includes("\\")) return false;
return !value.split("/").some((segment) => segment === "..");
}
Four rejections, in order: an absolute path (/dist/hero.js), any scheme: prefix
(https://cdn.example/hero.js, file:hero.js), any backslash, and any .. segment. An absolute
http(s) URL is therefore not a legal value for these fields — only assets.fonts, icon
and cover accept absolute URLs. Central applies the identical rule server-side.
Field notes
slug
The slug is a serving contract: it forms the keyword customers store on their widget rows, the
dist/{slug}/ directory the build emits, and the /p/{plugin}/{slug}/{slug}.iife.js proxy path.
dist, widgets and media are reserved because they are the published directory names under
the version prefix.
When a summary’s slug fails the regex the audit stops checking that entry and moves to the
next one — fix the slug first, then re-run to see the rest of that widget’s errors.
hr-widget-build enforces the same three rules before it runs a single Vite pass, so you hit
them at build time, not at submission; the build’s own strings are in
Build a suite.
name and description
name is required and non-empty at publish. Everything downstream still defends against an
absent value: resolvePluginWidget falls back to the slug, and the picker falls back to the
plugin name.
description is validated by nothing. When you omit it the widget picker substitutes the literal
string Third-party plugin, and a layout gets (layout — hosts other widgets in its slots)
appended to whatever description it shows.
// src/components/widget/CreateWidgetDialog.tsx — picker entry for a plugin widget
description:
(entry.description ?? 'Third-party plugin') +
(entry.category === 'layout' ? ' (layout — hosts other widgets in its slots)' : ''),
icon: entry.category === 'layout' ? Layers : Puzzle,
category
Only the two literals content and layout pass publish. At resolve time the check is
one-sided: summary.category === "layout" ? "layout" : "content" — so an absent value, and any
value that somehow reached the runtime without passing publish, is treated as content.
Choosing layout changes almost everything about how the widget renders and adds a mandatory
slots field to your zod config. Read Layout widgets before you set it.
ssr
Three legal states, and the third one is a publish error:
| Value | Meaning |
|---|---|
{ "url": "dist/hero/hero-ssr.umd.js" } | Server-rendered. You must ship an ssr-entry.ts for this widget. |
false | CSR-only. The renderer emits an empty host div; your client IIFE renders it. No ssr-entry.ts exists for this widget at all. |
| absent / anything else | publish ERROR. Omission is not the same as false — say false explicitly. |
A category: "layout" widget must declare ssr.url. A layout with ssr: false is accepted
by the publish audit but fails at render time:
// packages/homerunner-renderer/lib/render-utils.tsx — layout SSR path
plugin layout widgets require an SSR bundle (`ssr: false` is content-only)
See Layout widgets.
For a v1 (single-widget) plugin ssr.url is unconditionally required — a v1 manifest that
resolves to ssr: false produces the renderer failure reason
no plugin SSR URL in manifest. Only suite summaries may opt out.
What the SSR bundle must export, and the sandbox it runs in, are in Component and SSR module.
assets
assets.js is the only required member. Point each path at the build’s logical filename —
dist/{widget}/{widget}.iife.js — not at a content-hashed twin; the platform resolves the hashed
file itself from dist/manifest.json. See Keywords, assets and URLs for
the derived filenames and the two serving paths.
assets.fonts (widget-core 0.12.0+) is the only supported way to load web fonts, because
@font-face declared inside a shadow root is ignored by the browser. Up to 8 stylesheets per
widget; the client bundle injects them into document.head and the renderer appends them to the
server-rendered page’s stylesheet list. At publish, more than 8 or a malformed entry is an ERROR;
at resolve time the platform silently filters invalid entries and truncates to 8 (silent runtime
truncation), so a manifest that reached the fleet another way degrades rather than breaking.
Not validated before upload. The dashboard’s browser audit checks
assets.fontsonly insidewidgets[]. A v1 root-levelassets.fontsis honoured by the resolver and validated by central on submit, but nothing checks it in your browser first.
The publish audit also emits size warnings (never blocking) per widget: over 1.5 MB for the
client bundle, 512 KB for the stylesheet, and 1 MB for the SSR bundle — the last reading
dist/{name} is {n} MB — over the renderer's 1 MB cache threshold (slower cold SSR).
manifest — the sub-manifest path
A suite root manifest carries no schemas. Each widget’s configSchema and uiSchema live in a
separate file that the summary points at, conventionally widgets/{slug}.manifest.json:
// widgets/pdp-frame.manifest.json — written by your build:manifest step
{ "slug": "pdp-frame", "configSchema": { /* … */ }, "uiSchema": { /* … */ } }
Only the dashboard ever fetches this file, and only when it needs to draw a config form. The renderer never reads it. Consequences you should design around:
- Referenced but absent from the built zip → publish ERROR (see the table above).
- Present but with no
configSchema→ publish warning:widgets/{slug}.manifest.json has no configSchema — widget "{slug}"'s dashboard form will be empty. - Omit the
manifestfield entirely and the widget is legal, installable and renderable — the customer just gets a schemaless config panel. That is a supported (if unhelpful) state, not an error.
Generate this file; never hand-write it. The generator, the round-trip rules and the publish
warnings about extending widgetSchema are in
Config schema and UI schema.
icon and readme
Both are marketplace surface, both (widget-core 0.12.0+), and their path rules differ:
| Field | Accepted forms | Length cap |
|---|---|---|
icon | media/<file> — flat, no subdirectory — or an absolute http(s) URL | 2048 chars |
readme | media/<file>.md or media/<dir>/<file>.md — exactly one optional directory level; no absolute URL | 255 chars, file ≤ 64 KB |
media/ is authored content that nothing generates. It must be committed and present in your
SOURCE zip, because the reviewer only runs your build — a declared icon missing from your source
fails the PUBLISH, long after you were told the submission looked fine. See
Packaging and publishing rules.
Two traps:
- A
media/reference resolves only after the first publish. It is resolved against the plugin’s publishedmanifest_url; an unpublished submission has none, somedia/icons render as a fallback glyph in the review console while absolute-URL icons render immediately. - Filename charset. The publishable surface allows only
[A-Za-z0-9._-]per path segment.media/my icon.svgpasses the icon-shape check but is dropped from the upload with the warning"media/my icon.svg" won't be published — file names may only use letters, digits, ".", "_" and "-".and then fails withDeclared widget "{slug}" icon media/my icon.svg is missing from the zip.
When no per-widget icon resolves, the surface falls back to the plugin’s root icon, and then to
a glyph — Layers for a layout, Puzzle for content.
expectedHeight
An anti-CLS hint in CSS pixels, 0 <= n <= 10000. It must be a JSON number — "140" as a
string fails the browser audit even though central’s numeric check would accept it. It is used in
exactly two places:
- CSR-only widgets on server-rendered pages. The renderer emits
data-hr-min-height="{n}"on the empty host div, and only whenn > 0. A widget withssr.urlnever gets the attribute from the renderer — its server markup is the reservation. - The embed snippet. The dashboard’s Embed Code dialog bakes the same value into the copied HTML for any plugin widget, SSR or not, and it takes precedence over the platform’s own keyword-based hint.
<!-- src/app/(protected)/feeds/[id]/widgets/[widgetId]/[widgetName]/playground/EmbedCodeDialog.tsx -->
<div data-hr-widget-container>
<div id="{widgetId}" data-hr-widget="plugin:pdp-suite:stay-facts" data-hr-min-height="140"></div>
</div>
Set it on every ssr: false widget. Omitting it on a CSR-only widget means the page reserves
nothing and shifts when your bundle paints. What consumes the attribute is described in
Runtime, mount and the DOM contract.
mediaSafeAuto
Strict true only — the resolver reads summary.mediaSafeAuto === true, so "true", 1 and
any other truthy value mean false. Declaring it opts the widget into parse-time auto
colour-scheme resolution: on a Declarative-Shadow-DOM-capable request the renderer stamps
data-hr-scheme="auto" on your host, so dark mode resolves at first paint with no JavaScript.
The stamp is applied only when all three hold: the widget’s resolved colorScheme is auto, the
request emitted DSD, and this flag is true.
This is a promise about your CSS, and nothing validates it. The stamp assumes your stylesheet dual-emits every dark rule under both standard prefixes. Declaring it without the dual emission paints a dark host with light chrome. The exact selectors, and the two footguns in writing them, are in Styling and theming.
externalFetch
A list of hosts your SSR bundle is allowed to fetch from. The resolver keeps only the string entries and hands the list to the SSR bundle loader; the sandbox then permits HomeRunner’s public API plus these hosts and blocks everything else.
Nothing validates this field at publish — neither the browser audit nor central. A typo is
not an error you will see at submission; it is a blocked fetch at render time. Host-vs-host:port
matching, the redirect cap, the private-address denylist and the exact block messages are in
Component and SSR module.
Client-side fetches are unaffected: externalFetch constrains the server sandbox only.
slots, accepts, pages
Layout-only fields. Declaring either slots or pages on a widget whose category is not
layout is a publish ERROR with the strings in the field index. Everything about their meaning —
slot-name rules, accepts filtering, prefill, page assignment, the mandatory slots field in
your zod config — belongs to Layout widgets.
deprecated and replacedBy
deprecated: true is a soft retirement, and it is how you remove a widget from a suite. It:
- removes the widget from the dashboard’s widget picker for every feed,
- disables Create widget for it on the plugin page and shows a
deprecatedbadge, - leaves every existing placement rendering exactly as before, and
- is diffed at review, so the reviewer sees
deprecated/un-deprecatedon the summary.
Deleting a slug instead breaks every customer widget that stores that keyword. Prefer
deprecated. This is not the same as a kill switch, which stops the assets serving — see
Install, customers and kill switches.
Not supported yet.
replacedByis declared in the SDK type and read by nothing. Setting it is harmless and invisible — see Known gaps.
What the platform fills in for you
Every consumer normalizes a summary through one function before using it. These defaults apply at runtime whatever the manifest says:
| Summary value | Resolved value |
|---|---|
name absent | the widget slug |
category anything but "layout" | "content" |
ssr absent, or {} with no url | false (CSR-only) |
mediaSafeAuto anything but strict true | false |
icon failing the media-ref check | dropped (falls back to the plugin icon, then a glyph) |
readme failing the readme-ref check | dropped |
pages on a content widget | undefined — content widgets can never serve a page |
pages empty or all-unknown on a layout | ["misc"] |
assets.fonts with invalid entries or more than 8 | filtered, then truncated to 8 |
externalFetch with non-string entries | filtered to strings |
assets.js absent | the whole widget fails to resolve (see below) |
// packages/homerunner-widget-core/src/manifest.ts — resolvePluginWidget, suite branch
const summary = manifest.widgets.find((w) => w.slug === widgetSlug);
if (!summary || !summary.assets?.js) return null;
return {
pluginSlug: manifest.id,
widgetSlug,
name: summary.name ?? widgetSlug,
category: summary.category === "layout" ? "layout" : "content",
ssr: summary.ssr === false ? false : summary.ssr?.url ? { url: summary.ssr.url } : false,
// …
mediaSafeAuto: summary.mediaSafeAuto === true,
icon: isPluginMediaRef(summary.icon) ? summary.icon : undefined,
readme: isPluginReadmeRef(summary.readme) ? summary.readme : undefined,
};
Resolution fails closed. A null result — unknown slug, missing assets.js, or a keyword whose
shape disagrees with the manifest’s — makes the renderer emit a hidden failure breadcrumb with
the reason keyword does not resolve against the plugin manifest instead of rendering anything.
The shape-mismatch table is in The two manifest shapes.
Where each rule is checked
Your summary passes three gates at three different moments. Knowing which is which saves a review cycle.
| Gate | When | Scope | Failure looks like |
|---|---|---|---|
| Dashboard source-zip audit | in your browser, before a single byte uploads | every rule in the field index above, plus the 24-widget cap and preset integrity | a list of messages in the upload dialog; all errors at once |
| Central’s manifest validator | on submit, after the upload | the same rules, re-checked server-side, plus widgets[].icon and widgets[].readme shape | HTTP 422 INVALID_MANIFEST with one message — the first failure only |
| Built-zip audit | at publish, in the reviewer’s browser | file presence: every declared bundle, hashed twin, sub-manifest, icon and readme; sizes; the HRPlugins registration marker | the publish is blocked; you are asked to resubmit with a bumped version |
Two consequences worth internalising:
widgets[].iconandwidgets[].readmeshapes are checked by central but not by the browser audit. A malformed per-widget media ref sails through the local audit and comes back as422 INVALID_MANIFESTwith, e.g.,Manifest widget "{slug}" has an invalid "icon" (a "media/<file>" path or an absolute http(s) URL, max 2048 characters).- File presence is only checked at publish. Everything in the field index that says “must
ship in the built zip” is invisible until an admin builds your source. Commit
media/andwidgets/*.manifest.json. See Preflight and submit.
Central also applies its own root-level caps that the browser audit does not: name and id
≤ 255 chars, version ≤ 64, description ≤ 4000, author.name / author.url ≤ 255. Those are
root fields — see Manifest: root fields.
A complete summary, both kinds
// hr-plugins/pdp-suite/manifest.json — the shipped v1.3.3 manifest, widgets[] excerpt
"widgets": [
{
"slug": "pdp-frame",
"name": "PDP Frame",
"category": "layout",
"slots": [
{ "name": "hero", "prefill": ["property-title", "gallery"] },
{ "name": "main", "prefill": ["property-meta", "bednbath", "description", "amenities", "calendar"] },
{ "name": "sidebar", "prefill": ["booking"] },
{ "name": "bottom", "prefill": ["reviews"] }
],
"ssr": { "url": "dist/pdp-frame/pdp-frame-ssr.umd.js" },
"assets": {
"js": "dist/pdp-frame/pdp-frame.iife.js",
"css": "dist/pdp-frame/pdp-frame.css",
"fonts": ["https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&display=swap"]
},
"manifest": "widgets/pdp-frame.manifest.json",
"pages": ["pdp", "misc"],
"icon": "media/pdp-frame.svg",
"readme": "media/widgets/pdp-frame.md"
},
{
"slug": "stay-facts",
"name": "Stay Facts",
"ssr": false,
"assets": {
"js": "dist/stay-facts/stay-facts.iife.js",
"css": "dist/stay-facts/stay-facts.css"
},
"expectedHeight": 140,
"manifest": "widgets/stay-facts.manifest.json"
}
]
Read the two entries against the rules: the layout declares ssr.url because it must,
pages because it wants to be assignable, and no expectedHeight because it server-renders.
The CSR-only widget declares ssr: false explicitly, ships no ssr-entry.ts, and reserves
140 px.
Known gaps
One field of a summary does nothing at all:
Not supported yet.
replacedByis stored in the manifest, accepted by every validator and read by no runtime, UI or review code — not the renderer, not the dashboard, not the review diff. It surfaces nowhere. Name the successor in the widget’sreadmeinstead.
Three more are honoured at runtime but never checked before you ship, so a mistake in them costs you a released version rather than a failed upload:
Not validated.
externalFetchis checked by neither audit nor by central. A host you typo’d is not an error at submission; it is a blocked fetch at render time.
Not validated.
mediaSafeAutois a promise about your CSS that nothing can verify. Nothing inspects your stylesheet for the dual-emitted dark rules the flag requires, so a wrong declaration ships silently and paints a dark host with light chrome.
Not validated before publish.
widgets[].iconandwidgets[].readmeshapes are checked by central on submit but not by the browser audit, and whether the declaredmedia/file actually exists is checked only by the built-zip audit at publish. A submission can be accepted and then fail to publish on media alone.