Skip to main content

Admin-Console Chrome (juneau-console)

Three new opt-in modules add a themeable, security-hardened admin-console "chrome" (CSS theme tokens, a status/domain pill component, and FreeMarker-macro composition) on top of juneau-rest-server, without bleeding a template engine or the DataTables toolkit into the core:

ModuleAddsDepends on
juneau-consoleConsoleChromeMixin (dynamic, per-mount-themed chrome.css endpoint), Theme (--jc-* token allowlist grammar), the chrome.css / juneau-console.js shell assets, the page-contract schema, and PageContractAssert (test helper)juneau-rest-server, juneau-bean-html5
juneau-console-freemarkerConsoleFreemarkerMixin (reserved-classpath base.ftlh + the <@tag> macro, and the <@console> / <@page> / <@navigation> / <@card> directives)juneau-rest-server-view-freemarker (provided), juneau-console
<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-console</artifactId>
<version>${juneau.version}</version>
</dependency>
<!-- FreeMarker composition (base.ftlh, <@tag>): -->
<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-console-freemarker</artifactId>
<version>${juneau.version}</version>
</dependency>

A chrome-only consumer (no FreeMarker, or FreeMarker without DataTables) pulls in exactly one or two of these — never all three, and never the unrelated -datatables module unless it explicitly asks for the macro.

Why a security-hardened theme, not a raw CSS override?​

