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

Anatomy of a plugin

A plugin is three things wearing one directory: source you author, artifacts your build produces, and content nothing produces but the platform still expects. Every consumer downstream — the reviewer, central, the renderer, the dashboard, the browser — reads a different subset of those files. Once you know which file is which, the rest of this book is detail.

This page is a map, not a rule set. Field rules live on the contracts pages.

Two trees

The scaffold’s project and a real published plugin look surprisingly different. Roughly half of what create-hr-plugin gives you is a local sandbox that never reaches a customer, and the one real suite in the fleet ships none of it.

(a) The single-widget scaffold

# what `npx create-hr-plugin my-widget` writes — `--template single`, the
# default (create-hr-plugin 0.8.1)
my-widget/
├── manifest.json           # SHIPPED. Identity + the v1 single-widget fields. Rewritten by the build.
├── package.json            # SHIPPED (source zip). Pins react/react-dom/react-query exactly.
├── package-lock.json       # appears after `npm install`. Ship it — absent is a publish warning.
├── tsconfig.json           # SHIPPED (source zip), never published
├── vite.config.ts          # SHIPPED (source zip). Delegates to viteHomerunnerWidget().
├── README.md               # yours, developer-facing. NOT the marketplace readme — that is media/.
├── .env.example            # dev only. Copy to .env.local; consulted by `?mock=off` and dev:ssr.
├── .gitignore              # written from the template's `_gitignore` (npm strips real dotfiles)
├── index.html              # DEV ONLY — deletable. Loads /src/dev/main.tsx, falling back to
│                           #   the BUILT bundles when that 404s under `npm run preview`.
├── serve.js                # DEV/DEMO ONLY — deletable. Zero-dep node:http static server.
├── vercel.json             # DEMO ONLY — deletable. Self-hosted preview deploy config.
├── api/
│   └── widget-asset.ts     # DEMO ONLY — deletable. Vercel edge /w/ → hashed-dist redirect.
├── public/
│   └── mockServiceWorker.js  # DEV ONLY — kept out of `dist/` since 0.8.0; see below.
├── mocks/
│   └── fixtures.ts         # DEV ONLY — deletable. Overrides for the mock endpoints.
├── scripts/
│   ├── inject-schema.mjs   # YOURS, and required. zod → JSON Schema → manifest.json.
│   └── dev-ssr.mts         # DEV ONLY — deletable. Boots the local SSR preview.
└── src/
    ├── index.tsx           # SHIPPED. Client IIFE entry: registerPlugin() + mount().
    ├── widget.tsx          # SHIPPED. Your component (+ getInitialData in this template).
    ├── ssr.ts              # SHIPPED. dehydrateState + getStaticAssets.
    ├── ssr-entry.ts        # SHIPPED. Re-export barrel — the SSR bundle's entry point.
    ├── config.ts           # SHIPPED. widgetSchema.extend() + zodToManifestSchema().
    ├── widget.css          # SHIPPED. Tailwind import + your styles.
    ├── data.ts             # SHIPPED. Sample public-api fetcher + a query-key factory.
    ├── dev/                # DEV ONLY — deletable.
    │   ├── main.tsx        #   the entry index.html loads: mock worker → mount → panel
    │   └── DevPanel.ts     #   the bottom control bar
    └── components/ui/      # SHIPPED and yours. Vendored Button/Dialog/Carousel/Stylesheet/
                            #   WidgetSection/…; widget.tsx imports `cn` from here.

Everything marked DEV ONLY or DEMO ONLY can be deleted without affecting what customers load. You lose the local sandbox and the SSR preview, nothing else — see Dev sandbox and mocking and Previewing SSR locally.

serve.js, vercel.json and api/widget-asset.ts in particular imitate the platform’s own asset proxy for a self-hosted demo. The publish pipeline never touches them, because you host nothing: you submit source, and HomeRunner builds and serves the result. See From your laptop to a customer page.

(b) A real published suite (widget-core 0.11.0+)

