clay.js
  1. ClayJS
  2. Reference
  3. Setup
  4. Plugins

Plugins

Every plugin, what each one does, and how to load it by name.

Everything past the core is a plugin. Plugins load through the URL, in the browser only: ?plugins=cms asks for one by name, ?plugins=wire,sync for several, comma separated, and ?exclude=source turns a default one off. Whatever a page loads is then reachable from clay.

Three plugins have no page of their own. indicator is a ready-made save-status chip driven by the clay:save-* events; it shows Saving… and Saved, and with clay-ui.js loaded a failed or offline save is reported by a persistent toast instead of the chip, so no save is reported twice. sync is live sync: outside file changes and other open tabs merge into the page in place, preserving focus, caret, and unsaved edits (a three-way merge: both sides’ edits survive, and the incoming change wins where both changed the same words); it powers clay.morph and the merge="name" JSON script-tag merging. demo saves into browser storage instead of a host, which is what clayjs.com’s live demos use; clay.demo.reset() forgets the browser copy.

cms#

A content sidebar that builds itself from your page; load it with ?plugins=cms. cms

Declare content with a rules tag; whoever has edit access sees friendly fields (text, rich text, images, lists), not markup:

HTML
<script src="https://clayjs.com/v1/clay.js?plugins=cms"></script>
<script data-rules-name="cms" data-rules-version="1" type="application/json">
{
  "title":    ".page-title",
  "intro":    "p.intro",
  "avatar":   "img.avatar@src",
  "tags":     "ul.tags li[]",
  "products": [".product", { "name": ".name", "price": ".price" }]
}
</script>

Each entry is a field: a name, then the selector it edits. @src becomes an image-upload control, li[] a reorderable list, and the products shape a list of cards. Open the sidebar with ?cms=true on the URL or clay.cms.open(). The same rules tag can carry more tokens (data-rules-name="api cms") to also power the data library’s JSON API.

quickcrop#

A crop modal for image uploads. It loads with cms, or you can ask for it by name. quickcrop

const shot = await clay.quickcrop(file, { aspect: 16/9 }) resolves {blob, dataURL, width, height} or null if cancelled. The CMS uses it for any image field marked data-hcms-crop="1:1" (aspects: 1:1, 16:9, free on the attribute; a plain number in code).

richclay#

The editable attribute: floating toolbar, clean HTML output. On by default, so it needs no plugin list. richclay

Tokens: single-line, no-toolbar, toolbar-on-select. In the saved file the attribute is an inert marker; no editor chrome is written to disk.

bind-text#

Keeps long text in a record’s text element and the editor outside the saved records, by binding a textarea in a clay="freeze" region to one record. Ask for it by name. bind-text

unsent#

Text typed into chrome and not yet sent outlives the view that holds it. Ask for it by name. unsent

An input or textarea inside clay="editor-ui" or clay="freeze" marked unsent="<name>" is kept per record (its nearest [data-id] ancestor) and name in this tab, outside the file: reopening the detail, a sync redraw or a reload brings the text back, and the close warning stays on while any of it is kept. Nothing enters the file. Sending or cancelling calls clay.unsent.clear(input). Events: clay:unsent-restored, bubbling from the control with detail: {base, current, stale} (stale says the record’s saved value changed since the person started typing), and clay:unsent-changed on document with detail: {count}.

An Edit switch and a small menu in the top right, shown only to someone who can edit, so they can get back into editing without the keyboard. Ask for it by name. menu

undo#

Document-wide undo/redo built on DOM mutations: moves, deletions, and attribute changes all reverse cleanly. Ask for it by name. undo

It respects clay="no-undo" regions.

upload#

clay.upload(file) posts a file you supply to the host and resolves with its URL. It is not a picker. Ask for it by name. upload

people#

Who is editing and who wrote what: clay.me, clay.people, clay.author. Ask for it by name. people

Names live in the document as <div clay-people hidden><data value="id">Name</data></div>, and authored elements carry data-by="id".

message#

A visitor’s contact form to the document’s owner. Ask for it by name. message

A form with message-form="<confirmation text>", or with action="/_/message", is POSTed as JSON to /_/message with the Document-URL header, carrying a one-time token from GET /_/form-token (the host’s bot check). clay.message.send(data) resolves { ok, error } and never rejects. Events on the form: clay:message-sent, clay:message-failed (cancel to skip the toast).

wire#

A per-file control channel between the page and a process running in the user’s terminal (see htmlclay wire serve <file> -- <cmd>). Load it with ?plugins=wire,sync. wire