Letting a consumer supply arbitrary CSS custom-property values (colors, fonts, a page background) is an XSS/CSS-injection surface: a naive var(--jc-page-bg: url(javascript:...))- style value sunk into an unescaped stylesheet can exfiltrate data or inject markup. Theme closes that gap with a normalize-then-accept-known-safe pipeline, not a denylist:

  1. Reject the raw value if it contains any C0/C1/DEL control character (checked before trimming, so a value like "\u0000 url(evil)" can't hide a control character at the boundary).
  2. Trim, then strip CSS /* ... */ comments.
  3. Case-insensitively reject any url( production outright — style values in this module never carry a URL token, structurally.
  4. Match what remains against an accept-known-safe grammar: hex/functional colors, length units, font-family lists, and a small set of gradient functions (linear-gradient, radial-gradient, etc.) with a grammar tight enough that a gradient argument list cannot itself smuggle a nested function call outside the allowed shape.

Colors, fonts, and lengths sink only into non-URL-capable CSS properties (color, background-color, border-color, font-family, sizes/spacing, etc.). The only URL-capable sink for a var(--jc-*) token is background-image, and it's restricted to two specific tokens: --jc-page-bg and --jc-avatar-bg. Since step 3 above structurally rejects any url() production before a value is ever accepted, neither of those two tokens can be used to inject a URL anyway — v1 has no url() token form at all. The console logo is a static constant baked into the shipped chrome.css, never a consumer-suppliable token.

ConsoleChromeMixin — the dynamic chrome.css endpoint​

ConsoleChromeMixin serves the structural chrome stylesheet with Theme.OPEN's tokens appended, plus the five stock theme stylesheets (juneau-theme-<name>.css).

@Rest(mixins=ConsoleChromeMixin.class)
public class ConsoleAssetsRest extends BasicRestServlet {
@Bean
public ConsoleChromeMixin consoleChrome() {
return ConsoleChromeMixin.create().build(); // Theme.OPEN
// or: ConsoleChromeMixin.create().theme("gray").build();
}
}

Stock themes​

open (default), light-red, light-brown, red and gray. Select one per page in the FreeMarker chrome with <@theme name="gray"/>, which links that theme's stylesheet. Use the builder's theme("gray") only to bake a palette into the served chrome.css itself.

The petstore themes page in the gray theme

The petstore themes page shows every stock theme side by side.

Custom palettes​

Custom palettes are authored in FTL, not Java. Nest <@token> overrides in <@theme>, and <@console> emits the stock-theme <link> followed by an inline <style>html:root{...}</style> block:

<@console>
<@theme name="light-red">
<#-- A leaf: a literal value. -->
<@token name="--jc-pill-red-bg" value="#fdeceb"/>
<#-- An alias: a live reference that follows its target through the cascade. -->
<@token name="--jc-tab-bar-bg" alias="var(--jc-card-bg)"/>
</@theme>
<@main/>
</@console>

Every value goes through the same allowlist grammar and escaper as the stock palettes. A var() in value= is resolved to a literal at render time; an alias= stays a reference. A new name in the reserved --jc-chrome-* namespace is rejected; only the two such tokens that Theme.OPEN already defines (--jc-chrome-bg, --jc-chrome-icon) can be overridden.

Dark scheme​

Every stock theme has a dark counterpart. The console picks the scheme before first paint and sets data-jc-scheme (light or dark) on the root element. The choice comes from, in order:

  1. the visitor's stored choice, only on pages that opt in with a toggle;
  2. the page's pin (light or dark), when it has one;
  3. the operating system's prefers-color-scheme.

auto is the default pin, which means "follow the operating system".

The petstore store page in the dark scheme, chosen with the header&#39;s Auto / Light / Dark toggle

Pinning and the toggle​

In FreeMarker, set both on the console directive. scheme accepts auto, light or dark. schemeToggle is a strict boolean that adds the header's Auto / Light / Dark control and lets the visitor's stored choice win over the pin:

<@console brand="Admin" scheme="auto" schemeToggle=true>
<@main/>
</@console>

Programmatic pages use PageSpec.scheme("dark") and PageSpec.schemeToggle(true).

App dark tokens​

Add scheme="dark" to a <@token> to set it for the dark scheme only. Dark declarations render in their own rule after the light block. A dark value= must be a literal, because a var() would resolve against the light palette. A dark alias= may point at a stock theme token, at another dark token name, or at one of the console role aliases such as --jc-surface.

<@theme name="light-brown">
<@token name="--jc-pill-red-bg" value="#fdeceb"/>
<@token name="--jc-link" value="#d9a65c" scheme="dark"/>
</@theme>

App dark CSS​

Write your own dark rules with the selector the console itself uses, so they win over the light rules at the same specificity:

html:root[data-jc-scheme="dark"] .my-panel {
background: var(--jc-surface);
}

Content-Security-Policy​

The pre-paint script is inline and its text is a constant. Allow it with its hash instead of a nonce by adding ConsoleChromeMixin.SCHEME_SCRIPT_CSP_HASH to script-src. The hash covers only that script. When the request carries a CSP nonce, the script is stamped with it as well, so a script-src that allows the nonce works without the hash. The inline <style> blocks (theme and token overrides) still need the request nonce, or 'unsafe-inline', in style-src.

Pages that do not use the FreeMarker chrome​

ConsoleChromeMixin.schemeHead(req, pin, toggle) returns a SchemeHead with two pieces. Put htmlAttributes() on the <html> start tag and head() first in <head>: it holds the pre-paint script and the dark stylesheet link. The page still emits its own chrome.css and palette links.

SchemeHead h = ConsoleChromeMixin.schemeHead(req, "auto", true);
// <html lang="en" {h.htmlAttributes()}><head>{h.head()} ...

Client API​

JuneauConsole.schemeControl(doc) builds the Auto / Light / Dark radio group for a page that wants the control somewhere other than the header, and keeps it in step with the page. The page must still set schemeToggle=true (the data-jc-scheme-toggle attribute on the root element), because the stored choice is honoured only on pages that opt in; without it the control cannot hold a choice. Whenever the effective scheme changes (the operating system, another tab, or any control), the document receives a juneau:theme-changed event whose detail.scheme is light or dark:

document.addEventListener('juneau:theme-changed', e => redrawChart(e.detail.scheme));

Enum pills — the <@tag> macro​

Status/domain pills are rendered by the <@tag> macro (defined in the reserved base.ftlh, see below). It takes a domain and a value and emits <span class="tag <domain> <value>">, which chrome.css styles as a pill:

<@tag domain="status" value=release.status/>
<#-- <span class="tag status released">...</span> -->

value may be a string or an enum constant (its name() is used). Both arguments are lowercased and validated against ^[a-z0-9_-]+$; anything else (including HTML-significant characters) fails the render with a TemplateModelException rather than emitting unsafe markup.

FreeMarker composition — <@tag>​

ConsoleFreemarkerMixin extends FreemarkerMixin: register it exactly where you'd register a plain FreemarkerMixin, typed as FreemarkerMixin (the return type is load-bearing — FreemarkerViewRenderer looks it up by that exact type):

import org.apache.juneau.rest.server.view.freemarker.*;
import org.apache.juneau.console.freemarker.*;

@Rest(mixins=FreemarkerMixin.class)
public class AdminResource extends BasicRestServlet {

@Bean
public FreemarkerMixin freemarker() {
return ConsoleFreemarkerMixin.create().basePath("/templates/").build();
}
}

ConsoleFreemarkerMixin augments the resolved Configuration's TemplateLoader with a second, classpath-root-rooted ClassTemplateLoader — your own basePath-relative templates are tried first, so nothing under your /templates/ tree is ever shadowed. Pull in the reserved chrome template with a loader-root-absolute include (a bare relative path resolves against the including template's own directory, not the classpath root, and would miss):

<#include "/org/apache/juneau/console/base.ftlh">
...
<@tag domain="status" value=release.status/>

For tables, use <@card type="datatables"> (see Building a Console Page). Server-side processing uses the data-juneau-datatable-ajax attribute described in DataTables.

Consumer-supplied Configuration is honored, not augmented. If your BeanStore already has a freemarker.template.Configuration bean (Spring Boot autoconfig, or a microservice BasicBeanStore.put), resolveConfiguration returns that exact instance untouched — the console loader (and, transitively, the <@tag> macro) is not spliced in. Add the console template location to your own Configuration's loader if you need it in that setup.

DataTablesTable now honors @Html(render=...) (behavior change, not purely additive)​

DataTablesTable.of(MarshallingContext, String, Collection<?>, Class<?>) — the bean-row-type overload — now resolves and applies each property's @Html(render=...) the same way the ordinary HtmlSerializer/HtmlDocSerializer path already does. This is not a purely additive change: a bean already using @Html(render=...) for the existing serializer path now also renders through that same render when passed through this DataTablesTable overload, where previously it rendered the raw value. Map-based rows (no BeanPropertyMeta to resolve a render from) are unaffected and continue to render raw values.

FreeMarker directive reference​

ConsoleFreemarkerMixin registers these directives as shared variables on the Configuration, each only if you have not already defined a variable of that name. Every directive rejects an unknown attribute, and rejects a format= attribute, with a TemplateModelException that names the directive. Attribute values that are lists (init, css, toolkit) accept either a comma-delimited string or a sequence.

Nesting: <@page> is the outermost directive. Its body holds the page content and <@card> elements; it then includes the chrome template (default base.ftlh, settable with the builder's chromeTemplate(String)), and that template must contain the <@console> element. Everything else that describes the chrome nests inside <@console>.

DirectiveAttributesWhere it goes
<@page>tab, init, css, toolkitOutermost; needs a body; cannot be nested or repeated.
<@console>theme, icon, favicon, brand, title, chrome, scheme, schemeToggleInside the chrome template, once; cannot be nested. Writes the whole HTML document.
<@navigation>layoutInside <@console>; not nestable.
<@node>id, label, href, visible, selectedInside <@navigation> or another <@node>.
<@main/>noneInside <@console>; self-closing, marks where the page body goes.
<@badge>id, src, refreshMs, href, placement, table, tone, labelOne, labelOther, labelMine, tooltipMax, refreshes, refreshesAllowMissing, scopeParam, scopeBy, stateFilterColumnInside <@console>; repeatable, one per badge id. See <@badge> and BadgeDef.
<@card>type, id, title, src, template, requires, classInside <@page>; not nestable.
<@theme> / <@token>name / name, value, alias, scheme<@theme> inside <@console>; <@token> inside <@theme>.
slots: <@head>, <@scripts>, <@brand>, <@actions>, <@title>, <@footer>, <@body>phase (head, scripts), text (footer)Inside <@console>.

<@card>​

  • class - space-separated CSS class names added to the card's outer wrapper element (div.jc-card), after the built-in jc-card class. Each name must match [A-Za-z_-][A-Za-z0-9_-]*; anything else fails the render. Use it to keep an existing page-scope selector working, for example <@card id="skills" class="ssc-skills"> keeps .ssc-skills .row rules and closest(".ssc-skills") lookups matching. It applies to cards that have a wrapper; the markup between cards is not wrapped and takes no class.

<@page>​

  • tab - the active navigation path as node ids joined by / (for example tab="new/input"). An unknown path fails the render and the message lists the known paths.
  • init - script URLs emitted as <script src> after the toolkit scripts, in the order given.
  • css - stylesheet URLs emitted as <link rel="stylesheet"> after the theme.
  • toolkit - toolkit packs to load. views is always registered; calendar is registered too and is never inferred from a card type, so a calendar page lists toolkit="views,calendar". An unknown pack name fails the render. Register your own with the builder's registerToolkitPack(name, cssPaths, jsPaths). The template function jcHasToolkit("views") reports whether a pack was requested.

<@console>​

  • theme - one of open (default), light-red, light-brown, red, gray; anything else fails the render listing the built-in names. A nested <@theme> takes precedence.
  • brand - header title text. title - the document <title>; defaults to brand.
  • icon - header logo URL, resolved against the context:/ URI. favicon - emitted as <link rel="icon">.
  • chrome - strict boolean (true or false only; anything else, including an empty string, fails). Default false.
  • scheme - auto (default), light or dark; the page's pin for the color scheme. schemeToggle - strict boolean, default false; adds the header's Auto / Light / Dark control. See Dark scheme.

The directive also emits the chrome.css link, the theme link, any inline token override block, <meta name="page-tab">, and, when the request carries a CSRF token, a csrf-token meta tag and the data-juneau-csrf body attributes.

  • layout - horizontal or vertical; any other value fails the render.
  • id - required; must match ^[A-Za-z][A-Za-z0-9_-]{0,63}$.
  • label - required. href - optional for a node with children, required for a leaf.
  • visible - strict boolean, default true. When false the node and its children are omitted, which is how you gate entries on a permission check.
  • selected - strict boolean, default false; selects that node's id path as the active navigation.
<@navigation layout="vertical">
<@node id="setup" label="Setup" href="/rest/setup"/>
<@node id="admin" label="Admin" visible=user.admin>
<@node id="users" label="Users" href="/rest/users"/>
</@node>
</@navigation>

Slot directives​

Each slot captures its body as markup. <@head> adds to <head>; <@scripts> adds before </body>; <@body> supplies extra attributes for the <body> tag; <@brand> and <@actions> fill header slots; <@title> replaces the header title; <@footer> fills the footer.

  • <@head phase="before-page-css"> places the markup before the page's css links.
  • <@scripts phase="after-toolkit"> places the markup immediately after the toolkit scripts. Any other phase value fails the render.
  • <@footer text="..."> sets plain text; a <@footer> with a body supplies markup. Giving both fails the render.

<@badge> and BadgeDef​

A badge is a count pill in the console header (or in a table's ribbon). juneau-badges.js polls a same-origin JSON endpoint that returns {total, mine?, items?[]}, shows a label such as "3 changes pending", hides itself at zero, and lists up to tooltipMax items in a hover popover. BadgeDef is the Java form, added with PageSpec.badge(BadgeDef); <@badge> is its FreeMarker twin and builds the same BadgeDef. Both end up in the page contract's header.badges[] array.

<@console theme="open" brand="Petstore">
<@navigation layout="horizontal"> ... </@navigation>
<@badge id="pending" src="/console/pets/changes/pending" href="/console/pets/changes"
refreshMs=60000 placement="header" refreshesAllowMissing="pets"/>
</@console>

The same badge in Java:

BadgeDef badge = BadgeDef.create("pending")
.src("/console/pets/changes/pending")
.href("/console/pets/changes")
.refreshMs(60_000)
.placement(BadgeDef.Placement.HEADER)
.refreshesAllowMissing("pets");
badge.validate();
pageSpec.badge(badge);

Attributes (the BadgeDef method is the same name unless noted):

  • id - required; unique across every PageSpec and <@badge> badge on the page. A duplicate fails the render.
  • src - required, a same-origin path (SafePathTemplate.isSameOriginPath). href - optional click target, also same-origin; the script refuses an unsafe src or href again at run time.
  • refreshMs - poll interval in milliseconds. The script defaults to 60000; a value below 5000 (BadgeDef.MIN_REFRESH_MS) is rejected by validate(), not clamped.
  • placement - header (default) or toolbar. toolbar renders at the right end of the ribbon of the card named by table, so it requires table.
  • tone - info, warning (default) or danger.
  • labelOne, labelOther, labelMine (BadgeDef.label(Map) with keys one, other, mine) - templates using {total} and {mine}. Stock defaults: {total} change pending, {total} changes pending and ({mine} yours).
  • tooltipMax - items listed in the popover, 0 to 50 (the script clamps); default 10.
  • scopeParam and scopeBy (BadgeDef.scope(param, by, values)) - the query parameter sent with each poll and how its value is keyed: nav (the leaf of the active navigation) or view (the active tab of the card named by table, so scopeBy="view" requires table). In the directive both must be given together; the Java form defaults a missing by to nav. The per-key value lists come from the body (below). A scoped badge whose key has no values on the current page hides itself and keeps polling.
  • stateFilterColumn (stateFilter(column)) - the destination table's filter column. With a scope, the click URL gets a state= parameter built by JuneauViews.urlState.encode(...).
  • refreshes and refreshesAllowMissing - card ids to reload when an item id seen on one poll is missing from the next. Every table and refreshes id must name a card on the page, or the render fails at </@console>; ids in refreshesAllowMissing are exempt, for a header badge that refreshes a card on another page (the client skips a card that is not mounted).

The directive's optional body is JSON5 with three keys, because those fields have no flat shape: visibleWhen (a list of {field, op, value} rules, all of which must match; op is eq, ne, present, absent, in or contains), scopeValues (a map from nav node id, or view tab id, to a list of values) and params (extra query parameters). Any other key fails the render. For scopeBy="nav", each scopeValues key must be a node id in the navigation tree.

<@badge id="health" src="/rest/health" refreshMs=30000 tone="warning">
{"visibleWhen": [{"field": "status", "op": "eq", "value": "degraded"}]}
</@badge>

A badge is presentation only. The src endpoint must still authorize the caller, and a page should not declare a badge for users who cannot read it. The script sends Accept: application/json (Juneau negotiates content, so a plain GET would be answered with HTML), treats a 401 or 403 as "remove the badge and stop polling", and retries any other failure with exponential backoff. validate() throws IllegalArgumentException for a missing src, an unsafe src or href, an out-of-range refreshMs, placement="toolbar" without table, and scopeBy="view" without table; the directive reports the same messages as render failures.

Source: BadgeDef in juneau-console-views; BadgeDirectiveModel, PageSpec.badge and PageCapture in juneau-console-freemarker; juneau-badges.js in juneau-console. For a worked example with its endpoint, see Petstore: Pending Changes.

Cards, themes, and tokens​

<@card type> is html (the default), datatables, terminal-view, terminal, chart or job-log (see Terminal View Region and Terminal Region); js, json and calendar were removed in 10.0.0 and fail with a message naming the replacement. An html card takes exactly one of a body, template= or src=; a datatables card requires id and takes a table URL or JSON catalog as its body. <@token> requires exactly one of value or alias; scheme="dark" limits it to the dark scheme.

Theme token catalog​

These are the tokens defined by Theme.OPEN; the four other stock themes override a subset (page background, accent, link, text and border colors, chrome and button colors, avatar gradient) and inherit the rest. Token names match ^--jc-[a-z0-9-]+$, and theme names ^[a-z][a-z0-9-]*$.

GroupTokens
Typography and shape--jc-font, --jc-radius, --jc-card-padding, --jc-card-shadow
Page surfaces--jc-page-bg, --jc-main-bg, --jc-card-bg, --jc-chrome-bg, --jc-white, --jc-table-stripe-bg
Text and borders--jc-text, --jc-text-soft, --jc-text-muted, --jc-border, --jc-border-2, --jc-link
Accent and focus--jc-accent, --jc-accent-wash, --jc-accent-selected, --jc-focus
Buttons and status--jc-btn-primary, --jc-btn-primary-hover, --jc-danger, --jc-danger-wash, --jc-success, --jc-success-wash
Header and icons--jc-header-icon-color, --jc-header-height, --jc-chrome-icon, --jc-avatar-bg
Navigation--jc-nav-indicator-width, --jc-page-nav-hairline, --jc-page-nav-section-font-size, --jc-page-nav-child-font-size
Spacing--jc-space-1 through --jc-space-6
Pills (<@tag> colors)--jc-pill-{green,blue,amber,neutral,red}-{bg,text,border}; --jc-tag-*-{bg,text,border} are aliases of the matching pill tokens
Probe status--jc-probe-{ok,fail,warn,neutral}-{bg,text,dot}

--jc-page-bg and --jc-avatar-bg hold gradients; every other color token takes a plain color.

The page contract and the console shell​

Every <@console> page carries one JSON document, the page contract, in <script type="application/json" id="juneau-page">, and loads one script, /juneau-console/juneau-console.js. The server still renders <head> (CSS, theme, CSP-sensitive links). Everything else is drawn by the shell in the browser: the header, the nav (any depth), the cards and the footer. The contract's schema ships as org/apache/juneau/console/juneau-page.schema.json in juneau-console.

<script type="application/json" id="juneau-page">
{"contractVersion":"1","title":"Releases","theme":{"name":"open"},
"header":{"title":"Release Manager","chrome":true},
"nav":[{"id":"setup","label":"Setup","href":"/rest/setup"},
{"id":"releases","label":"Releases","href":"/rest/releases"}],
"activeNav":["releases"],
"cards":[{"id":"releases","type":"datatables","table":{"dataUrl":"/rest/releases/data"}}],
"footer":{"text":"Apache Juneau Release Manager"}}
</script>

Authoring​

The FreeMarker directives write the contract; you never write JSON by hand.

<@console brand="Release Manager" chrome="true">
<@navigation>
<@node id="setup" label="Setup" href="/rest/setup"/>
<@node id="new" label="New Release" href="/rest/runs">
<@node id="input" label="Input" href="/rest/runs?tab=input"/>
<@node id="exec" label="Execution" href="/rest/runs?tab=exec"/>
</@node>
</@navigation>
<@main/>
<@footer text="Apache Juneau Release Manager"/>
</@console>
  • <@page tab="new/input"> sets activeNav. The shell stamps aria-current="page" on every node of that path and draws one child row per selected ancestor. With no tab=, the shell falls back to the longest node href that prefixes the current URL, on path-segment boundaries (/rest/runs matches /rest/runs/7 but not /rest/runsx). Query strings count only when the node's href has one.
  • Markup in a <@page> body becomes <template data-card> elements, which are inert until the shell mounts them. Inline <script>s in them run at mount, in document order.
  • <@card type="html"> takes exactly one of a body, template= or a same-origin src=. <@card type="datatables" id="…"> carries its table catalog in the contract. The shell hands it to JuneauViews.regions.mount on DOMContentLoaded, so views JS may load after the shell.
  • An html card's src= fragment is inserted as served, so the endpoint must be first-party and must escape any data it embeds. Add sanitize=true ("sanitize": true in the page contract, CardSpec.sanitize(true) in Java) to rebuild the fragment through the views attribute-allowlist sanitizer instead: script, on* handlers, javascript: and data: URLs and every attribute off the allowlist are dropped, and benign markup (text, tables, links, images) survives. sanitize= needs src= and the views toolkit on the page; without the toolkit the card paints nothing and reports E-JS-8. The default stays off. Turn it on when the fragment's content is not fully under your control (it embeds user-supplied or third-party markup, or comes from an endpoint you do not own); leave it off for a fragment that needs class, id, style, forms, SVG or iframes, which the allowlist drops.
  • Plain-text footers use <@footer text="…"/>. A <@footer> body is a markup slot.
  • jcHasToolkit("views") reports whether the page asked for the views toolkit.
  • A <@card type="datatables"> loads jQuery, DataTables and the Juneau glue (juneau-datatables.js) before the views runtime, client or server mode. The host composes DataTablesMixin (the glue) and WebJarsMixin (the libraries). Other cards can require more packs. See Card Asset Dependencies.

JavaScript API​

window.JuneauConsole exposes contract(), card(id) (both deep-frozen), registerCard(type, handler) for custom card types, mount(contract, {root}), chrome (user menu and detail-bar hydration), and schemeControl(doc) (the Auto / Light / Dark color-scheme control; see Dark scheme). The shell dispatches juneau:console-mounted on document with {activeNav, activeNavSource, cards}.

Failures are loud​

A template mistake fails the render with an HTTP 500 and a message naming the directive and the fix (error codes E-1 to E-18): an unknown attribute, a non-literal boolean, a duplicate card id, an unknown tab= path (the message lists the known paths), stray markup outside <@main/>, or one of the removed card types. In the browser, a contract the shell cannot use (unsupported page contractVersion, an unknown card type, a missing template, a cross-origin src) shows a .jc-console-error banner and logs [juneau-console] … to the console (E-JS-1 to E-JS-13). It never renders a blank page silently.

Testing pages​

PageContractAssert (in org.apache.juneau.console.test, no JUnit dependency) parses the contract from a rendered page:

assertPage(body)
.isValid()
.hasActiveNav("new", "input")
.hasNavChildren("new", "input", "exec")
.hasCard("releases", "datatables")
.hasFooterText("Apache Juneau Release Manager");

ConsoleTemplateValidator lints templates without rendering them:

var findings = ConsoleTemplateValidator.create()
.templateRoot(Path.of("src/main/resources/templates"))
.chromeTemplate("base.ftlh")
.validateAll();
assertTrue(findings.isEmpty(), findings::toString);

Set ConsoleFreemarkerMixin.create().devMode(true) (or -Djuneau.console.devMode=true) to validate every contract against the schema at render time (E-14).

Limitations and out-of-scope (v1)​

  • No server-rendered chrome. Header, nav, cards and footer exist only as the page contract and are drawn by juneau-console.js. A page viewed without JavaScript shows its <head> styling and nothing else. Tests should assert on the contract (PageContractAssert), not on chrome HTML.
  • No url() token form. Every var(--jc-*) value is validated by the accept-known-safe grammar described above, which structurally excludes url() productions; a future release may add a javascript:/data:/url()-scheme allowlist alongside an actual url() token form, but v1 ships with neither.
  • The logo is a static chrome.css constant, not a configurable token.

See also​

In This Section​