Skip to main content

Content scripting

A Script action runs with a dari object that knows a content model—the content types defined in this workspace, or a connected Brightspot CMS's. It lets you build and edit content with named types and fields instead of hand-written JSON, and it checks what you write as you write it—a misspelled field name, a value of the wrong type, or a reference where the field expects an embedded object all fail on the line that caused them rather than silently producing content with a field missing.

Use it when a step has to assemble content that no single form can express: composing an article body from several sources, reshaping an import feed into your own types, or applying the same edit across every embedded object in an asset. For anything simpler, a Content Edit action with Simple Fields is less work.

This page covers the API. For the fields on the step itself, see the Script action reference.

Before you start​

The Script step's Brightspot Connection decides which content model dari knows. Leave it empty for the content types defined on this workspace's Content Types page, or pick a Brightspot connection for that CMS's content model. A script works against either, as long as the types and fields it names exist there.

Save what the script builds with a Content Edit action pointed at the same place: no connection for the workspace, the same Brightspot connection for a CMS.

The dari object​

Everything is on one global named dari. Take what you need from it at the top of the script:

1
const { Record, RecordRef, RichText } = dari;

The names used on this page are:

NameWhat it is
RecordA content asset—one of your types, with its fields
RecordRefA pointer to an asset that already exists
LocationA latitude and longitude
RegionAn area, as circles and polygons
StorageItemA file the CMS can already reach
RichTextA rich-text value
LocalDate, Duration, and the restDate and time values—see Dates and times
diffWorks out what changed between two versions of an asset
dari.uuid()A fresh id, for a record you are about to build
dari.type(identifier)The named type as the content model describes it—its id, its fields, and what you may name in as

Creating an asset​

Name the type, then set its fields:

1
const { Record } = dari;
2
3
const article = new Record("com.example.Article");
4
article.put("headline", "Storm closes harbor");
5
article.put("readCount", 0);

Naming a type​

The full internal name always works. When the last part of it is unique across the content model, that part alone works too. Without a connection, a short name is tried against this workspace's own types first:

1
new Record("Article"); // same type, when only one is called Article
2
new Record("com.example.Article"); // always

Matching is case-insensitive, and a full name is tried first — so a type genuinely called Article is never shadowed by a shorthand match on something else. A nested type keeps its outer class: Content$ObjectModification, not ObjectModification.

If more than one type shares the ending, the step fails and names the candidates, and you qualify:

1
"Renderer" matches more than one content type (com.psddev.cms.db.Renderer,
2
com.psddev.cms.render.Renderer). Use the full internal name.

Worth knowing: whether a shorthand is unambiguous is a fact about the content model, not about your script. A new type that shares the ending can make a working script start failing. It fails loudly and the fix is to qualify the name, but a script you want immune to that should use full names.

put returns the record, so it chains:

1
const article = new Record("com.example.Article")
2
.put("headline", "Storm closes harbor")
3
.put("featured", true);

putAll sets several at once:

1
article.putAll({
2
headline: "Storm closes harbor",
3
subheadline: "Ferries cancelled through Friday",
4
featured: true
5
});

For a connected CMS, the full internal name is the Java class name, the same name a developer on your team would use. For a type defined on the Content Types page, it is on the type's Developer tab, and its last part is the type's name in Pascal case—PressRelease for a type named Press Release. Name a type the content model does not have and the script stops there:

1
No content type named "com.example.Nope".

Setting fields​

Field names are checked against the type, and a name that does not exist lists the ones that do:

1
com.example.Article has no field named "headlien". Available: headline, subheadline, body, …

Values are converted to whatever the field holds, so ordinary JavaScript values work:

1
article.putAll({
2
headline: "Storm closes harbor", // text
3
readCount: 42, // number
4
featured: true, // true/false
5
status: "PUBLISHED", // a value from a fixed list
6
tagNames: ["storm", "harbor"], // a list or set
7
labels: { en: "Storm" } // a map
8
});

A field with a fixed set of values checks what you give it:

1
Field "status" only accepts DRAFT, PUBLISHED, but got "ARCHIVED".

Other useful calls on a record:

CallWhat it does
article.get("headline")Reads a field
article.remove("headline")Removes the field
article.put("headline", null)Clears the value, keeping the field
article.has("headline")Whether the field is set
article.keys()Every field name that is set
article.list("tags")The list at a field, creating an empty one if it is not set yet
article.entries()Every field name and value that is set, in pairs
article.idThe asset's id
article.descendants()Every record inside this one, including itself
article.toRef()A pointer to this asset, for setting on a reference field elsewhere
article.toState() / article.toStateJson(indent)Everything the asset holds, as data or as JSON text
article.putRaw(name, value)Writes a value under a stored name with no checking—the escape hatch for something the checks refuse

Embedded objects and references​

Brightspot stores an asset's related content in one of two ways, and which one a field uses is part of your content model rather than something you choose in the script.

An embedded object lives inside its parent and has no life of its own. Build one with Record and set it:

1
const { Record } = dari;
2
3
const article = new Record("com.example.Article");
4
5
article.put("caption", new Record("com.example.Caption").put("text", "The harbor at dawn"));
6
7
article.put("gallery", [
8
new Record("com.example.Caption").put("text", "One"),
9
new Record("com.example.Caption").put("text", "Two")
10
]);

A reference points at an asset stored separately, which has to already exist. Point at one with RecordRef and its id:

1
const { RecordRef } = dari;
2
3
article.put("author", new RecordRef(input.authorId));

Get it the wrong way round and the script tells you which one the field wants:

1
Field "author" holds a reference to separately stored content, so it needs a RecordRef.
2
A record built in this script has nothing to point at yet—save it first, then reference it by id.

That message names the one real constraint here: a script cannot create an asset and point another field at it in the same step, because the asset does not exist until a Save step runs. Create it in one step, save it, then reference it by id in the next.

Editing an existing asset​

Read an asset with a Content Fetch step, then load its values into the script:

1
const { Record } = dari;
2
3
const article = Record.fromState(payload.values);
4
5
article.get("headline"); // "Storm closes harbor"
6
article.get("gallery").map(s => s.get("text")); // ["One", "Two"]

To edit a revision rather than live content, set the Fetch's Revision, and bind the Save that writes the result to the same Revision, so the changes land in that revision and live is untouched.

Embedded objects come back as records you can change in place, and lists come back as ordinary arrays:

1
article.put("headline", "Storm closes harbor early");
2
3
article.get("gallery")[0].put("text", "One, revised");
4
5
article.list("gallery").push(new Record("com.example.Caption").put("text", "Three"));

Saving what you changed​

A Save step takes only what changed, so compare the asset before and after:

1
const { Record, diff } = dari;
2
3
const article = Record.fromState(payload.values);
4
article.put("headline", "Storm closes harbor early");
5
6
diff(payload.values, article.toState()).byRecord;

Bind that value to a Content Edit action Save with Values set to Differences. It covers the asset and every embedded object inside it in one piece.

You do not have to track what you touched—diff works it out by comparing, and it is precise about where a change belongs. Changing a field on an embedded object reports a change on that object and none on its parent. Adding to a list reports both the parent's list and the new object. Changing nothing reports nothing, which isEmpty tells you, so a step can skip a pointless save:

1
const changes = diff(payload.values, article.toState());
2
3
({ changed: !changes.isEmpty, differences: changes.byRecord });

What diff gives back answers a few questions besides "what changed":

CallWhat it gives you
changes.byRecordEvery change, keyed by record id—the value the Differences field wants
changes.isEmptyWhether anything changed at all
changes.recordIdsThe ids of the records that changed
changes.forRecord(id)Just that record's changed fields
changes.embeddedFor(id)Every changed record except the one named—the embedded objects, ready for a Save
changes.toJson(indent)The whole thing as JSON text, for logging while you work out why something is or is not changing

diff is the writer's tool: it builds the map a Save takes, keyed by record id, with no before values and no labels. To read what changed between two revisions—to report an editor's changes, or have an AI Agent review them—use a Content Compare step instead, which labels each change and marks text up the way Brightspot's Compare view does.

A list whose order carries no meaning—a set of tags reordered by the CMS—would otherwise report as a change every run. Name those fields and diff compares them as unordered:

1
diff(payload.values, article.toState(), { unorderedFields: ["tags", "sections"] });

For an asset you are creating, there is nothing to compare against. Ask the record for all of it:

