Keywords, assets and URLs
Everything your plugin ships — bundles, stylesheets, images, fonts — is addressed twice: once by the keyword a customer’s widget row stores, and once by a URL the platform derives from your manifest. This page is the normative reference for both derivations, on every path.
Enforcement levels used below are defined once in limits-and-errors.md: publish ERROR (the upload or the publish is refused), publish warning (the reviewer sees it, you do not), silent runtime truncation (the value is dropped or capped with no message) and advisory (unvalidated) (nothing checks it; you find out in production).
1. The keyword grammar
A plugin widget is addressed everywhere — stored settings, embed snippets, the renderer, the
registry on window.HRPlugins — by a single string.
| Shape | Keyword | Registry key |
|---|---|---|
| Single-widget (v1) | plugin:{pluginSlug} | {pluginSlug} |
| Suite | plugin:{pluginSlug}:{widgetSlug} | {pluginSlug}:{widgetSlug} |
registryKey is everything after the plugin: prefix, unchanged. It is the key your client
bundle registers under and the key mount() looks up — see
runtime-and-mount.md.
// Source: packages/homerunner-widget-core/src/urls.ts:34-46
export function parsePluginKeyword(keyword: string): PluginKeywordParts | null {
if (!keyword.startsWith(PLUGIN_WIDGET_PREFIX)) return null; // "plugin:"
const registryKey = keyword.slice(PLUGIN_WIDGET_PREFIX.length);
if (!registryKey) return null;
const sep = registryKey.indexOf(":");
if (sep === -1) {
return { pluginSlug: registryKey, widgetSlug: null, registryKey };
}
const pluginSlug = registryKey.slice(0, sep);
const widgetSlug = registryKey.slice(sep + 1);
if (!pluginSlug || !widgetSlug) return null;
return { pluginSlug, widgetSlug, registryKey };
}
Parsing rules — Both shapes:
| Input | Result | Why |
|---|---|---|
plugin:acme | {acme, null, "acme"} | No separator → single-widget form |
plugin:acme:hero | {acme, hero, "acme:hero"} | Split at the first colon |
gallery | null | Not a plugin keyword — a system widget |
plugin: | null | Empty registry key |
plugin::hero | null | Empty plugin slug |
plugin:acme: | null | Empty widget slug |
A null parse is fail-closed: nothing resolves, nothing mounts, and on a server-rendered page
the renderer logs [plugin] No manifest entry for "<keyword>". Skipping. and replaces the widget
with a hidden error breadcrumb. Both slugs are constrained to ^[a-z0-9][a-z0-9-]*$ at publish
(publish ERROR), so a published keyword can never contain a third colon — see
manifest-root.md and widget-summary.md.
2. Derived file names
You never invent bundle names. The SDK’s Vite preset emits fixed names, and every consumer —
the renderer, the /p/ proxy, the client runtime — recomputes the same names from the keyword.
// Source: packages/homerunner-widget-core/src/urls.ts:55-64
export function pluginWidgetDistFile(parts, kind: "iife" | "css" | "ssr"): string {
const base = parts.widgetSlug ?? parts.pluginSlug;
const dir = parts.widgetSlug ? `${parts.widgetSlug}/` : "";
const file =
kind === "iife" ? `${base}.iife.js` : kind === "css" ? `${base}.css` : `${base}-ssr.umd.js`;
return `${dir}${file}`;
}
| Artifact | Single-widget (v1) | Suite |
|---|---|---|
| Client IIFE | dist/{slug}.iife.js | dist/{widget}/{widget}.iife.js |
| Stylesheet | dist/{slug}.css | dist/{widget}/{widget}.css |
| SSR bundle (CJS) | dist/{slug}-ssr.umd.js | dist/{widget}/{widget}-ssr.umd.js |
| Bundled asset | dist/{name}-{vitehash}.{ext} | dist/{widget}/{name}-{vitehash}.{ext} |
| Source map | …{file}.map | …{file}.map |
The -ssr.umd.js suffix is historical: the file is CommonJS, not UMD.
Rule (Both, publish ERROR). Your manifest’s assets.js, assets.css and ssr.url must
point at files the build actually emitted. The built-zip audit checks each one and fails with
dist/<name> (<label>'s <kind>) is missing from the zip. Do not rename outputs.
dist/manifest.json — the build manifest
After the last build pass the SDK walks dist/ recursively, writes a content-hashed twin
next to every file, and records the mapping.
// Source: /Users/yhshanto/Projects/hcs/hr-plugins/pdp-suite/dist/manifest.json (real, v1.3.3)
{
"buildHash": "77328e0a",
"generatedAt": "2026-09-07T06:25:25.567Z",
"files": {
"booking-cta/booking-cta.css": "booking-cta/booking-cta-a830c68f.css",
"booking-cta/booking-cta.iife.js": "booking-cta/booking-cta.iife-ca74009c.js",
"pdp-frame/pdp-frame-ssr.umd.js": "pdp-frame/pdp-frame-ssr.umd-5872d9b8.js",
"pdp-frame/pdp-frame.css": "pdp-frame/pdp-frame-9992937d.css",
"pdp-frame/pdp-frame.iife.js": "pdp-frame/pdp-frame.iife-be6fddd4.js",
"stay-hero/hero-bg-Cr1v4Zys.png": "stay-hero/hero-bg-Cr1v4Zys-1cc265f5.png",
"stay-hero/stay-hero-ssr.umd.js": "stay-hero/stay-hero-ssr.umd-0b04d970.js",
"stay-hero/stay-hero.css": "stay-hero/stay-hero-f4a2cffb.css",
"stay-hero/stay-hero.iife.js": "stay-hero/stay-hero.iife-f2a3fe21.js"
}
}
| Property | Rule | Enforcement |
|---|---|---|
| Keys | dist-relative path — hero/hero.iife.js for a suite, a bare filename for v1 | generated |
| Values | {base}-{sha8}{ext}, first 8 hex of the file’s SHA-256 | generated |
manifest.json itself | excluded from files | generated |
*.map | excluded from files — no hashed twin, not /p/-servable, but still uploaded to the CDN and reachable directly | generated |
buildHash | SHA-256 (8 hex) of the sorted files map | publish forensics only — no serving path reads it |
| Presence in the built zip | required | publish ERROR — dist/manifest.json (build manifest with {buildHash, files}) is missing — run the widget-core build. |
| Hashed twin for every declared asset | required | publish ERROR — The build manifest has no hashed twin for <name>. / Hashed twin dist/<hashed> is missing from the zip. |
Three consumers read this file: the /p/ proxy (to pick a 302 target), the renderer (to emit a
direct content-addressed URL) and the built-zip audit. See
packaging-and-publishing.md.
Path normalisation: dist-relative name
Every consumer reduces a manifest asset reference — absolute URL or path — the same way before looking it up.
// Source: packages/homerunner-widget-core/src/urls.ts:107-118 (mirrored verbatim in
// packages/homerunner-embeddables/api/plugin-asset.ts:194-204 and
// packages/homerunner-renderer/lib/render-utils.tsx:177-188)
export function distRelativeAssetName(urlOrPath: string): string {
const clean = urlOrPath.split("#")[0]!.split("?")[0]!;
const marker = clean.lastIndexOf("dist/");
if (marker !== -1 && (marker === 0 || clean[marker - 1] === "/")) {
const rel = clean.slice(marker + "dist/".length);
if (!rel.split("/").some((segment) => /^\.+$/.test(segment))) return rel;
}
return extractAssetFilename(clean); // last path segment
}
Consequences you can rely on (Both): everything after the last dist/ survives,
including the widget directory; a path with no dist/ segment collapses to its basename; a
path containing a dot-only segment (dist/../evil.js) also collapses to its basename, so
traversal can never escape the version prefix.
3. Where published files live
Published objects are keyed {env}/{slug}/{version}/{path} in one shared bucket, served from
the plugin CDN (R2_PUBLIC_BASE_URL; https://plugins.homerunner.io in production).
// Source: src/lib/r2.ts:64-80 + src/lib/plugin-zip.ts:532-533,904-911
{env}/{slug}/{version}/manifest.json public, max-age=300
{env}/{slug}/{version}/dist/manifest.json public, max-age=300
{env}/{slug}/{version}/dist/<file> public, max-age=31536000, immutable
{env}/{slug}/{version}/dist/{widget}/<file> immutable (bundles, css, images, fonts, .map)
{env}/{slug}/{version}/widgets/{widget}.manifest.json immutable
{env}/{slug}/{version}/media/<file> immutable (icon, cover, screenshots, README)
{env}/{slug}/{version}/media/{dir}/<file>.md immutable (per-widget READMEs)
# {env} = prod (production dashboard) | dev (preview deployments) | local (a laptop)
Exactly two objects are not immutable: the root manifest.json and dist/manifest.json,
both public, max-age=300. Everything else — including media/ and .map files — is
public, max-age=31536000, immutable.
The {env} segment is mandatory in every URL. Verifying a publish:
# Source: src/lib/r2.ts:70-80 (cdnPluginUrl) — the {env} prefix is never optional
BASE=https://plugins.homerunner.io/prod/pdp-suite/1.3.3
curl -s $BASE/manifest.json | jq .version
curl -s $BASE/dist/manifest.json | jq .buildHash
curl -sI https://assets.homerunner.io/p/pdp-suite/stay-hero/stay-hero.iife.js | head -1 # 302
Version bumping, immutability and rollback are owned by publish-versions-and-rollback.md.
4. Two serving paths
There is no single origin and no single resolution rule. Which URL a browser sees depends on how the page was built.
| Path | Who uses it | URL emitted | Redirect | Cached |
|---|---|---|---|---|
| Direct content-addressed CDN | Server-rendered / composed customer pages, for pipeline-published plugins | {cdn}/{env}/{slug}/{version}/dist/[{widget}/]{file}-{sha8}.{ext} | none | 1 year, immutable |
/p/ proxy | CSR embed snippets, the playground, the client runtime’s default CSS resolution, legacy author-hosted plugins, and any lookup failure | {proxy}/p/{slug}/{distRelativeFile} | one 302 to the CDN | 60 s + SWR 30 s on the 302; the target is immutable |
Because the direct path has no pointer indirection, a cached page always loads the exact bundle its HTML was rendered with — there is no window in which new HTML meets an old bundle.
How the renderer resolves an asset
// Source: packages/homerunner-renderer/lib/render-utils.tsx:196-273
1. file = distRelativeName(url) // "hero/hero.iife.js"
2. anchor = resolvePluginAssetUrl(manifest_url, widget.assets.js)
3. if anchor does NOT match /\/\d+\.\d+\.\d+\/dist\/(?:[^/]+\/)?[^/]+$/
or its host is localhost/127.0.0.1 -> go to step 7
4. distBase = anchor up to and including "/dist"
5. GET {distBase}/manifest.json (3 s timeout; cached for the life of the
process, failures negative-cached for the life of the process)
6. if files[file] exists -> {distBase}/{files[file]} DONE
7. fallback: {proxyBase}/p/{slug}/{file}
proxyBase = NEXT_PUBLIC_WIDGET_ASSET_BASE_URL
?? NEXT_PUBLIC_PROXY_BASE_URL
?? "https://assets.homerunner.io"
Two rules follow, and both surprise authors:
-
Anything
getStaticAssets()returns is rewritten (Both). Absolute URLs are not passed through — they are reduced to a dist-relative name by step 1 and re-resolved. You cannot self-host an asset fromgetStaticAssets. The only pass-through channel for a third-party stylesheet URL isassets.fonts(§9). See component-and-ssr-module.md. -
Pre-release versions lose the direct path.
1.0.0-rc.1publishes, but every immutability detector requires a bareX.Y.Zpath segment, so a pre-release prefix falls back to the/p/proxy and to TTL caching everywhere.Not supported yet. Nothing warns you about this. Ship plain
MAJOR.MINOR.PATCHversions — enforcement level today is advisory (unvalidated).
Neither the worker nor the dashboard puts crossorigin on a plugin script tag on either path
(buildScriptsHtml, src/lib/homerunner/cf-worker.ts:2485-2493). See §6.
5. The /p/ plugin asset proxy
GET https://assets.homerunner.io/p/{slug}/{file}
There is no version segment and there never was one. {file} is the dist-relative name:
flat (acme.iife.js) for a v1 plugin, widget-nested (stay-hero/stay-hero.iife.js) for a
suite. Bumping your version does not change this URL — it changes the 302 target.
| Condition | Status | Body / headers |
|---|---|---|
No / after the slug | 400 | {"error":"Missing path: /p/{slug}/{file}"} |
Slug unknown, not approved, or suspended | 404 | {"error":"Plugin not found"} |
Manifest has no usable assets.js to anchor dist/ | 500 | {"error":"Could not resolve plugin dist location"} |
| File not on the allow-list | 404 | {"error":"Asset not in plugin manifest"} |
| Served | 302 | Location: {distOrigin}{distBase}/{hashedFile}, Cache-Control: public, max-age=60, stale-while-revalidate=30, Access-Control-Allow-Origin: * |
The allow-list
A file serves only if either condition holds (Both):
- it is a manifest-declared asset — root
assets.js/assets.css/ssr.url, or any widget summary’sassets.js/assets.css/ssr.url— compared after dist-relative normalisation; or - it is a key of your own
dist/manifest.jsonfilesmap. This is how SDK-emitted images, font files and?urlimports reach the browser.
Hashed twins are map values, never keys, so a hashed filename is only reachable through the
302 — not by requesting it from /p/ directly.
assets.fonts entries are not in the declared list (manifestKnownFilename does not read
them). A dist-relative font file therefore serves only because rule 2 covers it — which it does,
because everything under dist/ except manifest.json and *.map lands in the files map.
Suite-only rule. For a manifest with widgets[], a file admitted by rule 2 must sit under a
first-level directory whose name equals a widget slug still present in the manifest central
serves:
// Source: packages/homerunner-embeddables/api/plugin-asset.ts:276-287
const slash = filePath.indexOf('/');
if (slash <= 0) return false;
const dir = filePath.slice(0, slash);
return widgets.some((w) => w?.slug === dir);
Two consequences:
- A suite asset emitted at the top of
dist/(no widget directory) can never serve through/p/. Keep bundled assets inside the widget’s own folder — which the SDK’sassetFileNamesdoes for you (§8). - Disabling a suite widget with the platform kill switch 404s that widget’s entire
dist/{widget}/directory — bundles, images and fonts — not just its picker entry, within the proxy’s 60 s TTL. See install-and-kill-switches.md.
Proxy caching internals
| Cache | TTL | Notes |
|---|---|---|
Plugin lookup (central /public-api/v1/plugins/{slug}) | 60 s per edge instance | Only 403/404/410 are negative-cached; 429, 5xx and timeouts keep serving the last good entry |
Build manifest, immutable prefix (/{X.Y.Z}/dist on a non-loopback host) | life of the edge instance | The prefix can never change, so neither can the map |
| Build manifest, legacy/author-hosted | 60 s | Files can be overwritten in place |
| Upstream fetches | 3 s timeout | A failed refresh serves the last good map (stale-on-error) |
| No build manifest at all | — | The 302 falls back to the logical filename, so a plugin that shipped none still serves |
Legacy alias
{dashboard}/api/plugin-assets/{slug}[@{version}]/{file} still 302s onto /p/{slug}/{file},
stripping any @version. It exists only for IIFEs cached at customer edges before the proxy
moved, it sends no Access-Control-Allow-Origin, and you must never emit it.
6. The crossorigin trap
Never put
crossorigin="anonymous"on a/p/script tag.
/p/ answers with ACAO: *, but it 302s off-host to the plugin CDN, and the redirect
target sends no Access-Control-Allow-Origin. In CORS mode the browser therefore rejects the
final response, the script never executes, and your plugin never registers on
window.HRPlugins — with no error you can attribute to the cause.
<!-- Source: src/app/(protected)/feeds/[id]/widgets/[widgetId]/[widgetName]/playground/EmbedCodeDialog.tsx:63-85 -->
<!-- runtime: same-host redirect chain, ACAO on every hop → crossorigin is safe -->
<script src="https://assets.homerunner.io/w/runtime.iife.js" crossorigin="anonymous"></script>
<!-- plugin bundle: NO crossorigin attribute, deliberately -->
<script src="https://assets.homerunner.io/p/pdp-suite/stay-hero/stay-hero.iife.js"></script>
<div data-hr-widget-container>
<div id="{widgetId}" data-hr-widget="plugin:pdp-suite:stay-hero" data-hr-min-height="160"></div>
</div>
The cost of leaving it off is that cross-origin script errors from your bundle reach the host
page’s window.onerror as the opaque "Script error.". That is the accepted trade.
CSP
Advisory (unvalidated) — nothing on the platform inspects a customer’s CSP.
A customer page that enforces Content-Security-Policy needs both origins, because the two serving paths use different ones:
| Directive | Needs | Why |
|---|---|---|
script-src | the asset origin (assets.homerunner.io) | runtime IIFE, /p/ entry point |
script-src | the plugin CDN origin (plugins.homerunner.io) | direct URLs on composed pages, and the /p/ 302 target |
style-src | both of the above | widget CSS follows the same two paths |
style-src / font-src | every absolute host in your assets.fonts (e.g. fonts.googleapis.com, fonts.gstatic.com) | declared fonts are injected as <link rel="stylesheet"> |
img-src / font-src | data: | assets ≤ 4096 bytes are inlined as data URLs (§8) |
Both origins are deployment-configurable (NEXT_PUBLIC_WIDGET_ASSET_BASE_URL,
R2_PUBLIC_BASE_URL); confirm the live values with a curl -sI on one of your own bundles
before handing a customer a CSP snippet.
7. SDK URL helpers
These live on the @homerunner-next/widget-core/urls subpath and are re-exported from the root
barrel. Full export inventory: sdk-exports.md.
| Export | Signature | Behaviour |
|---|---|---|
PLUGIN_WIDGET_PREFIX | "plugin:" | — |
parsePluginKeyword | (keyword: string) => PluginKeywordParts | null | §1 |
pluginWidgetDistFile | (parts, kind: "iife" | "css" | "ssr") => string | §2 |
DEFAULT_PROXY_BASE_URL | "https://assets.homerunner.io" | The asset origin, not the dashboard |
isAbsoluteUrl | (url: string) => boolean | Matches ^https?:// only. Protocol-relative //host/x and data: count as RELATIVE |
resolvePluginAssetUrl | (manifestUrl: string | undefined, urlOrPath: string) => string | Manifest URL first. Absolute input passes through; relative input resolves via new URL(urlOrPath, manifestUrl); throws when relative and manifestUrl is undefined |
distRelativeAssetName | (urlOrPath: string) => string | §2 |
extractAssetFilename | (urlOrPath: string) => string | Last path segment, query and hash stripped |
getPluginAssetUrl | (slug, file, proxyBaseUrl = DEFAULT_PROXY_BASE_URL) => string | {base}/p/{slug}/{file}, trailing slash stripped |
The throw, verbatim:
// Source: packages/homerunner-widget-core/src/urls.ts:93-96
Cannot resolve relative plugin asset "<x>": manifest URL not known. Ship absolute URLs in the
manifest, or pass the manifest URL when fetching.
On a server-rendered page that throw is caught and becomes a per-widget failure with the reason
bad plugin asset URL: … — the widget disappears, the page is marked degraded, the rest of the
page still renders.
Because path-only references resolve against the manifest’s own URL, a leading slash escapes
your version prefix entirely (/shared/foo.js → {cdn}/shared/foo.js). Never start a manifest
asset path with /. In a Suite manifest the audit rejects it for you (publish ERROR —
the confined-relative-path rule, see widget-summary.md); in the
Single-widget shape the root assets/ssr paths are checked only for presence, so a leading
slash is advisory (unvalidated) and 404s in production.
8. Bundled assets
Requires widget-core 0.12.0+.
Import images, icons and font files from your widget folder like any Vite project. The SDK rewrites the reference so it resolves against wherever the bundle was actually loaded from.
// Source: /Users/yhshanto/Projects/hcs/hr-plugins/pdp-suite/src/widgets/stay-hero/widget.tsx:4,19
import heroBg from "./hero-bg.png";
// …
<img className="shero-bg" src={heroBg} alt="" data-testid="stay-hero-bg" />
/* Source: /Users/yhshanto/Projects/hcs/hr-plugins/pdp-suite/src/widgets/stay-hero/stay-hero.css:7 */
background: linear-gradient(120deg, #4f46e5, #9333ea), url(./hero-bg.png);
| Rule | Value | Shape | Enforcement |
|---|---|---|---|
| Inline threshold | a file of a listed extension ≤ 4096 bytes becomes a data: URL; above it, a real file is emitted | Both | build behaviour |
| Emitted name (JS-referenced) | dist/{widget}/{name}-{vitehash}{ext} (suite) · dist/{name}-{vitehash}{ext} (v1) | Both | build behaviour |
Emitted name (CSS url()) | same; the reference stays relative (url(./hero-bg-Cr1v4Zys.png)) and the browser resolves it against the stylesheet’s response URL | Both | build behaviour |
| Listed extensions | png jpg jpeg gif webp avif svg ico bmp woff woff2 ttf otf eot mp3 mp4 webm ogg wav pdf | Both | build behaviour |
Must live under dist/{widget}/ to serve via /p/ | yes | Suite | see §5 — silent 404 otherwise |
Vite’s library mode inlines everything unless a reference carries ?no-inline; the preset
appends that suffix for you above the threshold, which is why the limit works here at all. The
consequence: an asset import whose extension is not on that list is inlined as a data: URL
at any size, because nothing tags it ?no-inline. Keep unusual asset types off the import
graph and fetch them instead.
How the URL is computed at runtime
The build prepends a plain-ES5 banner to both the client IIFE and the SSR bundle:
// Source: packages/homerunner-widget-core/src/vite/plugin-assets.ts:57-69 (formatted;
// see the same code minified at the head of pdp-suite/dist/stay-hero/stay-hero.iife.js)
var __hrAssetBase_pdp_suite_stay_hero = (function () {
try {
if (typeof document !== "undefined" && document.currentScript && document.currentScript.src) {
var u = new URL(document.currentScript.src);
var segs = u.pathname.split("/");
segs.splice(-2); // 2 for a suite bundle, 1 for a v1 bundle
return u.origin + segs.join("/") + "/";
}
} catch (e) {}
return (typeof globalThis !== "undefined" && globalThis.__HR_PLUGIN_ASSET_BASE__) || "";
})();
function __hrAsset_pdp_suite_stay_hero(f) {
try { return new URL(f, __hrAssetBase_pdp_suite_stay_hero).href; } catch (e) { return f; }
}
The import site becomes __hrAsset_…("stay-hero/hero-bg-Cr1v4Zys.png") in both builds. The base
resolves to the plugin’s …/dist/ directory:
| Context | Base comes from | Value |
|---|---|---|
| Browser | document.currentScript.src minus 2 path segments (suite) or 1 (v1) | …/{env}/{slug}/{version}/dist/ |
| SSR sandbox | globalThis.__HR_PLUGIN_ASSET_BASE__, set by the renderer from the SSR bundle’s own URL | …/{env}/{slug}/{version}/dist/ |
npm run dev | document.currentScript.src | the Vite dev origin |
document.currentScript is only valid during the bundle’s synchronous top-level execution — the
banner runs there and captures the base once, so a later dynamic import cannot break it.
Not supported yet. Nothing verifies that a
dist/-relative reference actually shipped. Asset presence is advisory (unvalidated) at publish: only manifest-declared assets (assets.js,assets.css,ssr.url, sub-manifests,media/refs) are checked. A bundled asset lost by a bad build 404s in production with no earlier signal.
9. Web fonts — assets.fonts
Requires widget-core 0.12.0+.
@font-face is ignored inside a shadow root. A stylesheet loaded into your widget’s shadow
root can reference a font family but can never define one. That is the entire reason this field
exists: declared fonts are injected at document level, where the browser honours them.
// Source: /Users/yhshanto/Projects/hcs/hr-plugins/pdp-suite/manifest.json (real, v1.3.3)
{
"slug": "pdp-frame",
"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"]
}
}
| Field | Type | Required | Rule | On violation |
|---|---|---|---|---|
widgets[].assets.fonts | string[] | Optional (Suite) | Max 8 entries | publish ERROR — manifest widget "<slug>" "assets.fonts" must be a list of at most 8 stylesheets. |
widgets[].assets.fonts[] | string | — | Absolute http(s)://…, or a relative path with no leading /, no scheme: prefix, no \, no .. segment, ≤ 2048 chars | publish ERROR — manifest widget "<slug>" "assets.fonts" entries must be absolute http(s) URLs or relative paths inside the version prefix. |
root assets.fonts | string[] | Optional (Single-widget) | Same value rules | silent runtime truncation — the v1 root is not audited at publish; normalizeFonts drops invalid entries and slices to 8 at resolve time |
| File presence for a relative entry | — | — | must exist under dist/ | advisory (unvalidated) — a missing file 404s at runtime |
normalizeFonts also applies on the suite path: anything that slips past the audit is dropped,
and an over-long list is capped at 8 with no message.
The two injection paths
| Path | Who injects | Where | Dedupe key |
|---|---|---|---|
| Client (CSR embeds, hydration) | the ES5 banner compiled into your client IIFE | document.head | link[data-hr-font="{href}"], shared across every widget and plugin on the page |
| Server (composed pages) | the renderer appends resolved fonts to the widget’s css array; the worker turns that array into <link rel="stylesheet"> in <head> | <head> | none — the renderer owns the list |
The SSR bundle carries no font loader (loadFonts: false); the renderer does that job. This
is why you must declare fonts in the manifest rather than injecting them yourself: on a
server-rendered page your client code has not run at first paint.
Resolution differs per entry kind:
- Absolute
http(s)URLs pass through unchanged on both paths. This is the only channel in the whole plugin system that lets you reference a third-party stylesheet URL verbatim. - Relative entries go through §4’s hashed-CDN-first resolution, exactly like your bundles.
The client banner strips a leading
dist/and resolves the rest against the asset base.
Two behaviours worth knowing:
- Declared fonts share the same
cssarray as your widget stylesheet, so they also appear as<link>tags inside the Declarative Shadow DOM template — harmless duplication, but you will see them there when reading page source. (HR_DSD=inlinenever inlines afonts.googleapisURL; it stays a link.) - The build reads
assets.fontsfrommanifest.jsonat config time and bakes the list into the banner, filtering only for non-empty strings before slicing to 8 — it does not run the reference validator. Editingmanifest.jsonwithout rebuilding leaves a stale list in the client bundle, and a malformed entry can ship in the banner while the server-side resolver drops it.
Choosing families, dark-mode and font.family from the base schema are covered in
styling-and-theming.md.
10. Dev-server URLs
npm run dev serves the same URL shapes so mount()’s default resolution works unchanged. The
switch is the __HR_WIDGET_DEV__ define, set to true in dev and false in a build.
// Source: packages/homerunner-widget-core/src/runtime/mount.tsx:134-138
const cssFile = pluginWidgetDistFile(parts, "css");
if (isViteDev()) {
return [`/dist/${parts.pluginSlug}/${cssFile}`];
}
return [getPluginAssetUrl(parts.pluginSlug, cssFile, getProxyBaseUrlOverride())];
| Route | Shape | Serves |
|---|---|---|
/dist/{slug}/{slug}.css | Single-widget | Every .css imported from src/, run through the full Vite transform (Tailwind, PostCSS), concatenated in import order |
/dist/{slug}/{widget}/{widget}.css | Suite | The same, walking from src/widgets/{widget}/index.tsx so each widget’s dev stylesheet carries only its own imports |
/dist/* | Both | Static files from the project’s dist/, with Access-Control-Allow-Origin: * |
/w/{file} | Both | 302 to the hashed twin from dist/manifest.json — mirrors the production proxy contract |
/w/manifest.json, /manifest.json | Both | The build manifest and the root manifest |
CSS imported from src/ is redirected to a no-op virtual module in dev so Vite never injects a
<style> into document.head (a shadow root would ignore it anyway). On a .css change the
server emits the custom HMR event hr-widget-css-update instead of Vite’s default CSS HMR, and
a small client compiled into each widget entry cache-busts the <link> tags inside the shadow
root by appending ?t={timestamp}.
The production proxy origin is overridable per host page via
window.HRWidgetRuntime.proxyBaseUrl; plugin builds carry no environment-specific URL. More on
the dev sandbox: dev-sandbox-and-mocking.md.
Known gaps
Not supported yet. You cannot self-host any asset.
getStaticAssets()output is reduced to a dist-relative name and re-resolved against your published prefix, and register-by-URL is an admin-only escape hatch with no author-facing UI.assets.fontsabsolute URLs are the single exception.
Not supported yet. Pre-release versions (
1.0.0-rc.1) publish successfully but silently drop out of every immutability optimisation — direct hashed URLs, the proxy’s permanent build-manifest cache, and the slug-keyed SSR bundle cache all fall back to TTLs.
Not supported yet. A suite asset emitted outside
dist/{widget}/can never serve through/p/, and nothing warns you at build or publish time.
Not supported yet. Bundled and font asset presence is unvalidated at publish. Only manifest-declared bundles, sub-manifests and
media/refs are checked.
Symptom-first diagnosis for 404s, missing fonts and non-registering bundles: troubleshooting.md. Every number on this page is also indexed in limits-and-errors.md.