Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 fieldv1 root equivalent
widgets[].slugwidgetType (and the plugin id)
widgets[].ssrssr (required; never false)
widgets[].assetsassets
widgets[].manifest → sub-manifestinline root configSchema / uiSchema
widgets[].category / slots / pagesno 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.

FieldTypeRequired (suite entry)RuleOn violation
slugstringRequired/^[a-z0-9][a-z0-9-]*$/, unique in the plugin, not dist / widgets / mediapublish 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.
namestringRequirednon-empty; shown in the picker and every admin surfacepublish ERROR — manifest widget "{slug}" is missing "name".
descriptionstringOptionalfree text, shown under the name in the pickeradvisory (unvalidated) — no length cap, no format check
category"content" | "layout"Optional (default content)exactly one of the two literalspublish ERROR — manifest widget "{slug}" has an unknown "category" (content | layout).
ssr{ url: string } | falseRequiredssr.url a confined relative path, or the literal false for a CSR-only widgetpublish ERROR — manifest widget "{slug}" needs "ssr.url" (relative path) or an explicit "ssr": false for CSR-only widgets.
assets.jsstringRequiredconfined relative path to the client IIFEpublish ERROR — manifest widget "{slug}" needs a relative "assets.js" path inside the version prefix.
assets.cssstringOptionalconfined relative pathpublish ERROR — manifest widget "{slug}" has an invalid "assets.css" path.
assets.fontsstring[]Optional (0.12.0+)≤ 8 entries, each an absolute http(s) URL or a confined relative pathpublish 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
manifeststringOptional, in practice requiredconfined relative path to widgets/{slug}.manifest.json; the file must ship in the built zippublish 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.
iconstringOptional (0.12.0+)media/<file> (FLAT, no subdirectory) or an absolute http(s) URL, ≤ 2048 chars; a media/ file must ship in the built zippublish 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.
readmestringOptional (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 zippublish 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.
expectedHeightnumberOptional0 <= n <= 10000 (px)publish ERROR — manifest widget "{slug}" has an invalid "expectedHeight" (number, 0-10000 px).
mediaSafeAutobooleanOptionalstrict true only; opts into parse-time auto scheme stamping and carries a CSS obligationadvisory (unvalidated) at publish; anything other than true is read as false
externalFetchstring[]Optionalhosts the SSR sandbox will allow; non-string entries are dropped at resolveadvisory (unvalidated) at publish — enforced only at SSR runtime
slots{name, accepts?, prefill?}[]Layouts onlyrequires category: "layout"; inner shape rules in Layout widgetspublish ERROR — manifest widget "{slug}" declares "slots" but is not a layout. · manifest widget "{slug}" needs a "slots" list.
pagesPluginPageKey[]Layouts only (0.12.0+)requires category: "layout", non-empty, values from the six page keys; see Layout widgetspublish 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).
deprecatedbooleanOptionaltruthy hides the widget from the picker; existing placements keep renderingadvisory (unvalidated) at publish
replacedBystringOptionaldeclared in the SDK type onlyadvisory (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:

ValueMeaning
{ "url": "dist/hero/hero-ssr.umd.js" }Server-rendered. You must ship an ssr-entry.ts for this widget.
falseCSR-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 elsepublish 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.fonts only inside widgets[]. A v1 root-level assets.fonts is 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 manifest field 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:

FieldAccepted formsLength cap
iconmedia/<file>flat, no subdirectory — or an absolute http(s) URL2048 chars
readmemedia/<file>.md or media/<dir>/<file>.md — exactly one optional directory level; no absolute URL255 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 published manifest_url; an unpublished submission has none, so media/ 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.svg passes 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 with Declared 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:

  1. CSR-only widgets on server-rendered pages. The renderer emits data-hr-min-height="{n}" on the empty host div, and only when n > 0. A widget with ssr.url never gets the attribute from the renderer — its server markup is the reservation.
  2. 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 deprecated badge,
  • leaves every existing placement rendering exactly as before, and
  • is diffed at review, so the reviewer sees deprecated / un-deprecated on 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. replacedBy is 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 valueResolved value
name absentthe widget slug
category anything but "layout""content"
ssr absent, or {} with no urlfalse (CSR-only)
mediaSafeAuto anything but strict truefalse
icon failing the media-ref checkdropped (falls back to the plugin icon, then a glyph)
readme failing the readme-ref checkdropped
pages on a content widgetundefined — content widgets can never serve a page
pages empty or all-unknown on a layout["misc"]
assets.fonts with invalid entries or more than 8filtered, then truncated to 8
externalFetch with non-string entriesfiltered to strings
assets.js absentthe 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.

GateWhenScopeFailure looks like
Dashboard source-zip auditin your browser, before a single byte uploadsevery rule in the field index above, plus the 24-widget cap and preset integritya list of messages in the upload dialog; all errors at once
Central’s manifest validatoron submit, after the uploadthe same rules, re-checked server-side, plus widgets[].icon and widgets[].readme shapeHTTP 422 INVALID_MANIFEST with one message — the first failure only
Built-zip auditat publish, in the reviewer’s browserfile presence: every declared bundle, hashed twin, sub-manifest, icon and readme; sizes; the HRPlugins registration markerthe publish is blocked; you are asked to resubmit with a bumped version

Two consequences worth internalising:

  • widgets[].icon and widgets[].readme shapes are checked by central but not by the browser audit. A malformed per-widget media ref sails through the local audit and comes back as 422 INVALID_MANIFEST with, 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/ and widgets/*.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. replacedBy is 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’s readme instead.

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. externalFetch is 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. mediaSafeAuto is 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[].icon and widgets[].readme shapes are checked by central on submit but not by the browser audit, and whether the declared media/ 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.