1
const story = new Record("com.example.Story")
2
.put("headline", input.headline)
3
.put("lead", new Record("com.example.Caption").put("text", input.caption));
4
5
story.toDifferences().byRecord;

Pick the content type on the Save step itself; the script supplies everything else. The step fills in the new content's own _id and _type, so nothing in the script has to agree with them.

Fields that come from elsewhere​

Some fields on your content are not declared by the content type itself. Brightspot adds publishing dates, import provenance, SEO settings and much else through modifications—separate classes that attach their fields to a type. Those fields are stored under prefixed names you would otherwise have to know.

as addresses them by the class and the plain field name instead:

1
article.as("brightspot.importapi.ImportObjectModification")
2
.put("externalId", input.wireId)
3
.put("sourceUrl", input.wireUrl);
4
5
article.as("com.psddev.cms.db.Content$ObjectModification")
6
.put("publishDate", new Date());

You name the class and the field as they appear in the code; the prefix is worked out for you. A class that does not apply to your type lists the ones that do, and a field the class does not have lists its fields.

as supports the same calls as a record—get, put, putAll, remove and has—all reading and writing the same asset. internalName("publishDate") gives back the prefixed name the field is actually stored under, for the rare case where you need to write it yourself.

Locations and areas​

1
const { Location, Region } = dari;
2
3
venue.put("where", new Location(39.1498124, -76.848583));
4
5
venue.put("deliveryArea", Region.circle(new Location(39.0, -76.0), 5000));

Location takes a latitude and a longitude, in that order. Region.circle takes a centre and a radius in metres; Region.polygon takes a list of locations, and closes the shape for you. Add more than one with addCircle and addPolygon.

Reading an area back gives you centres and metres rather than the stored form:

1
const area = Region.fromState(venue.get("deliveryArea"));
2
3
area.circleList[0].radiusInMeters; // 5000

Files​

A script has no network access, so it cannot upload. It can point a file field at something the CMS can already reach:

1
const { StorageItem } = dari;
2
3
article.put("leadImage", StorageItem.url(payload.cdnUrl)
4
.withContentType("image/png")
5
.withMetadata({ width: 800, height: 600 }));

A plain URL works as shorthand:

1
article.put("leadImage", "https://cdn.example.com/pic.png");

To copy a file from one asset to another, read it and set it:

1
target.put("leadImage", StorageItem.fromState(source.get("leadImage")));

Dates and times​

Brightspot stores a moment in several different ways—an exact instant, a calendar date with no time, a time of day with no date, and more. A JavaScript Date works for all of them, because the field says which one it is:

1
const when = new Date("2026-08-14T01:15:30Z");
2
3
article.putAll({
4
publishedOn: when, // an exact instant
5
runsOn: when, // a calendar date
6
startsAt: when, // a time of day
7
airsAt: when // a date and time with a zone
8
});

Choose the time zone​

A calendar date depends on where you are. The instant above is 14 August in UTC and 13 August in New York, so set the zone the dates belong to before you convert any:

1
dari.timeZone = "America/New_York";

It defaults to UTC and applies to every conversion from a Date in the script. Get this wrong on a publish date and an evening's content lands on the wrong day.

Building values directly​

Where you have parts rather than a moment, or a length of time rather than a point in one, build the value:

1
const { LocalDate, LocalTime, Year, Duration, Period } = dari;
2
3
article.putAll({
4
runsOn: LocalDate.of(2026, 8, 14),
5
startsAt: LocalTime.of(9, 30),
6
copyrightYear: Year.of(2026),
7
readingTime: Duration.of({ minutes: 4, seconds: 30 }),
8
retention: Period.of({ years: 1, months: 6 })
9
});

Duration.between(start, end) measures the gap between two dates. Every one of these also accepts a string, checked against the format the field expects:

1
"14/08/2026" is not a valid LocalDate.

A length of time cannot be worked out from a single moment, and the script says so rather than guessing:

1
Field "readingTime" is a duration, which is a span of time rather than a point in one,
2
so a Date cannot say what it should be. Use Duration.of({ ... }).

Rich text​

A rich-text field holds the markup the rich-text editor produces—which is not quite HTML, and building it by hand is the main thing this API saves you from.

The editor separates paragraphs with two <br/> tags and a single line break with one—it never writes a <p> tag. paragraph and line write exactly that, so what a script produces is what the editor would have produced.