The page sends a request, the process answers with progress, and the process edits the file; HTML never travels over the wire. The change reaches the open page as an ordinary external file change, through live sync.

JavaScript
const run = clay.wire.send({ ask: "tighten the intro" });
clay.wire.on((state, frame) => console.log(state.state, frame && frame.text));
const outcome = await run.done;

send returns its handle immediately; handle.done resolves with the final snapshot and never rejects. on takes a function, not an event name, and returns its own unsubscribe. A listener’s second argument is the inbound frame when one caused the update, which today means wire/status and nothing else: state.text is sticky across later states, so the frame is the only way to tell a new status line from the same line arriving again.

Also cancel, get, list, isBusy. Works in view mode too.

live#

clay.live(name) gives you a JSON tag’s data as an object you can change like any object. Load it with ?plugins=sync,live. live

Mark the tag with clay="live"; every change becomes a small operation, and add makes counters that never lose a point to a player scoring at the same moment.

page#

Opens one article[data-page] with one merge="page" clay="live" Yjs store in the page editor. Ask for it by name. page

It loads with edit mode (a live entry imports it while the tab still shows view) and requires a host that advertises the page extension; without that extension, the saved article stays readable. clay.page.flush() waits for its edits to reach a checkpoint. It brings live, sync and upload with it: image and file blocks store their files through clay.upload, and on a host that stores no files the editor offers to embed them instead.

visitor-drafts#

A free document: a visitor who is not the owner gets the same editing UI the owner gets, and their work is kept as the page’s own JSON in their own browser. Ask for it by name. visitor-drafts

Never a save, a sync or an upload to the host. On every load that JSON is applied over the file you published, so a fix you make still reaches them. The plugin also draws the download prompt, the optional intro area and the owner’s chip.

ai-edit#

Select text and ask an agent to rewrite it. Opt in with ?plugins=ai-edit, which brings wire with it. ai-edit

Select words in any block (a paragraph, heading, list item, table cell…) and press ⌘J (Ctrl+J elsewhere), or click the small AI chip at the end of the selection. The panel opens under the selection with the words highlighted. The request carries the block’s HTML, the selected text, and the text’s character offsets inside the block; a selection across blocks sends the nearest container, and one across top-level blocks sends the whole page. ⌘J with nothing to open is left to the browser, and so is ⌘J in a page’s own input, textarea or select. Pages built from [data-edit-id] sections also get the hover chip, a click on bare section padding, and the AI bubble for the whole page (just left of the CMS’s Edit content toggle when there is one).

The box composes and nothing else. Any click on the page away from it closes it, a click inside the block being edited included, and the click still lands where it was aimed. Text typed and not sent is kept as that target’s draft and comes back when the box is opened on the same target again, so a click away costs nothing. A sent comment is that target’s draft too until its reply is ready, so Error and Stop reopen the box with it. The box’s own Stop, Keep and Revert are gone: once a request is sent the whole live state moves to a bar fixed at the bottom centre of the viewport, and the ring stays on the block being edited while the bar is up.

The bar shows one state at a time. Working shows the host’s own progress line and Stop, which cancels the request and leaves the page exactly as it was. Ready shows Edit ready. with any warning the reply carried and the model that wrote it, and offers Keep and Revert. Error shows the host’s message and an X that closes the bar, with the edit already rewound; a cancellation on the host’s own side, the AI Editing switch going off or HTML Clay quitting, reports HTML Clay stopped this edit. there. After Keep the bar reports Saving… and then the host’s answer, and closes itself about a second and a half later. Keep saves what the live preview shows, a fix made by hand in it included, and a save that fails or conflicts stays on the bar with its message and its X instead of closing. Revert rewinds the edit and closes the bar. While the reply waits, every save is held, explicit ones included (clay.save(), ⌘S, [persist], the wire’s pre-send flush): Revert lets a save asked for meanwhile run, and Keep releases the hold and saves the rewrite through clay.save(). A reply that adds a script, an inline event handler, a javascript: URL, srcdoc, or an iframe, frame, object or embed the original did not have is refused before it is shown, and the bar says so.

Escape reads the bar first: in Working it stops the request, in Ready it keeps the edit, in Error it closes the bar, and in Saving it does nothing. With no bar and the box open it closes the box and keeps the draft. It stands down when the page has a claim on it too: the event was already handled, a [data-clay-modal], dialog[open] or [aria-modal="true"] element is open, or the caret is in a field the page owns. On a page with [data-edit-id] sections a second click on the whole page bubble closes an empty box and keeps a typed one open. A leading @token picks the engine, @file.ext adds context, and @page saves the page first so the helper reads it from disk.

