npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/galley

Register 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/galley exports the EmDash plugin descriptor factory.
  • @raybase/galley/runtime exports the native plugin runtime.
  • @raybase/galley/admin exports the React admin entry.
  • @raybase/galley/astro exports site-side Portable Text block components.
  • @raybase/galley/astro/GalleyBlock.astro exports the direct Astro renderer.
  • @raybase/galley/astro/DynamicField.astro exports the designed custom-field renderer.
  • @raybase/galley/helpers exports template helper types and validators.
  • @raybase/galley/render is the stable public render API for tooling and hosts.
  • @raybase/galley/tailwind exports the optional Tailwind CSS endpoint helper.
  • @raybase/galley/behavior exports 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 through renderLayout with 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/get

Batch 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 tailwindcss

Configure 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:

  1. Pass value ?? null — never skip rendering because the stored value is absent.
  2. Always pass fieldRef and the host context object, for visitors and editors alike; the editing bridge activates only inside an edit session.
  3. Key the host context by the collection singular (page, post, book); templates declare the same key with source: "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-template

The 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.