# /Users/…/hr-plugins/pdp-suite — the published v1.3.3 source tree (50 zip entries, 82 KB)
pdp-suite/
├── manifest.json                    # root manifest: widgets[] (4) + presets[] + media refs
├── package.json                     # three scripts: dev, build, build:manifest
├── package-lock.json
├── tsconfig.json
├── vite.config.ts                   # 13 lines — identical in shape to the single-widget one
├── scripts/
│   └── inject-schema.mjs            # same script, multi-widget branch
├── widgets/                         # GENERATED by build:manifest, but COMMITTED and shipped
│   ├── pdp-frame.manifest.json
│   ├── stay-hero.manifest.json
│   ├── stay-facts.manifest.json
│   └── booking-cta.manifest.json
├── media/                           # AUTHORED. Nothing generates this directory.
│   ├── README.md                    #   declared as manifest.readme
│   ├── CHANGELOG.md                 #   picked up by filename convention, declared nowhere
│   ├── pdp-frame.svg                #   per-widget icons — flat, one level
│   ├── stay-hero.svg
│   ├── stay-facts.svg
│   ├── booking-cta.svg
│   └── widgets/
│       ├── pdp-frame.md             #   per-widget readmes — one nested level is allowed
│       └── …
└── src/
    ├── vite-env.d.ts
    └── widgets/
        ├── pdp-frame/               # the layout
        │   ├── index.tsx            #   registerPluginWidget(…, { category: "layout" }) + mount
        │   ├── widget.tsx           #   reads props.renderedSlots
        │   ├── config.ts            #   MUST declare a `slots` zod object
        │   ├── ssr-entry.ts         #   one line: export { default } from "./widget";
        │   └── pdp-frame.css
        ├── stay-hero/
        │   ├── … + hero-bg.png      #   a bundled asset, imported from the widget folder
        │   └── ssr-entry.ts         #   present, because this widget declares ssr.url
        ├── stay-facts/              #   "ssr": false → NO ssr-entry.ts at all
        └── booking-cta/             #   "ssr": false → NO ssr-entry.ts at all

No index.html, no src/dev/, no mocks/, no public/, no api/, no serve.js, no vercel.json, no src/components/ui/, and no root README.md. A suite’s public README is media/README.md.

npx create-hr-plugin my-suite --template suite (create-hr-plugin 0.8.0+) writes that same shape — three widgets under src/widgets/, media/, presets[], scripts/inject-schema.mjs — plus the dev-only files this real suite has dropped: index.html, src/dev/, mocks/fixtures.ts, public/mockServiceWorker.js, serve.js, vercel.json, .env.example and scripts/dev-ssr.mts (create-hr-plugin 0.8.1+, and widget-core 0.12.2+ to run it). Every one of those is deletable on the same terms as in tree (a). It also adds src/shared/, which is only a demonstration of relative cross-widget imports. It does not add Tailwind or a src/components/ui/ kit: a layout’s stylesheet is unisolated light DOM on a server-rendered page, so a framework reset would land on the customer’s whole document.

Build a suite covers both the scaffold and the hand conversion; The two manifest shapes is the decision, and it is permanent once you publish.

Who authored what

Three categories, and confusing them is the most common cause of a publish that fails after your submission was accepted.

Authored by you. Everything under src/, manifest.json, vite.config.ts, scripts/inject-schema.mjs (it lives in your project, not in the SDK — you can edit it), and all of media/.

Generated by the build, and committed anyway. widgets/{slug}.manifest.json (Suite). Your build:manifest step writes them from each widget’s config.ts, and they must be present in both zips because the dashboard fetches them from the CDN to draw the config form.

Generated by the build, and never committed. dist/. The scaffold’s .gitignore already excludes it, and a root-level dist/ in a source zip is rejected — the reviewer rebuilds it from your source. See Packaging and publishing rules.

media/ is content, not output

Nothing generates media/. You create the directory, you draw the icons, you write the README, and you commit them. The reviewer runs your build and nothing else, so a manifest that declares icon: "media/logo.svg" against a file that is not in your source zip passes the upload audit, passes review, and fails at publish. If media/ is in your .gitignore, or your icons live outside it, that is the failure you will hit.

Which paths each media field accepts is on Manifest: root fields; the commit rule and the exact publish errors are on Packaging and publishing rules.

What npm run build changes on disk

npm run build is npm run build:manifest && hr-widget-build. Between them they rewrite your tracked manifest.json twice:

  1. build:manifest (scripts/inject-schema.mjs) injects the schemas. (Single-widget) it writes configSchema and uiSchema into the root manifest. (Suite) it writes one widgets/{slug}.manifest.json per summary and back-fills each summary’s manifest path — the root manifest never carries a schema.
  2. The build’s finalize pass stamps runtime.widgetCore with the SDK version it built with, and logs runtime.widgetCore stamped: X.Y.Z.

