@raybase/galley
v0.9.0
Published
Native EmDash/Astro plugin for runtime-authored Liquid block templates.
Downloads
1,195
Readme
Galley
Galley is a native EmDash/Astro plugin for runtime-authored Liquid section templates. Editors can insert local or shared template blocks into Portable Text, render designed JSON fields on public Astro routes, and edit local settings plus declared CMS context fields from the live EmDash toolbar.
Install
pnpm add @raybase/galleyRegister it as a native plugin in astro.config.mjs:
import { galley } from "@raybase/galley";
emdash({
database,
storage,
plugins: [galley()],
});Native plugins must be registered in plugins, not sandboxed.
Exports
@raybase/galleyexports the EmDash plugin descriptor factory.@raybase/galley/runtimeexports the native plugin runtime.@raybase/galley/adminexports the React admin entry.@raybase/galley/astroexports site-side Portable Text block components.@raybase/galley/astro/GalleyBlock.astroexports the direct Astro renderer.@raybase/galley/astro/DynamicField.astroexports the designed custom-field renderer.@raybase/galley/helpersexports template helper types and validators.@raybase/galley/renderis the stable public render API for tooling and hosts.@raybase/galley/tailwindexports the optional Tailwind CSS endpoint helper.@raybase/galley/behaviorexports the optional behavior sidecar compiler/storage/route helpers.
Layout Templates
Galley supports three template roles:
surface: a normal block or designed-field template.partial: a storage-free fragment rendered from Liquid with{% render "template-id" %}.layout: a visual shell rendered throughrenderLayoutwith caller-supplied slot HTML.
Layouts declare optional slot metadata and use {% galley_slot "content" %} as an opaque insertion point:
{
"id": "site-shell",
"role": "layout",
"slots": {
"content": { "accepts": ["section", "page-starter", "portable-text"] }
}
}<div class="site-shell">
{% render "site-nav" %}
<main>{% galley_slot "content" %}</main>
{% render "site-footer" %}
</div>Render a layout from @raybase/galley/render:
import { renderLayout } from "@raybase/galley/render";
const result = await renderLayout(
layoutTemplate,
{ templateId: "site-shell", templateVersion: 1, data: {}, richText: {} },
{ content: renderedPageHtml },
);The runtime plugin also exposes a public render-layout route. Active layout pointers are stored separately so rollback is a pointer swap:
POST /_emdash/api/plugins/galley/layout-pointer/set
{ "templateId": "site-shell" }
GET /_emdash/api/plugins/galley/layout-pointer/getBatch install primitives are available at templates/save-batch and templates/publish-batch; validate all templates before writing and activate a layout last with activateLayout.
Template records can be removed with templates/delete:
POST /_emdash/api/plugins/galley/templates/delete
{ "id": "demo-card" }Deleting removes the working record and its published version snapshots, and responds 404 for unknown ids. If the template is the active layout, the route responds 409 unless the body includes "force": true, in which case the active layout pointer is cleared as well. The astro-emdash scaffold's demo:unload script (site/scripts/galley-demo.mjs) feature-detects this route and uses it to remove demo template records once this Galley version is installed; on older versions it leaves them behind as never-published drafts.
Behavior Sidecars
Templates can carry optional behaviorSource content. When behavior support is enabled, published templates compile to content-addressed JS metadata exposed in render responses as behaviorAssets.
export function enhance(root) {
if (root.dataset.enhanced) return;
root.dataset.enhanced = "1";
root.querySelector("[data-toggle]")?.addEventListener("click", () => {
root.classList.toggle("is-open");
});
}Sidecars must export enhance(root), be idempotent, and scope DOM reads/writes under root. Pass configuration through data-* attributes or non-executable JSON blobs. Galley serves persisted artifacts through handleGalleyBehaviorJsRequest; the Astro wrappers import each content-addressed module and call enhance(root) for matching rendered roots.
Behavior compilation uses a src/behavior/compiler.ts boundary. The current implementation uses native Node esbuild for publish/build-time compilation and marks it as not Worker-compatible; public Worker routes serve persisted artifacts and do not compile on request.
Optional Tailwind CSS
Galley can compile Tailwind utilities used in published runtime templates into a supplemental stylesheet. This is opt-in and Cloudflare Workers first.
Install the optional compiler package in the host app:
pnpm add @raybase/vaportrail@^0.2.0 tailwindcssConfigure Galley with an explicit Tailwind CSS source string and a Workers KV binding:
import { galley } from "@raybase/galley";
const galleyTailwindSourceCss = `
@theme reference {
--breakpoint-md: 48rem;
--color-brand: #0066cc;
}
@layer utilities {
@tailwind utilities;
}
`;
emdash({
plugins: [
galley({
tailwind: {
enabled: true,
kvBinding: "GALLEY_TAILWIND",
cssPath: "/_galley/tailwind.css",
sourceCss: galleyTailwindSourceCss,
safelist: ["sr-only"],
},
}),
],
});@raybase/[email protected] supports the standard bare
@tailwind utilities; directive in runtime emit mode. Galley still recommends
@theme reference plus the @layer utilities shape for its supplemental
stylesheet so generated Galley CSS can use host theme tokens while staying in
Tailwind's utility cascade layer. If sourceCss uses plain @theme or a bare
utilities directive, Galley normalizes those before compilation.
sourceCss must currently be self-contained for Galley's endpoint helper. A
raw host entry such as @import "tailwindcss"; requires Vaportrail's
loadStylesheet resolver, which cannot be passed through EmDash's serialized
native plugin options yet. Until that resolver path is wired into Galley, embed
the theme tokens that runtime templates need directly in sourceCss.
Add the KV binding to wrangler.jsonc:
{
"kv_namespaces": [
{
"binding": "GALLEY_TAILWIND",
"id": "<namespace-id>"
}
]
}Prefer Vaportrail's workerd export condition in the host Vite config so the
Worker build emits a static compiled-WASM module:
export default defineConfig({
vite: {
resolve: { conditions: ["workerd"] },
ssr: { resolve: { conditions: ["workerd"] } },
},
});Mount a real CSS endpoint outside EmDash's JSON plugin API wrapper. Astro treats
underscore-prefixed page directories as private, so one safe shape is
src/pages/[galleyNamespace]/tailwind.css.ts with an exact _galley guard:
import type { APIRoute } from "astro";
import { env, waitUntil } from "cloudflare:workers";
import { handleGalleyTailwindCssRequest } from "@raybase/galley/tailwind";
export const GET: APIRoute = (context) => {
if (context.params.galleyNamespace !== "_galley") {
return new Response("Not found", { status: 404 });
}
return handleGalleyTailwindCssRequest({
request: context.request,
env,
waitUntil,
options: {
tailwind: {
enabled: true,
kvBinding: "GALLEY_TAILWIND",
sourceCss: galleyTailwindSourceCss,
},
},
});
};If TypeScript cannot resolve cloudflare:workers, include the generated
worker-configuration.d.ts file from wrangler types in the host app's
tsconfig.json.
When enabled, Galley injects <link rel="stylesheet" href="/_galley/tailwind.css">
through the trusted page:fragments hook. The host layout must render EmDashHead.
If it does not, add the link manually. The endpoint scans published templates and
published template snapshots, writes a content-addressed CSS artifact plus manifest
to Workers KV, and repairs missing KV artifacts on demand.
Runtime Tailwind scanning has the normal Tailwind limitation: complete class names
must appear in template source. Liquid conditionals with complete class strings work;
constructed fragments such as bg-{{ accent }}-500 do not. Use safelist for
intentional classes that are not present in template source.
Designed Custom Fields
Galley can also register a React widget for EmDash json fields. The schema keeps the
template policy on the collection field, while each entry stores only the field value:
{
"slug": "card",
"label": "Card",
"type": "json",
"widget": "galley:block",
"options": {
"template": "feature-card",
"allow": ["feature-card", "feature-card-hero"]
}
}Stored values are clean JSON:
{
"data": { "accent": "blue", "ctaLabel": "Open feature" },
"richText": {}
}Omit templateId to inherit options.template. Set templateId to one of
options.allow to pin a per-entry override. Omit templateVersion to render the latest
published template revision. Set templateVersion to render that historical published
revision instead. Draft saves keep a prospective revision number, but only published
templates are written to version history.
A designed field can also store an ordered list of blocks — the opt-in rearrangeable region regime. Each block has the same shape as a single value, and the field renders as the concatenation of its blocks:
[
{ "data": { "ctaLabel": "Open feature" }, "richText": {} },
{ "templateId": "feature-card-hero", "data": { "headline": "Hero" }, "richText": {} }
]Each block renders as its own targeted designed-field wrapper (unique
field-id#index plus a per-block source map) inside a data-galley-field-region
container, so the on-site visual editor is the primary editing surface: inline
markers edit the owning block, the live panel edits one block at a time, and its
block controls add, remove, and reorder blocks (adding to a single-object field
opts it into the array shape on the spot). The galley:block admin widget edits
the same block list as a secondary surface. Both editors emit the single-object
shape whenever the list has exactly one block, so existing single-block fields
keep their stored shape. An empty list renders nothing (a missing value still
renders the inherited template with defaults).
Render from Astro with an explicit field ref and typed context refs:
---
import { DynamicField } from "@raybase/galley/astro";
const options = { template: "feature-card", allow: ["feature-card", "feature-card-hero"] };
---
<DynamicField
value={feature.data.card}
options={options}
fieldRef={{ collection: "features", entryId: feature.data.id, field: "card" }}
context={{ feature: { collection: "features", entryId: feature.data.id } }}
/>Templates declare CMS entries in context; fields are local block settings only:
{
"context": {
"feature": { "kind": "entry", "collection": "features", "source": "host" }
},
"fields": {
"accent": { "label": "Accent", "kind": "string", "default": "blue" },
"ctaLabel": { "label": "CTA Label", "kind": "string", "default": "Open feature" }
}
}Liquid receives context entries as top-level variables and under context:
<h2 data-dynamic-text="feature.name">{{ feature.name | escape }}</h2>
<div data-dynamic-portable-text="feature.body_copy">
{{ feature.body_copy | portable_text }}
</div>
<a data-dynamic-text="ctaLabel">{{ ctaLabel | escape }}</a>Visual editing uses data-galley-source-map wrapper metadata. Local marker paths
save to the designed field value; context marker paths save to the declared
source entry field. Context scalars edit inline, context image/media/file
fields open a media picker, and context portableText fields open the full
rich-text editing flow — all writing back to the source entry. Context json
fields stay read-only. Data-source result rows are read-only unless the template
opts in with an item scope (see Collection Detail Pages below).
Designed fields can compose child designed fields in Liquid with the galley filter:
{% for feature in features %}
{{ feature.card | galley }}
{% endfor %}Partial templates compose reusable template fragments without adding another stored surface. Mark the child template as a partial and declare argument-supplied context:
{
"id": "author-info",
"role": "partial",
"context": {
"author": { "kind": "entry", "collection": "authors", "source": "argument" }
},
"fields": {
"heading": { "label": "Heading", "kind": "string", "default": "About the author" }
}
}Call it from a surface or another partial with a string-literal template id:
{% render "author-info", author: author, heading: authorHeading %}render is for storage-free partial templates. galley is for rendering a designed
field value from an entry, such as feature.card | galley. Render arguments are
explicit: entry context arguments must evaluate to Galley entry drops, not scalar ids
or reference values. Dynamic template id expressions and per-call version pins are not
supported.
Visual editing stays scoped to the rendered partial boundary. Context markers inside
the partial, such as data-dynamic-text="author.name", edit the source entry. Local
partial markers are editable only when the caller passes a simple editable alias:
heading: authorHeading edits the parent local field, while heading: author.name
edits the author entry field. Literal, computed, defaulted, missing, or arbitrary
data-source row values render read-only.
Collection data sources can filter against declared context paths:
{
"features": {
"kind": "collection",
"collection": "features",
"where": {
"author": "author.id",
"id": { "in": "page.features" }
},
"sort": "order",
"order": "asc"
}
}When an in expression resolves to an ordered context array and no sort is set,
Galley preserves that context array order. Resolved data sources are available both
as top-level Liquid variables such as features and under dataSources.features.
Entry fields come from the live EmDash schema; Galley fields render through entry
drops such as feature.card | galley.
Collection Detail Pages
A collection detail page is a json galley:block field on the collection whose
options set a default template. Entries do not need a stored value: when the
field's options declare a default template, a null/absent value renders that
template with its field defaults, byte-identical to the value the admin writes on
first edit. The value materializes only when an editor changes a local field or
overrides the template; entry-context edits (book.title) write to the entry
fields directly and never materialize it.
The host detail-route contract:
---
// pages/[slug].astro — one shared layout for every entry in the collection.
const options = { template: "book-detail", allow: ["book-detail"] };
---
<DynamicField
value={entry.data.page ?? null}
options={options}
fieldRef={{ collection: "books", entryId: entry.id, field: "page" }}
context={{ book: { collection: "books", entryId: entry.id } }}
/>Contract points:
- Pass
value ?? null— never skip rendering because the stored value is absent. - Always pass
fieldRefand the host context object, for visitors and editors alike; the editing bridge activates only inside an edit session. - Key the host context by the collection singular (
page,post,book); templates declare the same key withsource: "host".
Per-Item Editing For Data-Source Results
Data-source results render read-only by default. A template opts a loop into per-item editing by marking each iteration's root element with an item scope:
{% for testimonial in testimonials %}
<figure data-dynamic-item="testimonials" data-dynamic-item-id="{{ testimonial.id }}">
<blockquote data-dynamic-text="testimonial.quote">{{ testimonial.quote | escape }}</blockquote>
<figcaption data-dynamic-text="testimonial.name">{{ testimonial.name | escape }}</figcaption>
</figure>
{% endfor %}data-dynamic-item names the data source and data-dynamic-item-id carries the
entry id; the pair binds the loop variable to a concrete entry for everything
inside the element. Markers inside a scope resolve against the scoped entry with
the same provenance routing (and per-field-type read-only rules) as context
paths, and each scope offers an "Edit in admin" jump. Scopes whose id does not
match a data-source entry are neutralized at render time; loop-variable markers
outside a scope stay non-editable. data-dynamic-list/data-dynamic-list-item
remain reserved for local list fields.
Local Example
This repository includes dummy-app, a fresh EmDash app that consumes the package through
the pnpm workspace.
pnpm install
pnpm --filter dummy-app dev
pnpm --filter dummy-app install:demo-templateThe admin UI is available at http://localhost:4321/_emdash/admin.
The demo template page is available at http://localhost:4321/pages/atlas-launch.
The Tailwind partials demo is available at http://localhost:4321/pages/tailwind-partials.
The custom-field demo is available at http://localhost:4321/features.