1
const { RichText } = dari;
2
3
const body = new RichText()
4
.paragraph("Winds hit 60mph overnight.")
5
.paragraph(t => t
6
.text("The ")
7
.bold("harbor")
8
.text(" is closed until ")
9
.link("https://example.com/notice", "further notice")
10
.text("."))
11
.list(["Ferries cancelled", "Roads closed"]);
12
13
article.put("body", body);

Text you pass in is escaped, so a value from an API or an AI step cannot break the markup. Use html when you have markup you want written as-is.

Text that was never meant for a rich-text field​

Prose that arrived from somewhere with no idea where it was going—an AI step's answer, an imported description—separates its paragraphs with blank lines. Assigning that string to a rich-text field stores something the editor never writes: the newlines aren't markup, so it renders as one run-on paragraph, and any < or & in it becomes markup the moment it's stored.

fromPlainText is that conversion:

1
article.put("body", RichText.fromPlainText(input.body));

Blank lines become paragraph breaks, single newlines become soft line breaks, and the text is escaped. It returns a RichText, so you can keep building on it if there's more to add.

CallProduces
paragraph(text)A block of text, separated from its neighbours by <br/><br/>
line(text)A line inside the current block, ending in a single <br/>
list(items) / orderedList(items)A bulleted or numbered list
element(tagName, options)One of your CMS's rich-text elements
elements(tagName)Every occurrence of that element in the markup, in order
rewrite(tagName, visitor)Replaces or removes every occurrence of that element
html(markup)Markup written unchanged
toPlainText() / isEmptyThe readable text, and whether there is any
fromPlainText(text)Plain prose converted, as above (static)

Inside paragraph, line and list, pass a function to mix formatting: text, bold, italic, underline, strikethrough, code, link and html.

Rich-text elements​

Anything richer than text and lists—a pull quote, an embedded image, an iframe—is a rich-text element your CMS defines. Add one by its tag name, giving it the attributes and body it expects:

1
body.element("bsp-pull-quote", {
2
attributes: { "data-align": "right", "data-attribution": "Dan" },
3
body: "Nothing like it in thirty years."
4
});

Each element decides for itself where its data goes, and that decision lives in the element's Java code, which cannot run inside a script. Some elements keep each field in its own attribute, some use the body, and some put their whole configuration in a single attribute as serialized state. So you write the attributes and body that element expects, rather than setting fields on it and hoping.

For the last kind, give the attribute a record and it is written as that record's state—which can hold a reference like any other field:

1
const { Record, RecordRef } = dari;
2
3
body.element("bsp-image", {
4
attributes: {
5
"data-state": new Record("com.example.ImageEnhancement")
6
.put("image", new RecordRef(input.imageId))
7
.put("caption", "The harbor at dawn")
8
}
9
});

The attribute name is the element's choice—data-state is a common one, not a rule.

Which elements a field allows depends on your CMS and on that field's toolbar. Ask a developer on your team which tag names apply, which attributes each expects, and whether it reads its body.

Reading and changing existing rich text​

1
const body = RichText.from(article.get("body"));
2
3
body.toPlainText(); // the readable text, markup removed
4
body.isEmpty;
5
body.elements("bsp-pull-quote"); // every pull quote in the body

rewrite visits every occurrence of one element. Return the element to keep it, or null to remove it:

1
body.rewrite("bsp-pull-quote", el => {
2
if (el.attribute("data-align") === "left") {
3
return null;
4
}
5
el.attribute("data-align", "center");
6
return el;
7
});
8
9
article.put("body", body);

An element you are given exposes exactly what is in the markup—tagName, body, and attribute(name). Where an attribute holds serialized state, record(name) reads it back as a record:

1
body.rewrite("bsp-image", el => {
2
const state = el.record("data-state");
3
state.put("caption", state.get("caption").toUpperCase());
4
return el.attribute("data-state", state);
5
});

Everything rewrite does not visit is left exactly as it was, so a script that changes one pull quote cannot disturb the rest of the body.

When something is wrong​

Every complaint from dari names the field and what it expected. They stop the step with the message in the run's log, so a mistake surfaces at the step that made it.

To handle one yourself rather than fail the step, catch it:

