Skip to main content

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 the ConditionalRequestFilter this plugin's filters order themselves around and extends with version-aware ETags.

Installation

<!-- Requires Brightspot 5.0 or later. -->
<dependency>
<groupId>com.brightspot.immersive</groupId>
<artifactId>immersive-project</artifactId>
<version>1.0.0</version>
</dependency>

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.

1
public class ImmersiveProjectPage extends Content
2
implements
3
DefaultSiteMapItem,
4
FeedItem,
5
HasAuthorsWithField,
6
HasSectionWithField,
7
HasTagsWithField,
8
ImmersiveProjectLinkable,
9
Page,
10
PagePromotableWithOverrides,
11
SeoWithFields,
12
SiteSearchResult {
13
14
@ToolUi.Tab("SEO")
15
@ToolUi.Cluster("Sub Page Metas")
16
private List<ImmersiveSubPageMetasData> metasData;
17
18
// --- Record ---
19
20
@Override
21
public String getLabel() {
22
return Optional.ofNullable(getImmersiveProjectContent())
23
.map(ImmersiveProjectContent::getLabel)
24
.orElse(null);
25
}
26
27
// --- SeoWithFields ---
28
29
@Override
30
public String getSeoTitleFallback() {
31
return getLabel();
32
}
33
34
@Override
35
public String getSeoDescriptionFallback() {
36
return null;
37
}
38
39
// --- PagePromotableWithOverrides ---
40
41
@Override
42
public String getPagePromotableTitleFallback() {
43
return getSeoTitle();
44
}
45
46
@Override
47
public String getPagePromotableDescriptionFallback() {
48
return getSeoDescription();
49
}
50
51
@Override
52
public WebImageAsset getPagePromotableImageFallback() {
53
return null;
54
}
55
56
// --- ImmersiveProjectLinkable ---
57
58
@Override
59
public ImmersiveSubPageMetasData getSubPageMetasDataForUrl(String url) {
60
if (StringUtils.isBlank(url)) {
61
return null;
62
}
63
return Optional.ofNullable(metasData).orElseGet(Collections::emptyList).stream()
64
.filter(m -> StringUtils.isNotBlank(m.getSubPage()) && url.contains(m.getSubPage()))
65
.findFirst()
66
.orElse(null);
67
}
68
69
// --- FeedItem ---
70
71
@Override
72
public String getFeedTitle() {
73
return getSeoTitle();
74
}
75
76
@Override
77
public String getFeedDescription() {
78
return StringUtils.firstNonBlank(getSeoDescription(), getSeoTitle());
79
}
80
81
@Override
82
public String getFeedLink(Site site) {
83
return Permalink.getPermalink(site, this);
84
}
85
86
@Override
87
public String getFullContentEncoded() {
88
return 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:

1
dist/
2
index.html
3
about/index.html
4
assets/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
}
FieldApplied to
nameSets 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.
versionSets this version's internal name, replacing the default upload-timestamp label.
assetServingModeOverrides 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:

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

InterfacePurpose
HtmlTransformationTransforms the full HTML string before it is written to the response
HtmlSubstitutionReplaces an inline {{identifier}} placeholder with produced text
HtmlBlockSubstitutionReplaces a comment-delimited block (<!-- IMMERSIVE-SUB {{identifier}} --><!-- IMMERSIVE-SUB-END -->)
HtmlSubstitutionModifierPost-processes the output of an HtmlSubstitution (e.g. encode, trim)
HtmlTagUpdateRewrites a matched <meta>, <title>, or <link> tag using its parsed attributes
ImmersiveCacheInvalidationStrategyCalled 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:

ModeBehavior
CDN_REWRITERelative 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_BUSTERRelative 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:

  1. The ImmersiveProjectVersion being served, if its mode was set from the upload's manifest (see Manifest file).
  2. The ImmersiveProject's own mode, if one was set—either from the manifest of an earlier upload, or directly on the project record.
  3. The global Disable Cdn Asset Substitution setting: CACHE_BUSTER if selected, CDN_REWRITE otherwise.

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

KeyDefaultPurpose
immersive/storagevalue of dari/defaultStorageDari 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.

1
curl \
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" \
7
https://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.

Was this page helpful?

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