Technical reference
The immersive project plugin lets a Brightspot site host self-contained static microsites (immersive projects) that are built independently and uploaded as a .zip file. A CMS page implements ImmersiveProjectLinkable to act as a shell for a project: a servlet filter intercepts requests to the page's permalink, reads the matching HTML file from cloud storage, runs it through a chain of HtmlTransformation extension points, and writes the result to the response. Requests for the project's non-HTML assets are redirected to their cloud storage location.
Dependencies
com.brightspot.job:job—runs the background job that extracts an uploaded .zip and copies its contents to cloud storage.com.psddev:dari-storage—the storage abstraction used to read and write immersive project files.com.psddev.component-lib:conditional-request-headers—provides theConditionalRequestFilterthis plugin's filters order themselves around and extends with version-aware ETags.
Installation
- Maven
- Gradle
- Gradle (Kotlin DSL)
<!-- Requires Brightspot 5.0 or later. -->
<dependency>
<groupId>com.brightspot.immersive</groupId>
<artifactId>immersive-project</artifactId>
<version>1.0.0</version>
</dependency>
// Requires Brightspot 5.0 or later.
implementation 'com.brightspot.immersive:immersive-project:1.0.0'
// Requires Brightspot 5.0 or later.
implementation("com.brightspot.immersive:immersive-project:1.0.0")
Linking a page to a project
Implement ImmersiveProjectLinkable on any content type to make it hostable. The ImmersiveProjectLinkableModification adds the project-picker field automatically through the Dari modification system—no other wiring is required.
1public class ImmersiveProjectPage extends Content2implements3DefaultSiteMapItem,4FeedItem,5HasAuthorsWithField,6HasSectionWithField,7HasTagsWithField,8ImmersiveProjectLinkable,9Page,10PagePromotableWithOverrides,11SeoWithFields,12SiteSearchResult {1314@ToolUi.Tab("SEO")15@ToolUi.Cluster("Sub Page Metas")16private List<ImmersiveSubPageMetasData> metasData;1718// --- Record ---1920@Override21public String getLabel() {22return Optional.ofNullable(getImmersiveProjectContent())23.map(ImmersiveProjectContent::getLabel)24.orElse(null);25}2627// --- SeoWithFields ---2829@Override30public String getSeoTitleFallback() {31return getLabel();32}3334@Override35public String getSeoDescriptionFallback() {36return null;37}3839// --- PagePromotableWithOverrides ---4041@Override42public String getPagePromotableTitleFallback() {43return getSeoTitle();44}4546@Override47public String getPagePromotableDescriptionFallback() {48return getSeoDescription();49}5051@Override52public WebImageAsset getPagePromotableImageFallback() {53return null;54}5556// --- ImmersiveProjectLinkable ---5758@Override59public ImmersiveSubPageMetasData getSubPageMetasDataForUrl(String url) {60if (StringUtils.isBlank(url)) {61return null;62}63return Optional.ofNullable(metasData).orElseGet(Collections::emptyList).stream()64.filter(m -> StringUtils.isNotBlank(m.getSubPage()) && url.contains(m.getSubPage()))65.findFirst()66.orElse(null);67}6869// --- FeedItem ---7071@Override72public String getFeedTitle() {73return getSeoTitle();74}7576@Override77public String getFeedDescription() {78return StringUtils.firstNonBlank(getSeoDescription(), getSeoTitle());79}8081@Override82public String getFeedLink(Site site) {83return Permalink.getPermalink(site, this);84}8586@Override87public String getFullContentEncoded() {88return null;89}90}
SiteSearchResult is required for the page to appear as a selectable type in the SiteSearch configuration UI—omitting it makes the type invisible in the type picker even though it is otherwise a valid search result.
Implementing getSubPageMetasDataForUrl against a metasData field (as shown above) is what surfaces the Sub Page Metas cluster described in Publishing and displaying immersive projects; it is optional, not something ImmersiveProjectLinkable requires.
.zip format
The plugin expects a standard .zip. Most build tools wrap output in a single top-level folder (e.g. dist/); the plugin strips that wrapper so storage paths match URL structure. A .zip containing:
1dist/2index.html3about/index.html4assets/main.js
is stored and served as index.html, about/index.html, and assets/main.js. The sub-directory structure beneath the wrapper is preserved exactly as-is. If the .zip has no top-level wrapper folder, paths are used unchanged.
Files matching __MACOSX/ or starting with . are skipped.
Manifest file
Include an immersive-manifest.json file at the root of the .zip (inside the top-level wrapper if one is present) to provide metadata about the project and version. The file is consumed during upload and is not stored in cloud storage.
1{2"name": "My Immersive Project",3"version": "2.1 — Spring launch",4"assetServingMode": "cache-buster"5}
| Field | Applied to |
|---|---|
name | Sets the project's internal name when it has no name yet. Editors can override it in the CMS afterward; a subsequent upload does not overwrite a name that has already been set. |
version | Sets this version's internal name, replacing the default upload-timestamp label. |
assetServingMode | Overrides the asset-serving mode for the project this version belongs to. Valid values are cdn-rewrite and cache-buster; an absent or unrecognized value leaves the existing mode unchanged. See Asset serving mode. |
If the manifest is absent or malformed, upload proceeds normally: the version is labeled with an upload timestamp, and asset serving falls back to whatever mode is otherwise in effect.
HTML placeholders
The plugin performs string substitution on HTML files before serving them. Include these comment-style or inline placeholders in the HTML to receive platform-provided content:
| Placeholder | What it receives |
|---|---|
{{combined-head-scripts}} | Platform <script> tags from the head |
{{combined-body-top-scripts}} | Scripts injected at the top of <body> |
{{combined-body-bottom-scripts}} | Scripts injected at the bottom of <body> |
{{combined-link-elements}} | Platform <link> elements |
{{project-root-path}} | The permalink path of the hosting CMS page |
Block substitutions
For content that should show a fallback when the project is served standalone (e.g. an ad container), use the block syntax:
1<!-- IMMERSIVE-SUB {{leaderboard-ad}} -->2<div class="fallback-ad">Ad shown locally</div>3<!-- IMMERSIVE-SUB-END -->
When Brightspot serves the page and a matching HtmlBlockSubstitution produces content, the entire block—comment delimiters and fallback—is replaced with the real content. When null or blank content is returned, the comment delimiters are stripped and the fallback content between them is preserved as-is. Since the fallback is typically empty for platform-injection slots (scripts, links), this means the block simply disappears from the served HTML.
When the .zip is opened locally as a static file, no substitution runs at all—HTML comments are invisible to the browser and the fallback content renders normally.
Use inline {{placeholder}} syntax instead when the substitution point is inside an HTML attribute value or a script context where comment syntax is not valid:
1<script src="{{project-root-path}}/main.js"></script>
Extension points
All extensions are auto-discovered through ClassDisplay.findConcreteClasses()—no registration is required.
| Interface | Purpose |
|---|---|
HtmlTransformation | Transforms the full HTML string before it is written to the response |
HtmlSubstitution | Replaces an inline {{identifier}} placeholder with produced text |
HtmlBlockSubstitution | Replaces a comment-delimited block (<!-- IMMERSIVE-SUB {{identifier}} --> … <!-- IMMERSIVE-SUB-END -->) |
HtmlSubstitutionModifier | Post-processes the output of an HtmlSubstitution (e.g. encode, trim) |
HtmlTagUpdate | Rewrites a matched <meta>, <title>, or <link> tag using its parsed attributes |
ImmersiveCacheInvalidationStrategy | Called after a new version is published; use to invalidate CDN cache |
PageMetasUpdate is an abstract HtmlTagUpdate base class for the common case of rewriting a <meta> tag that contains a {{...}} placeholder using data from an ImmersiveSubPageMetasData entry.
ViewBasedHtmlSubstitution and ViewBasedHtmlBlockSubstitution are abstract base classes that render a Brightspot view model to produce the substitution or block content, for projects that want to inject page-level elements (scripts, analytics tags) built the same way as the rest of the site's views. Implement one of these in the site's layer instead of HtmlSubstitution or HtmlBlockSubstitution directly when the injected content should come from a view model rather than being assembled by hand.
The plugin's own asset-URL handling (StaticAssetCdnUrlSubstitution and StaticAssetVersionCacheBuster) is implemented as a built-in HtmlTransformation pair; see Asset serving mode.
Asset serving mode
Static asset paths (JavaScript, CSS, images) in an immersive project's HTML can be handled in one of two ways, represented by the AssetServingMode enum:
| Mode | Behavior |
|---|---|
CDN_REWRITE | Relative asset paths are rewritten to absolute cloud storage URLs. Assets load directly from the CDN, bypassing Brightspot's redirect entirely. Safe for simple static projects, but may break JavaScript frameworks that compute asset paths at run time from document.currentScript.src or import.meta.url. |
CACHE_BUSTER | Relative asset paths keep routing through Brightspot's 302 redirect, but get a version-keyed ?v=<hash> query parameter appended so each version gets a distinct CDN cache entry. Safe for all frameworks, since relative path semantics are preserved. |
The effective mode for a request is resolved in priority order:
- The
ImmersiveProjectVersionbeing served, if its mode was set from the upload's manifest (see Manifest file). - The
ImmersiveProject's own mode, if one was set—either from the manifest of an earlier upload, or directly on the project record. - The global Disable Cdn Asset Substitution setting:
CACHE_BUSTERif selected,CDN_REWRITEotherwise.
A version or project whose mode is GLOBAL_DEFAULT (the default when nothing has set it) falls through to the next step in the chain.
Configuration
All settings are read through the Dari Settings API (properties files, environment variables, or CMS tool settings).
| Key | Default | Purpose |
|---|---|---|
immersive/storage | value of dari/defaultStorage | Dari storage backend name for immersive project files. Set this to use a dedicated storage bucket separate from the platform default. |
How HTML serving works
The filter reads HTML files directly from cloud storage through StorageItem#getData(), not through an HTTP request to the CDN. This means the storage backend does not need to expose a public CDN URL for the filter to function—it only needs Dari storage credentials. CDN URLs are used exclusively for browser redirects to static assets (CSS, JS, images).
CI/CD upload API
POST to /immersive-project-upload with an API client's credential pair. The HMAC is computed over the raw .zip bytes using HMAC-SHA256.
1curl \2-F "file=@dist.zip" \3-F "hmac=$(openssl dgst -sha256 -hmac "$HMAC_KEY" -binary dist.zip | base64)" \4-F "id=my-project-identifier" \5-H "X-Client-Id: $CLIENT_ID" \6-H "X-Client-Secret: $CLIENT_SECRET" \7https://example.com/immersive-project-upload
The id parameter maps to the project's internal identifier. If an existing project with that identifier is found, it is updated; otherwise a new one is created. Omit id to always create a new project with a timestamp-based name.
The endpoint responds with a small JSON body (status and message); a 200 response with status: "SUCCESS" means the upload was accepted and queued, not that processing has finished. Poll or review the corresponding job (see Monitor upload jobs) to confirm the version was processed successfully.