A page can open the composer itself, prefilled with a suggested request:

JavaScript
await clay.aiEdit.open(element, { prompt: "Add a bar chart above the table. Keep the table." });

open opens the existing composer and returns true when opened, false when unavailable, busy or the target cannot be edited. It does not send automatically. The prompt seeds an empty per-target draft; previously typed text is preserved. The target must be a connected content element in this document. Controls and transient UI are not valid targets. A listed but disabled helper opens the existing setup explanation with Send unavailable, just like the keyboard shortcut.

Edit mode only, and dormant unless clay.wire.helpers() lists an ai-edit helper. Listed as unavailable (the host’s switch is off), ⌘J opens the panel with a note and no Send. HTML Clay and Hyperclay Local both answer it.

source#

Saves the file rather than a fresh printout of the page. On by default; ?exclude=source turns it off. source

Without it, every save rebuilds the document from the DOM, which reorders attributes, renormalises quoting and reprints every tag: a save that changes nothing rewrites about 88% of a hand-written file’s lines. With it, clay keeps the bytes the file was loaded from, pairs each node to its place in them, and reprints only what changed. Edit mode only.

JavaScript
clay.source.stats();   // { installed, paired, saves, reprints, partialReprints, lastReprint, ... }
document.addEventListener("clay:save-reprinted", (e) => console.warn(e.detail.scope, e.detail.reason));

clay.source.text();      // the bytes clay believes are on disk right now
clay.source.locate(el);  // { from, to, line, column } for a live element, or null

text() and locate() are the pair an editing tool needs: read the file as it will be written, point at an element in the live page, and express an edit as a range in those bytes rather than as a DOM mutation. No ids written into the file, and no map that has to survive a reload. Both answer for the file as of the last accepted save or the last live-sync frame from disk, whichever came later: a refresh still waiting runs first.

The offsets and column are UTF-16 code units into text(), not bytes. Slice the string text() returns and the answer is exact. Hand the same numbers to something that counts bytes, such as a file seek or most Python file APIs, and it writes to the wrong place, silently, and only on documents with non-ASCII content: one line of café 🎉 naïve puts the same position at 51 code units and 55 bytes.

locate() answers null rather than guessing, and the reasons differ enough to matter: the element was created after load, so it is not in the file yet; or it is an implied <html>, <head> or <body> the author never wrote and that holds nothing; or the plugin never installed, which is the whole document rather than that one element. stats().installed and stats().refused tell the last case from the first two. An implied tag that does hold content locates to the bytes it occupies, which are its children’s, because there are no tag bytes to include.

Every save is reparsed and compared against the page before it is sent. Where it differs, the element it differs in is printed the way the browser would print it and the rest is still copied, widening to the parent while the check still fails. Only a failure that reaches <html>, or has no element to point at, sends the ordinary full serialization, so the worst case is the behaviour without the plugin. clay:save-reprinted fires for an accepted save that needed either, with detail.scope 'partial' or 'full' (and detail.printed, the element count, on a partial one), and stats() counts them as partialReprints and reprints. A render the host never accepted counts nothing. It costs an HTML parser (about 48 KB compressed) and one request for the file’s own bytes at load, both in edit mode only; exclude=source is how a page declines to pay that. It does nothing, silently and safely, when that request fails, redirects, is not text/html, or returns something whose doctype and document-level comments are not this document’s. It also declines two document shapes it cannot describe: one where the parser moves content out of a <table> it was written inside, so the source order and the tree order disagree, and one with no content inside <html> at all. clay.source.stats().refused says which, and saves on such a page are the ordinary full serialization.

sap#

Reactive templates with the DOM itself as the state: attributes and elements are the source of truth, and templates re-render when the DOM they depend on changes. It is a separate library, not a plugin of clay.js. sap

Reactivity without a virtual DOM or a store, made for malleable files, where the page already is the database.

data#

Read the page’s content in and out as JSON. Load it with ?plugins=data, or as the clay-data.js satellite. data

clay.extractData(rules?) reads structured JSON from the live page, and clay.applyData(values, {rules}) applies structured JSON to the live page atomically. Core adds clay.readData() and clay.writeData() for hosts that announce the matching saved-file capabilities.

sortable#

Drag-drop reordering; fires clay:sorted. sortable

upgrade#

Notices that the template an app was copied from has a new version, and pulls the new design in while pouring the existing data into it. Ask for it by name. upgrade

A copied app stays upgradable while it carries the two meta tags, hyper-source and hyper-version, and the whole mechanism is client-side: download the new file, move your labeled data in, save.