This document describes a coherent way to build a pwb-properties EmDash plugin for this
repo.
For the next stage, where the plugin becomes write-capable and supports editing listings in
PWB, see docs/pwb-properties-plugin-write-capable.md.
The goal is narrow:
- add a read-only Properties admin page inside EmDash
- fetch property data from the existing PWB Rails API
- let editors inspect properties without leaving the CMS
- keep PWB as the source of truth for listings
This plugin does not create, edit, or delete properties in EmDash.
Treat this as a standard EmDash plugin with:
- a descriptor in
src/index.ts - runtime logic in
src/sandbox-entry.ts - Block Kit admin UI
- one persisted setting: the PWB base URL
That keeps the plugin compatible with EmDash's standard plugin model and avoids mixing in React admin code unless it becomes necessary later.
This site already uses PWB as the backend for listing/search/detail data and EmDash only for CMS-managed content.
Relevant existing code:
src/lib/pwb/client.tscontains the current PWB API integration used by Astro pagessrc/lib/pwb/types.tsdefines the response shapes the site already expectsastro.config.mjsshows how trusted and sandboxed plugins are registered today
The plugin should follow those contracts instead of inventing a second PWB API shape.
Important consequences:
- property search returns
SearchResultswithdataandmeta, notproperties - property detail returns the property object directly, not
{ property: ... } - search mode should use
sale_or_rental, notmode - the plugin should not assume undocumented globals such as
globalThis.__PWB_API_URL__
EmDash standard plugins have two entrypoints:
| File | Role |
|---|---|
src/index.ts |
descriptor factory, loaded by Vite from astro.config.mjs |
src/sandbox-entry.ts |
definePlugin({ hooks, routes }) runtime logic |
Keep them separate:
index.tsdeclares metadata, capabilities, admin pages, and entrypointsandbox-entry.tshandles route logic, storage, and Block Kit responses
Do not put business logic in index.ts.
Build the smallest useful version first:
- one admin page at
/_emdash/admin/plugins/pwb-properties/ - one settings page at
/_emdash/admin/plugins/pwb-properties/settings - list properties with pagination
- show a read-only detail view
- link out to the public property page or PWB admin if a stable URL exists
Do not add search facets, writeback, or sync jobs in v1.
Create the package directory:
mkdir -p packages/plugins/pwb-properties/srcThis repo already has a pnpm-workspace.yaml. Do not overwrite it.
Add a packages: section while preserving the existing onlyBuiltDependencies section:
packages:
- packages/plugins/*
onlyBuiltDependencies:
- better-sqlite3
- esbuild
- sharp
- workerdAdd the plugin as a workspace dependency:
{
"dependencies": {
"pwb-properties": "workspace:*"
}
}Then run:
pnpm installWhen developing a local workspace plugin, restart npx emdash dev after changing plugin
entrypoints or runtime route code. In practice, edits under packages/plugins/... may not
always be picked up cleanly by the running admin/plugin host, and stale code can make a
settings save or route fix appear broken when the new handler is not actually loaded yet.
Create packages/plugins/pwb-properties/package.json:
{
"name": "pwb-properties",
"version": "0.1.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./sandbox": "./src/sandbox-entry.ts"
},
"peerDependencies": {
"emdash": "*"
}
}Create packages/plugins/pwb-properties/tsconfig.json:
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true
},
"include": ["src"]
}packages/plugins/pwb-properties/src/index.ts
import type { PluginDescriptor } from "emdash";
export function pwbPropertiesPlugin(): PluginDescriptor {
return {
id: "pwb-properties",
version: "0.1.0",
format: "standard",
entrypoint: "pwb-properties/sandbox",
options: {},
capabilities: ["network:fetch"],
allowedHosts: ["*"],
adminPages: [
{ path: "/", label: "Properties", icon: "list" },
{ path: "/settings", label: "Settings", icon: "settings" }
]
};
}Notes:
allowedHosts: ["*"]is acceptable for local development only- in production, restrict
allowedHoststo the actual PWB API host adminPagesbelongs in the descriptor, not indefinePlugin()
If the plugin later only needs configuration, consider replacing the custom settings page
with settingsSchema. For now, keeping a custom settings page is reasonable because the
plugin also needs a custom list/detail admin UI.
packages/plugins/pwb-properties/src/sandbox-entry.ts should contain:
- helpers for reading and validating the configured PWB base URL
- helpers for calling the PWB API via
ctx.http.fetch() - one
adminroute for Block Kit interactions - optional JSON routes only if they are useful outside the admin UI
Use plugin KV for the base URL:
settings:pwbApiUrl
Do not depend on undocumented runtime globals for the main implementation.
A safe pattern is:
- read
settings:pwbApiUrl - if missing, show a settings prompt in the admin UI
- optionally seed it once during development if EmDash exposes a documented runtime env
If you cannot prove runtime env access in plugin code, leave seeding out of v1.
Mirror the shapes already defined in src/lib/pwb/types.ts.
At minimum the plugin will need equivalents of:
SearchResultsPropertySummaryProperty
The easiest long-term approach is to extract shared PWB types into a workspace package that both the site and the plugin import. If you do not want that refactor yet, copy the types carefully from the existing PWB client layer and keep them aligned.
Use the same conventions as the site:
- endpoint:
/api_public/v1/en/properties - query param for mode:
sale_or_rental=sale|rental - response:
SearchResults - list items:
data - pagination metadata:
meta.page,meta.total_pages,meta.per_page
Use:
- endpoint:
/api_public/v1/en/properties/:slug - response body: the property object itself
Use the Block Kit shapes documented in the local plugin skill reference.
Important details:
fieldsrows uselabelandvalue- form elements use
action_id - forms use
submit: { label, action_id }
Do not mix in alternate names like title, name, or submit_label unless you have
verified that the renderer accepts them.
Recommended blocks:
headeractionsfor filterssectionrows with aViewbuttonactionsfor paginationbannerfor configuration or fetch errors
Recommended blocks:
headerwith property titlefieldsfor price, bedrooms, bathrooms, type, locationsectionfor descriptionactionsfor back and external link buttons
Recommended blocks:
headercontextorsectionexplaining the URLformwith onetext_input
Example settings form shape:
{
type: "form",
block_id: "settings",
fields: [
{
type: "text_input",
action_id: "pwbApiUrl",
label: "PWB API URL",
initial_value: currentUrl,
placeholder: "https://example.com"
}
],
submit: { label: "Save", action_id: "save_settings" }
}Keep the admin route simple and state-light.
Recommended interaction flow:
page_loadon/- fetch page 1 of properties
- render list blocks
- on
view_property:<slug>, fetch detail and render detail blocks - on
page:<n>, fetch that page and render list blocks - on
filter:<mode>, fetch filtered page 1 and render list blocks - on
/settings, render the settings form - on
save_settings, validate and persist the URL, then re-render with a success toast
If the URL is not configured:
- the properties page should render a warning banner
- the settings page should remain available
If the API call fails:
- log the failure with
ctx.log - render a user-facing error banner
- avoid pretending that “no results” and “request failed” are the same thing
Validate the configured base URL before persisting it.
Recommended checks:
- must parse via
new URL() - must use
http:orhttps: - strip a trailing slash before storing
If validation fails, return a Block Kit response with an error banner or toast rather than silently saving bad data.
Add the import:
import { pwbPropertiesPlugin } from "pwb-properties";Register the plugin in exactly one array per environment.
For this repo's current pattern, that means:
plugins: isDev
? [formsPlugin(), webhookNotifierPlugin(), pwbPropertiesPlugin()]
: [formsPlugin()],
sandboxed: isDev
? []
: [webhookNotifierPlugin(), pwbPropertiesPlugin()],Do not put pwbPropertiesPlugin() in both plugins and sandboxed for the same
environment.
Add "pwb-properties" to vite.optimizeDeps.exclude alongside the other local/plugin
packages:
exclude: [
"@emdash-cms/admin",
"emdash",
"emdash/astro",
"@emdash-cms/plugin-forms",
"@emdash-cms/plugin-webhook-notifier",
"pwb-properties",
...emdashLocalExcludes,
...nativeSsrExcludes
]- scaffold package, descriptor, and runtime entrypoint
- add plugin registration to
astro.config.mjs - implement settings storage and validation
- implement list fetch against the real PWB search response
- implement detail fetch against the real PWB detail response
- add pagination and sale/rental filter buttons
- improve error states and empty states
The guide should be considered implemented only when all of the following are true:
| Check | Expected |
|---|---|
| Plugin appears in admin sidebar | Yes |
| Settings page can save a valid URL | Yes |
| Invalid URL is rejected with a clear message | Yes |
| List page loads properties from PWB | Yes |
Sale/rental filter uses sale_or_rental and works |
Yes |
Pagination uses API metadata, not items.length === 20 heuristics |
Yes |
| Detail view loads a property by slug | Yes |
| API failures show an error state distinct from empty results | Yes |
| After changing plugin runtime code locally, the dev server is restarted before retesting | Yes |
Production config registers plugin only in sandboxed |
Yes |
- Defining the same plugin in both
pluginsandsandboxedfor production. - Rebuilding PWB response types incorrectly instead of following the existing client.
- Using
modeinstead ofsale_or_rentalin PWB search requests. - Assuming property detail returns
{ property }when the site expects the object directly. - Treating API failure as an empty list.
- Overwriting
pnpm-workspace.yamlinstead of merging the workspace config. - Using undocumented env access in plugin runtime code without verifying it.
- Using Block Kit field names that differ from the documented local reference.
- Retesting a plugin fix without restarting
npx emdash dev, causing stale plugin code to remain active.
This plugin makes sense. The strongest version is a thin admin-facing adapter around the existing PWB API contracts already used by the site.
If you build it that way, you avoid two classes of problems:
- the plugin drifting from the public site's real PWB integration
- the guide drifting from EmDash's actual standard-plugin model
If you later need richer admin UX, you can revisit whether this should remain a standard Block Kit plugin or become a native/trusted plugin with React admin pages. For the current scope, standard + Block Kit is the right default.