So manifest.json is dirty in git after every build. That is correct — commit it. Never hand-write runtime.widgetCore; the publish audit gates on the stamped value.

The same pass walks dist/, writes a content-hashed twin next to every file, and emits dist/manifest.json — the build manifest, {buildHash, generatedAt, files}, keyed by dist-relative path. .map files and manifest.json itself are skipped by the hashing pass but sourcemaps are still published.

# /Users/…/hr-plugins/pdp-suite/dist — the real output of one `npm run build`
dist/
├── manifest.json                          # the build manifest — hashed twins, keyed dist-relative
├── pdp-frame/
│   ├── pdp-frame.iife.js  + …-be6fddd4.js # client bundle + hashed twin
│   ├── pdp-frame.css      + …-9992937d.css
│   ├── pdp-frame-ssr.umd.js + …-5872d9b8.js
│   └── pdp-frame.iife.js.map              # published, but absent from the build manifest
├── stay-hero/
│   └── … + hero-bg-Cr1v4Zys.png           # a bundled asset (widget-core 0.12.0+)
├── stay-facts/                            # iife + css only — "ssr": false
└── booking-cta/                           # iife + css only — "ssr": false

File-naming rules, the {env}/{slug}/{version}/ CDN key and how a URL is resolved live on Keywords, assets and URLs.

The MSW leak, and its fix. Both scaffolds put the mock service worker in public/, and Vite copies publicDir into outDir on every production build — so on scaffolds older than create-hr-plugin 0.8.0, mockServiceWorker.js and a hashed twin end up in your published assets and in dist/manifest.json. Harmless but pointless. 0.8.0’s vite.config.ts returns publicDir: command === "build" ? false : "public", which keeps it out of the build and leaves npm run dev untouched; add that line yourself if your project predates it. (The real suite has no public/, so it never had the leak.)

The artifacts, and who reads each one

After the reviewer builds and publishes, a handful of file kinds sit on the CDN under one immutable version prefix. They have almost disjoint audiences:

ArtifactRead by
Root manifest.jsonCentral caches it (manifest_cache) and attaches it to every plugin widget row. The renderer resolves your keyword against that copy. The dashboard reads it for picker entries, icons and presets.
widgets/{slug}.manifest.json (Suite)The dashboard only, fetched from the CDN on demand when it draws the config form. The renderer never fetches it.
dist/manifest.jsonThe renderer, to prefer the content-hashed twin of a bundle over the version-less proxy.
dist/…iife.js (client)The browser, after the shared runtime IIFE has loaded.
dist/…-ssr.umd.js (server CJS)The renderer, loaded into a node:vm sandbox. Absent for "ssr": false widgets.
dist/….cssBoth paths — the renderer emits <link>s into the shadow root, the client adopts or loads them.
media/…The dashboard, for the marketplace page, the plugin readme and picker icons.

Two consequences worth designing around:

  • Only the ROOT manifest is cached and shipped per render. Keep it small. That is the whole reason a suite’s schemas live in sub-manifests instead of inline — see Config schema and UI schema.
  • The sub-manifest fetch is best-effort. If it is missing or slow the dashboard degrades to a schemaless panel rather than erroring — but a sub-manifest your manifest declares and your zip does not contain is a hard publish failure.

The end-to-end path — source zip, review, built zip, CDN, install — is From your laptop to a customer page. What each render path does with these bundles is How a widget renders.

TypeScript setup

The scaffold’s tsconfig.json is byte-identical to the real suite’s, so treat it as the platform baseline: jsx: "react-jsx", target: "ES2017", module: "ESNext", moduleResolution: "bundler", lib: ["ES2020", "DOM", "DOM.Iterable"], strict, noEmit, isolatedModules, and a @/*./src/* path alias.

include is ["src/**/*", "vite.config.ts"] only. mocks/, scripts/, serve.js and api/ sit outside the TS project, and there is no typecheck script — nothing type-checks your project unless you add "typecheck": "tsc --noEmit" yourself. Vite’s build does not type-check either.

Where to go next