Build Primo NDE customizations as plain HTML plus Alpine.js "islands", without writing Angular. Islands react to host state (search results, filters, user, config) through @libis/primo-shared-state and treat it as read-only.
The package contains the runtime and the primo-alpine command-line tool (islands, CSP lint, design tokens). It works in an unmodified Ex Libris customModule as well as in the LIBIS fork (NDE_customModule).
The major version follows the host's Angular major: 19.x.y is for Angular 19, 20.x.y for Angular 20. Breaking changes within one Angular major are released as a minor version and called out in the changelog.
| primo-alpine | Angular | Tested against (ExLibrisGroup/customModule) |
|---|---|---|
| 19.x | 19.2 | Angular-update |
| 20.x | 20.3 | November-2026-compatible (not released yet) |
| Peer dependency | Range |
|---|---|
@angular/core |
~19.2.0 (the remote tracks the host's Angular version) |
@libis/primo-shared-state |
>=2026.6.1 (the PrimoStateService facade) |
@ngx-translate/core |
>=15 |
alpinejs |
~3.17.3 |
rxjs |
^7 |
In an Ex Libris customModule on the matching Angular version:
npm install @libis/primo-alpine @libis/primo-shared-state [email protected]
npx primo-alpine init
npx primo-alpine generate search-summary --slot nde-search-results --position top
npm run start:islandsinit is safe to run again. It:
- adds a
"primoAlpine"section topackage.json(see Configuration); - adds
primo-alpine codegenbeforebuild,startandstart:proxy, plus the scriptsstart:islands,islands:watchandlint:islands; - adds
...islandMappingstoselectorComponentMapincustomComponentMappings.ts; - adds
...providePrimoAlpine({...})to the providers inapp.module.ts; - gitignores the generated
src/app/islands/islands.generated.ts.
When a file does not look like upstream's, init leaves it alone and prints the line to add.
An island is a directory under src/app/islands/ with island.json, template.html, and optionally logic.js and style.scss. npm run start:islands runs start:proxy and regenerates the components whenever an island changes.
| Command | What it does |
|---|---|
primo-alpine init [--registration map|nde-decorator] |
Set up the project (see above) |
primo-alpine generate <name> --slot <nde-slot> [--position before|after|top|bottom|replace] [--dir <subdir>] [--csp-safe] |
Scaffold an island |
primo-alpine codegen [--watch] |
Generate the island components |
primo-alpine dev -- <command> |
codegen, then keep regenerating while <command> runs |
primo-alpine lint |
Report expressions that break under a strict CSP; fails only for islands with cspSafe: true |
primo-alpine tokens import <snapshot.json> --env <name> |
Turn a token inspector export into committed files |
The same functions are available from Node as require('@libis/primo-alpine/cli'), together with PrimoAlpineWebpackPlugin for projects that prefer regenerating inside webpack.
{
"$schema": "../../../../node_modules/@libis/primo-alpine/island.schema.json",
"selector": "nde-search-results",
"position": "top",
"priority": 100,
"viewPattern": "^32KUL_",
"cspSafe": true
}selector and position give the slot key the host looks up (nde-search-results-top; replace uses the bare selector). viewPattern is a regular expression matched against the vid. When several islands claim one slot, the lowest priority wins; entries you add to selectorComponentMap after ...islandMappings override islands.
The "primoAlpine" section of package.json:
| Key | Default | Meaning |
|---|---|---|
islandsDir |
src/app/islands |
Where islands live (any depth) |
registration |
map |
map: one generated file exporting islandMappings. nde-decorator: one <name>.island.component.ts per island with @NDEComponent, for the LIBIS fork |
mapFile |
src/app/islands/islands.generated.ts |
Generated file in map mode |
decoratorImport |
src/app/decorators/nde-component.decorator |
Module exporting NDEComponent, in nde-decorator mode |
cspSafeDirs |
[] |
Top-level directories under islandsDir whose islands default to cspSafe: true (the fork uses ["central"]) |
tokensDir |
src/app/styles/tokens |
Output of tokens import |
init adds this; in other setups, provide it once in the remote's root module:
import { providePrimoAlpine } from '@libis/primo-alpine';
providers: [
...providePrimoAlpine({
dev: ['localhost', '127.0.0.1'].includes(location.hostname), // freeze host values, warn on unknown keys, ?ndeTokens=1
assetBase: () => assetBaseUrl, // where `assets/...` resolves
}),
];An island is an Angular component with an empty template that extends AlpineIsland:
@Component({ selector: 'custom-island-summary', standalone: true, template: '', encapsulation: ViewEncapsulation.None })
export class SummaryIsland extends AlpineIsland {
static override island = 'summary'; // set as data-island="summary" on the root
static override slot = 'nde-search-results'; // the host slot; enables $record where supported
static override setup = (Alpine) => { /* Alpine.data(...) */ }; // optional, runs once per page
static override html = `<p x-data x-text="$primo.search.totalResults"></p>`;
}primo-alpine codegen generates these classes from island.json and template.html; you do not write them by hand.
On ngAfterViewInit the island injects html and calls Alpine.initTree. On ngOnDestroy it calls Alpine.destroyTree and releases its subscriptions. Everything runs outside the Angular zone.
| Name | Value |
|---|---|
$primo |
Read-only view of host state (see below) |
$record |
The current Doc in record-level slots, otherwise undefined |
$t(key, params?) |
TranslateService.instant, re-evaluated on language and translation changes |
$asset(path) |
assets/... resolved against assetBase |
x-asset-src="assets/..." |
Sets src (or href on <a> and <link>) from a literal path. Not evaluated, so CSP-safe |
$hostComponent |
The raw host component instance. Never reactive; prefer the above |
$primo.<domain>.<name> reads primo.<domain>.select<Name>$() on PrimoStateService:
| Expression | Calls |
|---|---|
$primo.search.totalResults |
primo.search.selectTotalResults$() |
$primo.search.isLoading |
primo.search.selectIsLoading$() |
$primo.user.jwt |
primo.user.selectJwt$() |
$primo.filters.includedFilters |
primo.filters.selectIncludedFilters$() |
- Domains:
search,filters,user,config,entity,account. - Only zero-argument selectors are exposed. Parametrised ones such as
selectDocById$(id)are not; use$recordfor the current record. - Subscriptions are lazy: an island only subscribes to what its template reads. All subscriptions are released when the last island is destroyed.
- Values are not wrapped in Alpine proxies. Alpine tracks a per-key counter instead, so only expressions reading a key re-run when it changes.
- Assigning to or deleting from
$primothrows aTypeError. - With
dev: true, emitted values are deep-frozen (accidental mutation throws) and unknown keys log a warning listing the valid ones.
The TypeScript type PrimoView is derived from PrimoStateService, so it follows primo-shared-state regenerations automatically. IslandMagics and IslandData<T> type this in logic.js through JSDoc:
/** @param {import('@libis/primo-alpine').AlpineType} Alpine */
export default function setup(Alpine) {
Alpine.data('summary', () =>
/** @type {import('@libis/primo-alpine').IslandData<{ readonly label: string }>} */ ({
get label() {
return `${this.$primo.search.totalResults} results`;
},
}),
);
}The record's identity comes from the host component, its data from the store:
- A per-slot resolver in
RECORD_RESOLVERStakes the record fromhostComponent. This is the only code that knows host component internals. $recordthen followsprimo.search.selectDocById$(pnx.control.recordid[0]), so it updates when the host enriches the record. NDE keys its store byrecordid, not by@id.- When the store has no such record (a deep-linked full display), the host component's own copy is used.
Verified slots (NDE, September 2026): nde-record-availability (hostComponent.searchResult) and nde-record-image (hostComponent.item). On the results page records render in nde-search-result-item-container; there is no nde-brief-result element. nde-full-display has a best-guess resolver that still needs checking against the live host.
Islands render into the host page without style encapsulation. Scope rules with [data-island='<name>']: the host decides the element's tag name (for example nde-search-results-top-from-remote-0), so a tag selector is not stable. Use the host's CSS custom properties, never host component classes, whose _ngcontent attributes change every build.
In NDE, colours are --mat-sys-* and the typescale is --sys-* (--sys-body-medium, --sys-title-medium, ...).
With dev: true, add ?ndeTokens=1 to the page URL. The inspector lists every custom property defined on :root, html, body and theme classes, with colour swatches, type samples and click-to-copy var(--...). Export JSON saves a snapshot; primo-alpine tokens import turns it into a committed snapshot (diff it after a Primo upgrade), SCSS variables, a Markdown table and a CSS file for the CSS Variable Autocomplete VS Code extension.
The inspector is a separate entry point (@libis/primo-alpine/tokens), loaded only when requested. collectTokens() returns the snapshot programmatically.
- One Alpine per page. The first remote to load claims a page-wide singleton; later remotes (central package plus view package) reuse it, so magics and
Alpine.dataregistrations are shared and never configured twice. A foreignwindow.Alpinetriggers a warning. Alpine.start()is never called. It would observe mutations on the whole Primo document. Islands callinitTreeanddestroyTreethemselves. As a result, thealpine:initevent never fires; register data insetup/logic.jsinstead.- Alpine gets a working evaluator. When zone.js is used, Angular CLI (webpack and esbuild alike) downlevels
asyncfunctions in every module, including Alpine. Alpine then gets plainFunctionwhere it expectsAsyncFunction, and every expression fails withfunc.call(...).catch is not a function. At startup primo-alpine evaluates1 + 1through Alpine; only if that fails does it replace Alpine's evaluator with a port that takes the realAsyncFunctionfrom anew Functionstring, which no bundler rewrites. It needs no build configuration. One difference remains: Alpine's internaldontAutoEvaluateFunctions(used only byAlpine.extractProp) is not honoured. reflect-metadatais loaded first.@libis/primo-shared-stateis compiled with plain TypeScript decorators, and Angular reads its constructor parameters fromreflect-metadata.
This repository is an Angular workspace; the library is in projects/primo-alpine.
npm install
npm test # unit tests (Karma, headless Chrome)
npm run pack # build + dist/libis-primo-alpine-<version>.tgz
npm run e2e # the packed tgz in a fresh ExLibrisGroup/customModule (see below)@libis/primo-shared-state is not on npm yet, so the workspace uses the copy in vendor/.
- The unit tests cover the bridge (laziness, ticks, reference counting, read-only enforcement, dev freezing), the island lifecycle with several record-level instances, the async-safe evaluator, and a contract test against the real
primo-shared-stateservices, which fails when a regeneration breaks theselect<Name>$()convention. - The e2e (
scripts/e2e-vanilla.mjs) clones ExLibrisGroup/customModule ate2e.vanillaBranch(the upstream branch on this major's Angular version), installs the tgz with strict peer dependencies, runsinittwice, generates islands (including one withlogic.jsand one excluded byviewPattern), lints, builds, and checks the generated code and the bundle.--branch <name>tests another branch,--keepkeeps the checkout. - CI (
.github/workflows/ci.yml) runs both on every push, and nightly against upstream. Av<version>tag publishes to npm with provenance after both pass (needs theNPM_TOKENsecret).
When you reinstall a tgz with an unchanged version number into a project, delete its .angular/cache first: webpack caches node_modules packages by version and will otherwise keep bundling the old build.
MIT