paperfalcon
v1.0.1
Published
Paperfalcon embeddable widget SDK — render dynamic, template-driven widgets across any web environment
Downloads
330
Maintainers
Readme
Paperfalcon Widget SDK
Package: paperfalcon · Version: 1.0.0
Embeddable, template-driven widget platform for any web page. Define widgets via API (Liquid template + data + triggers), then render them through an npm import or a single <script> tag.
Table of contents
- Overview
- Installation & build
- Architecture
- Quick start
- Development guide
- Production guide
- Core API —
WidgetSDK - API contract
- Widget configuration
- Widget types
- Templates (Liquid)
- Triggers
- Analytics & frequency
- Auth
- Plugins
- Script-tag embed
- Events
- Dev tools
- Examples
- Troubleshooting
Overview
| Capability | Details |
| ------------ | ------------------------------------------------------------------------------------------------------ |
| Delivery | ESM / CJS (npm) + UMD/IIFE (<script> tag) |
| Templates | LiquidJS — content comes from your API |
| Widget types | 12: static, list, form, interactive, carousel, timer, media, social, data, container, chatbot, generic |
| Triggers | Time, scroll, exit intent, page load, location, behavior, custom |
| Analytics | Impressions, sessions, frequency capping |
| Auth | Bearer token or API key on widget config fetches |
| Plugins | Analytics, Cache, Security, Performance (+ custom) |
| Dev tools | MockServer, DevPanel, TemplatePreview, TriggerSimulator, … |
Design principles
- Template-first — new widget layouts do not require SDK code changes; update the API response.
- Dual delivery — one codebase builds npm packages and a browser bundle.
- Host-page friendly — mount into any DOM element by id.
Installation & build
Install (consumers)
npm install paperfalcon
# or
pnpm add paperfalconDevelop this repo
pnpm install
pnpm run build # ESM + CJS + UMD + types → dist/ (dev, with sourcemaps)
pnpm run build:publish # Minified, no sourcemaps — what ships to npm
pnpm run typecheck # tsc --noEmit
pnpm run clean # remove dist/
pnpm run pack:check # publish build + npm pack --dry-runFull npm release steps: see documents/sdk/npm-publish.md.
Build outputs
| Path | Format | Use |
| ---------------------------- | ------------- | ----------------------------- |
| dist/esm/index.js | ESM | Bundlers (import) |
| dist/cjs/index.js | CommonJS | require() |
| dist/umd/widget-sdk.js | IIFE | Script tag (readable / debug) |
| dist/umd/widget-sdk.min.js | IIFE minified | Script tag (production) |
| dist/types/ | .d.ts | TypeScript |
Package exports:
paperfalcon→ main SDKpaperfalcon/embed→ script-tag / embed entry
Local preview (after build):
npx serve .
# open http://localhost:3000 (or the URL printed)Architecture
Host page
│
├─ npm: import { WidgetSDK } from 'paperfalcon'
└─ script: <script src="…/widget-sdk.min.js">
│
▼
WidgetSDK.init(config)
│
┌──────────┼──────────┐
▼ ▼ ▼
ConfigManager Auth PluginManager
GET /widgets/:id
│
▼
WidgetManager → registry (static, form, …)
│
▼
BaseWidget.mount()
→ Liquid template + data
→ inject HTML into container
│
TriggerManager (optional show/hide rules)
Tracker (impressions / frequency)Request flow for sdk.load(containerId, widgetId)
- Resolve DOM
#containerId GET {apiUrl}/widgets/{widgetId}(with auth headers)- Validate config (
meta,template, …) - Register triggers / check frequency cap
- Instantiate widget class by
meta.type - Render Liquid → sanitize HTML → mount into container
- Track impression (if analytics enabled)
Quick start
npm / bundler
<div id="promo"></div>import { WidgetSDK } from 'paperfalcon';
const sdk = new WidgetSDK();
sdk.init({
apiUrl: 'https://api.example.com',
token: 'YOUR_TOKEN',
debug: false,
});
await sdk.load('promo', 'widget-abc123');Script tag
<div data-widget-id="widget-abc123"></div>
<script
src="https://cdn.example.com/widget-sdk.min.js"
data-api-url="https://api.example.com"
data-token="YOUR_TOKEN"
data-auto-init="true"
></script>Manual (no auto-init):
<div id="promo"></div>
<script src="./dist/umd/widget-sdk.min.js"></script>
<script>
const sdk = new WidgetSDK.WidgetSDK();
sdk.init({ apiUrl: 'https://api.example.com', token: 'YOUR_TOKEN' });
sdk.load('promo', 'widget-abc123');
</script>Development guide
Use this when building widgets locally without a backend.
1. Build the SDK
pnpm install
pnpm run build2. Mock the API with MockServer
MockServer patches fetch and answers GET …/widgets/:id. Start it before sdk.load().
<!DOCTYPE html>
<html>
<body>
<div id="root"></div>
<script src="./dist/umd/widget-sdk.min.js"></script>
<script>
const mock = new WidgetSDK.MockServer({
delay: 100,
widgets: [
{
meta: { id: 'hello', name: 'Hello', type: 'static', version: '1' },
template: '<div class="banner"><h2>{{ data.title }}</h2><p>{{ data.body }}</p></div>',
data: { title: 'Hello', body: 'Local mock works.' },
},
],
});
mock.start();
const sdk = new WidgetSDK.WidgetSDK();
sdk.init({ apiUrl: 'http://localhost:3000', debug: true });
sdk.load('root', 'hello');
</script>
</body>
</html>Serve with a real HTTP server (not file://):
npx serve .
# or: python3 -m http.server 80803. Debug mode
sdk.init({ apiUrl: '…', debug: true });- Verbose logging
- Floating DevPanel (events / widgets)
sdk.getDevPanel()window.__widgetSDKexposed by DevPanel
4. Seed config without HTTP
sdk.setWidgetConfig('hello', {
meta: { id: 'hello', name: 'Hello', type: 'static', version: '1' },
template: '<p>{{ data.title }}</p>',
data: { title: 'Cached config' },
});
// Still call load — ConfigManager uses cache when TTL allows
await sdk.load('root', 'hello');5. Watch rebuild (source changes)
pnpm run build:watch # if enabled via build.mjs --watch
# or rebuild after edits:
pnpm run buildHard-refresh the browser after rebuilding dist/umd/.
6. Recommended local checklist
- [ ]
pnpm run buildsucceeds - [ ] Page served over
http://localhost… - [ ]
MockServer.start()before anyload() - [ ] Container element exists (
idmatchesloadfirst arg) - [ ] Widget
meta.typeis a registered type - [ ] Templates use
{{ data.fieldName }} - [ ] Triggers set
"enabled": truewhen used - [ ] Prefer
sdk.destroy(widgetId)overinstance.destroy()alone
Production guide
Choose a delivery mode
| Mode | When to use | Bundle |
| -------------- | ----------------------------------------------------- | ------------------------------------ |
| npm | App already has a bundler (React, Vue, Vite, Next, …) | ESM/CJS from package |
| Script tag | Marketing sites, CMS, no build step | Host widget-sdk.min.js on your CDN |
npm production
import { WidgetSDK } from 'paperfalcon';
const sdk = new WidgetSDK();
sdk.init({
apiUrl: 'https://api.yourproduct.com',
token: process.env.WIDGET_SDK_TOKEN, // inject safely; never hardcode in public repos
debug: false,
timeout: 10000,
cacheTtl: 60_000,
analytics: true,
headers: {
'X-Client': 'web',
},
});
await sdk.load('slot-hero', 'promo-summer');Script-tag production
- Deploy
dist/umd/widget-sdk.min.jsto your CDN (versioned URL recommended). - Ensure CORS + HTTPS on your widget API.
- Embed:
<div id="wgt-hero" data-widget-id="promo-summer"></div>
<script
src="https://cdn.yourproduct.com/widget-sdk/1.0.0/widget-sdk.min.js"
data-api-url="https://api.yourproduct.com"
data-token="PUBLIC_OR_SCOPED_TOKEN"
data-auto-init="true"
defer
></script>Prefer short-lived / scoped tokens for public pages. Treat anything in HTML as public.
Backend requirements
Your API must implement:
GET {apiUrl}/widgets/{widgetId}
Authorization: Bearer <token> # when using bearer tokensResponse shape:
{
"success": true,
"data": { /* WidgetConfig — see below */ }
}Error statuses the client maps specially: 401, 404, 429, plus generic non-OK.
Production config recommendations
| Option | Recommendation |
| ----------- | ---------------------------------------------------- |
| debug | false |
| Bundle | widget-sdk.min.js |
| timeout | 8–15s |
| cacheTtl | 30–120s (or 0 to disable) |
| analytics | true + set tracker endpoint if you ingest events |
| HTTPS | Required for API + script |
| CSP | Allow your CDN script + API origin |
Lifecycle in SPAs
// On route enter
await sdk.load('mount', widgetId);
// On route leave / unmount
sdk.destroy(widgetId);
// Full teardown (logout, app exit)
sdk.destroyAll();After destroyAll(), call init() again before loading widgets.
Security notes
- HTML from templates is sanitized (strips
<script>, event handlers likeonclick,javascript:URLs). - Put interactive behavior in SDK bindings (
data-action, widget events), not inline JS in templates. - Use
SecurityPluginto restrict allowed origins when embedding on partner sites.
Core API — WidgetSDK
import { WidgetSDK, SDK_VERSION } from 'paperfalcon';
const sdk = new WidgetSDK();init(config: SDKConfig): void
Must be called once before load. Merges with defaults:
| Field | Type | Default | Description |
| ------------------- | ----------------------- | -------------------- | -------------------------------- |
| apiUrl | string | required | Base URL for widget API |
| token | string | — | Bearer token |
| locale | string | 'en' | Locale hint |
| debug | boolean | false | Logs + DevPanel |
| timeout | number | 10000 | HTTP timeout (ms) |
| cacheTtl | number | 60000 | Config cache TTL (ms); 0 = off |
| analytics | boolean | true | Enable Tracker |
| headers | Record<string,string> | — | Extra HTTP headers |
| fetch | typeof fetch | — | Custom fetch (tests / mocks) |
| plugins | Plugin[] | — | Installed at init |
| defaults | Partial<WidgetConfig> | — | Merged into each widget config |
| containerSelector | string | '[data-widget-id]' | Embed discovery |
Calling init twice without destroyAll() logs a warning and returns.
load(containerId, widgetId?): Promise<WidgetInstance>
containerId— DOM element idwidgetId— API id; if omitted, usesdata-widget-idon the element, then the element id
Returns a WidgetInstance:
interface WidgetInstance {
id: string;
config: WidgetConfig;
state: WidgetState;
container: HTMLElement;
mount(): Promise<void>;
unmount(): void;
update(data: Partial<WidgetData>): Promise<void>;
destroy(): void;
getState(): WidgetState;
on(event: string, handler: (data: unknown) => void): void;
off(event: string, handler: (data: unknown) => void): void;
}Other methods
| Method | Description |
| ----------------------------------- | ------------------------------------------------------- |
| destroy(widgetId) | Unmount + remove from registry (preferred) |
| destroyAll() | Destroy widgets, stop triggers/tracker, allow re-init |
| use(plugin) | Register a plugin after init |
| on / emit | SDK event bus |
| getWidget(id) / getAllWidgets() | Access instances |
| setToken(token) | Update bearer token |
| setWidgetConfig(id, config) | Seed / override cache |
| invalidateCache(id?) | Clear cached configs |
| getDevPanel() / getTracker() | Debug / analytics accessors |
| version | '1.0.0' |
API contract
GET /widgets/:id
Success
{
"success": true,
"data": {
"meta": {
"id": "promo-summer",
"name": "Summer Promo",
"type": "static",
"version": "1",
"description": "optional",
"tags": ["promo"]
},
"template": "<div>… Liquid …</div>",
"data": { },
"styles": { },
"settings": { },
"triggers": [ ],
"frequency": { },
"analytics": { }
}
}Required fields: meta.id, meta.type, meta.name, meta.version, template (string; may be empty for some types like chatbot which ignore it).
Widget configuration
meta
| Field | Required | Notes |
| --------- | -------- | --------------------- |
| id | yes | Stable widget id |
| name | yes | Display name |
| type | yes | One of the 12 types |
| version | yes | Config version string |
data
Arbitrary JSON used in Liquid as {{ data.* }}. Shape depends on widget type (see below).
styles (optional)
{
position?: { fixed?: boolean; top?: string; bottom?: string; left?: string; right?: string; zIndex?: number };
dimensions?: { width?: string; height?: string; maxWidth?: string; maxHeight?: string };
className?: string;
inlineStyles?: Record<string, string>;
/** Raw CSS string from the API — injected automatically */
css?: string;
/** Remote stylesheet URL(s) */
cssUrl?: string | string[];
}position/dimensionsbecome host rules on#containerId(good for full-screen popups).css/cssUrlare injected into the widget Shadow Root (style isolation is automatic).- Prefer API
cssover inlinestyle=""in templates.
Shadow DOM (automatic)
Shadow DOM is always enabled by default. API configs and integrators do not set shadowDom / shadowMode.
What the SDK does for you:
- Attaches a Shadow Root on the container
- Mounts template HTML inside the shadow tree
- Injects API
css/cssUrlinside the shadow (isolated from the host page) - Applies host
position/dimensionson the light-DOM container
{
"styles": { "css": ".card { padding: 1rem; background: #fff; }" }
}Opt out only if needed: "settings": { "shadowDom": false }.
Shadow events are retargeted on the host — use
event.composedPath()for click handling from outside.
settings (optional)
| Field | Default | Notes |
|-------|---------|-------|
| closable | true | Flag for hosts / templates |
| overlay | false | Flag — implement overlay in template |
| animation | 'fade' | 'fade' \| 'slide' \| 'scale' \| 'none' |
| animationDuration | 300 | ms |
| autoClose | 0 | ms; 0 = off |
| resetOnClose | false | |
Overlay / close UI is typically implemented in the Liquid template; settings are metadata for your product.
frequency (optional)
{
maxShows?: number; // 0 / omitted = unlimited
unit?: 'session' | 'day' | 'week' | 'month' | 'forever';
minInterval?: number; // seconds between shows
resetOnConversion?: boolean;
}When capped, load() returns a no-op instance and skips mount.
Widget types
static
Banners, announcements, popups.
Data: title?, content?, imageUrl?, ctaText?, ctaUrl?
list
Data: items[] (id, title?, subtitle?, imageUrl?, url?, badge?), layout?: 'list'|'grid', columns?, title?
Event: item:click → { itemId }
form
Data: fields[], submitLabel?, successMessage?, errorMessage?, action?, method?
Field: name, type (text|email|tel|number|textarea|select|checkbox|radio|hidden), label?, placeholder?, required?, options?, defaultValue?
Event: form:submit → { values, widgetId } (on the instance)
interactive
Custom click actions. Requires your own template.
Data: content, actions?: Record<string, InteractiveAction>, initialState?
DOM hooks: [data-action="…"], [data-toggle="selector"]
Action types: show | hide | toggle | navigate | emit
Event: interaction
carousel
Data: slides[], autoPlay?, autoPlayInterval? (default 4000), showArrows?, showIndicators?, loop?
Event: slide:change → { index }
timer
Countdown to targetDate (ISO string or ms).
Template extras: remaining.days|hours|minutes|seconds|total, expired, id
Event: timer:expired
media
Data: mediaType: 'video'|'audio'|'image'|'youtube'|'vimeo', src, posterUrl?, autoPlay?, muted?, loop?, controls?, caption?, width?, height?
Events: media:play, media:pause, media:ended
social
Data: platform, embedCode?, shareUrl?, shareText?, username?, postId?, showFollowButton?
Event: social:share
data
Fetches remote JSON and re-renders.
Data: source: { type, url?, method?, headers?, body?, refreshInterval?, path? }
Method: refresh(): Promise<void> (on the widget instance when cast/used as DataWidget)
container
Layout shell with named slots for child widgets.
Data:
{
layout?: 'flex' | 'grid' | 'stack';
columns?: number;
gap?: string;
slots: { id: string; widgetId?: string; selector?: string }[];
}Method: getSlot(slotId): HTMLElement | null
Typical pattern: load container, then load children into slot element ids (slot-{id}).
chatbot
Built-in chat UI (ignores Liquid template).
Data: botName?, placeholder?, welcomeMessage?, apiUrl?, websocketUrl?, maxMessages?
Transport order: WebSocket → REST POST { message, history } → local echo.
generic
Fallback type. Renders your template, or dumps {{ data | json }}.
Templates (Liquid)
Widget data is available as data.* (and also as top-level keys for convenience):
<div class="promo">
<h2>{{ data.title }}</h2>
{% if data.imageUrl %}
<img src="{{ data.imageUrl }}" alt="{{ data.title }}" />
{% endif %}
{% for item in data.items %}
<p>{{ item.name }} — {{ item.price | currency: '$' }}</p>
{% endfor %}
</div>Built-in filters: truncate_words, currency, json, default (+ standard Liquid filters).
Rendered HTML is sanitized before insert (no <script>, no on* handlers).
Triggers
Attached on the widget config. Evaluated by TriggerManager on a ~500ms poll (+ exit-intent listener).
{
"triggers": [
{
"id": "show-after-3s",
"enabled": true,
"conditionOperator": "AND",
"once": true,
"conditions": [
{ "type": "time", "params": { "delay": 3 } }
],
"actions": [
{ "type": "show", "params": { "containerId": "popup-root" } }
]
}
]
}
enabledmust betrue. If omitted, the trigger is ignored.
Conditions
| type | Params | Meaning |
| ------------ | --------------------------- | ------------------------------- |
| time | delay (seconds) | Session duration ≥ delay |
| scroll | threshold (%, default 50) | Scroll depth ≥ threshold |
| pageLoad | — | Always true |
| exitIntent | — | Cursor leaves toward top |
| location | pathname | Path contains string |
| behavior | pageViews | Session page views ≥ N |
| custom | key, value | context.custom[key] === value |
Actions
| type | Params | Effect |
| --------------- | ------------------ | --------------------------- |
| show | containerId | Show element |
| hide | containerId | Hide element |
| updateContent | widgetId, data | widget.update(data) |
| delay | ms | Wait before next action |
| custom | name, … | Runs registerCustomAction |
Show/hide toggle visibility of a DOM node; they do not by themselves call sdk.load. Typical popup flow: load (or pre-create) the widget, keep container hidden, then show when conditions match — or call sdk.load from your own code on a timer/button.
Analytics & frequency
When analytics: true (default), init creates a Tracker:
- Impressions recorded on successful
load - Session id in storage (30 min inactivity → new session)
- Frequency checks before mount when
frequencyis set
const tracker = sdk.getTracker();
tracker?.events.track('conversion', 'promo-summer', { plan: 'pro' });Event types: impression | click | submit | close | conversion | error | custom
To flush to your backend, configure the tracker’s analytics endpoint (batch upload). Without an endpoint, events stay local / no-op on flush.
Auth
sdk.init({ apiUrl: '…', token: 'eyJ…' });
sdk.setToken('new-token'); // later- Bearer →
Authorization: Bearer … - Utilities also exist for API key (
X-Api-Key) viaAuthManagerif you wire them yourself TokenManager/AuthInterceptorare available as building blocks for custom auth flows
Config fetch refreshes the token when marked expired (if refresh is configured on AuthManager).
Plugins
import {
WidgetSDK,
AnalyticsPlugin,
CachePlugin,
SecurityPlugin,
PerformancePlugin,
} from 'paperfalcon';
const sdk = new WidgetSDK();
sdk.init({
apiUrl: 'https://api.example.com',
plugins: [
new AnalyticsPlugin(),
new CachePlugin(5 * 60 * 1000),
new SecurityPlugin({ allowedOrigins: ['example.com'] }),
new PerformancePlugin(),
],
});
// or later
sdk.use(new PerformancePlugin());Plugin interface
interface Plugin {
name: string;
version?: string;
install(sdk: SDKInstance): void;
hooks?: Partial<Record<PluginHook, PluginHookFn>>;
}Hooks: beforeInit, afterInit, beforeLoad, afterLoad, beforeRender, afterRender, beforeDestroy, afterDestroy, onError, onEvent
The SDK currently invokes beforeLoad / afterLoad during load().
| Plugin | Role |
| ------------------- | --------------------------------------------------------- |
| AnalyticsPlugin | Listens to mount/click/submit-style bus events |
| CachePlugin | Extra in-memory / sessionStorage helpers |
| SecurityPlugin | Origin allowlist + sanitize hooks |
| PerformancePlugin | Render timing (getTimeline(), getAverageRenderTime()) |
Script-tag embed
Attributes on the SDK <script>
| Attribute | Purpose |
| ---------------- | ------------------------------------------------- |
| data-api-url | API base URL |
| data-token | Auth token |
| data-widget-id | Optional: auto-create a host and load this widget |
| data-debug | "true" enables debug |
| data-auto-init | Default on; "false" disables auto discovery |
Placeholders
<div data-widget-id="promo-summer"></div>On DOM ready, embed runtime:
- Parses script attributes
inits an SDK instance →window.widgetSDKInstance- Discovers
[data-widget-id]and callsload - Optionally loads
data-widget-idfrom the script into a generated container
UMD global: WidgetSDK (class at WidgetSDK.WidgetSDK, plus MockServer, etc.).
Events
SDK-level (sdk.on)
Emitted today:
| Event | Payload (approx.) |
| ----------------- | -------------------- |
| sdk:init | { version } |
| sdk:destroy | {} |
| widget:load | { widgetId, type } |
| widget:mount | { widgetId } |
| widget:update | { widgetId, data } |
| widget:destroy | { widgetId } |
| plugin:register | { name } |
| auth:token:set | {} |
sdk.on('widget:mount', (data) => console.log(data));Widget-level (instance.on)
Depends on type: item:click, form:submit, interaction, slide:change, timer:expired, media:*, social:share, …
const w = await sdk.load('form-root', 'lead-form');
w.on('form:submit', (payload) => {
console.log(payload);
});Dev tools
| Tool | Use |
| -------------------- | -------------------------------------- |
| MockServer | Fake GET /widgets/:id in the browser |
| DevPanel | Floating panel when debug: true |
| DevTools | inspectAll(), exposeOnWindow() |
| EventViewer | Capture / list bus events |
| TriggerSimulator | Fire triggers with context overrides |
| TemplatePreview | preview(template, data), validate |
| StateManager | Snapshot / diff widget state |
| PerformanceMonitor | Marks / measures |
| WidgetInspector | Visual outline overlay |
| ConfigEditor | Live JSON editing helper |
import { MockServer, TemplatePreview } from 'paperfalcon';
const preview = new TemplatePreview();
const html = await preview.preview('<h1>{{ data.title }}</h1>', { title: 'Hi' });Examples
Popup (static + overlay template)
await sdk.load('popup-root', 'promo-popup');
// Widget config styles: position.fixed + full width/height
// Template: fixed overlay + centered card + [data-close] button
// Close: sdk.destroy('promo-popup')See index.html in this repo for a full MockServer popup demo.
Container + children
await sdk.load('layout-root', 'container-home');
// slots render as #slot-header, #slot-sidebar, …
await sdk.load('slot-header', 'banner-top');
await sdk.load('slot-main', 'product-list');Form submit handling
const form = await sdk.load('lead', 'lead-capture');
form.on('form:submit', (data) => {
// { values, widgetId }
fetch('/api/leads', { method: 'POST', body: JSON.stringify(data.values) });
});Time-delayed show (host-driven)
// Keep #popup-root empty until ready
setTimeout(() => sdk.load('popup-root', 'promo-popup'), 3000);Or use a trigger show action on a pre-mounted, hidden container.
Troubleshooting
| Symptom | Cause / fix |
| ---------------------------- | -------------------------------------------------------------------------- |
| Blank {{ data.* }} text | Rebuild after template-context fix; confirm Liquid uses data. prefix |
| Button load does nothing | Call sdk.destroy(id) before reloading the same widget id |
| CONTAINER_NOT_FOUND | Element id missing or script ran before DOM |
| Network / CORS errors | Serve over HTTP; API must allow your origin |
| Mock ignored | mock.start() before sdk.init / load; URL must contain /widgets/:id |
| Trigger never fires | Set "enabled": true |
| Popup has no dimmed backdrop | Put overlay markup in the template, not only settings.overlay |
| file:// broken | Use npx serve . |
| Stale UI after code change | pnpm run build + hard refresh |
Destroy correctly
// ✅ removes from registry — safe to load again
sdk.destroy('promo-popup');
// ⚠️ instance-only destroy leaves a stale registry entry
instance.destroy();Project layout
src/
WidgetSDK.ts Main class
index.ts npm exports
embed.ts Script-tag entry
core/ Config, registry, manager, template, plugins, auth
widgets/ 12 widget implementations
triggers/ Conditions + actions + scheduler
analytics/ Tracker, session, frequency
embed/ Script discovery / host helpers
plugins/ Built-in plugins
auth/ Token helpers
dev/ MockServer, DevPanel, …
types/ TypeScript types
utils/ HTTP, sanitize, DOM, …
build.mjs esbuild multi-format build
dist/ Build output (generated)
index.html Local popup demo
README.md This documentLicense
MIT