1
try {
2
article.put(name, value);
3
} catch (error) {
4
if (error instanceof dari.DariError) {
5
console.warn("skipping " + name + ": " + error.message);
6
} else {
7
throw error;
8
}
9
}

console.log, console.info, console.warn, console.error and console.debug write to the step's execution log, which is the quickest way to see what a value holds mid-script. Each lands at the matching level, so you can filter the log down to just your warnings or errors. console.debug entries are only recorded when Capture Debug Logs is turned on in the settings.

Returning a value​

A script has no return statement. The value of its last expression is the step's output, and writing return at the top level is a syntax error. End the script with the expression itself:

1
article.toState();

Wrap an object in parentheses, or it reads as a block rather than a value:

1
({ changed: true, differences: changes.byRecord });

Give the step plain values—what toState, toDifferences and diff produce—rather than the dari objects themselves:

1
article.toState(); // yes
2
changes.byRecord; // yes
3
article; // no

Making it pickable downstream​

A later step—a Content Edit action Save's Differences field, most often—reaches your result through the variable picker, and the picker lists what the step's Output Type says it holds. Leave it unset and there is nothing to list. Differences narrows that further: it offers only values declared as objects.

A differences map is keyed by the record ids of the run that produced it, so its keys are not something you can write down in advance. Declare it as an Object with no fields: that says a value comes back without claiming to know what is inside it, and the picker offers the whole thing under payload.

Returning the map on its own is the shortest route:

1
changes.byRecord;

Wrapping it is usually worth the extra line, because it gives you something to branch on. Declare an Object with two fields—changed as a Boolean, differences as an Object with no fields—and both are pickable by name:

1
({ changed: !changes.isEmpty, differences: changes.byRecord });

An Expression condition can then skip the save when nothing changed, and the Save step binds differences directly.

Auto-generate Output Type is the wrong tool here. It infers the shape from the last run's result, and for a differences map that means writing this run's record ids into the declaration as though they were field names. Declare the Object by hand instead.

A complete example​

Reading an article, tidying every caption in its gallery, stamping where it came from, and saving only what changed:

1
const { Record, diff } = dari;
2
3
dari.timeZone = "America/New_York";
4
5
const article = Record.fromState(payload.values);
6
7
for (const slide of article.get("gallery") || []) {
8
const text = slide.get("text");
9
if (text) {
10
slide.put("text", text.trim().replace(/\s+/g, " "));
11
}
12
}
13
14
article.as("brightspot.importapi.ImportObjectModification")
15
.put("externalId", input.wireId);
16
17
const changes = diff(payload.values, article.toState());
18
19
({ changed: !changes.isEmpty, differences: changes.byRecord });

Reusing content if it exists, creating it if it doesn't​

The common shape—use the Tag that already exists, or make one; update the Article with this slug, or import it. It runs as a straight line, with no branch in the automation:

  1. A Content Fetch looks the content up by its unique field: Lookup Field slug, Lookup Value the slug you have, and a Content Type to search within. Finding nothing is a normal result there, not a failure.
  2. This script builds from what came back, either way.
  3. A Content Edit action → Save with the same Content Type, Lookup Field and Lookup Value, Create If Missing on, and Values set to Differences bound to the script's output.
1
const { Record } = dari;
2
3
const article = payload.found
4
? Record.fromState(payload.values)
5
: new Record("com.example.Article");
6
7
article.put("slug", input.slug);
8
article.put("headline", input.headline);
9
10
article.toDifferences().byRecord;

Record.fromState keeps the id the content already has, and new Record(...) mints one. The Save's own lookup then decides: it updates the content it finds, or creates the content under the id the script minted when it finds none. Nothing upstream has to know which case it is in.

Use toDifferences() here, not diff(...). When the content is new there is no earlier version to compare against, and toDifferences() hands over the whole record either way. A run that changes nothing still succeeds: a Save with Create If Missing asks for content to exist in a given state, and it already does.

Limits​

dari runs inside the Script action's sandbox and under its ceilings—no file system, no network, and bounds on time, memory and statements. The dari object's own work counts toward them, so a script that assembles a very large content tree does more than its line count suggests.

With a Brightspot connection, reading the content model costs one call to the CMS the first time a script names a type. That result is cached, so a script naming the same types on every run pays for it once rather than every time. This workspace's own content model is read directly, with no call.

Was this page helpful?

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.