Setting up the build system

Contents

In this chapter we will learn how to set up transpilation of Typescript code - both client- and server-side - to JavaScript.

Create project

This chapter rebuilds the TypeScript setup from the ground up, so you can see how every piece fits together. If you just want to use it, scaffold a ready-made project with enonic project create -r starter-ts and skip this tutorial entirely.

To follow along, we start from the most basic setup possible — a vanilla XP project, a minimal JavaScript app with no TypeScript or npm tooling. The same steps also apply when adding TypeScript to an existing Enonic project.

enonic project create myproject -r starter-vanilla

NPM modules

Types

Install types needed to configure the build system:

npm install --save-dev @types/node

Install the types for the libraries that ship with Enonic XP. These libraries are released and versioned together with XP, so the starter bundles all of them — giving you full type-checking and editor autocompletion for every /lib/xp/* import out of the box:

npm install --save-dev @enonic-types/core @enonic-types/global @enonic-types/lib-admin @enonic-types/lib-app @enonic-types/lib-auditlog @enonic-types/lib-auth @enonic-types/lib-cluster @enonic-types/lib-common @enonic-types/lib-content @enonic-types/lib-context @enonic-types/lib-event @enonic-types/lib-export @enonic-types/lib-grid @enonic-types/lib-i18n @enonic-types/lib-io @enonic-types/lib-mail @enonic-types/lib-node @enonic-types/lib-portal @enonic-types/lib-project @enonic-types/lib-repo @enonic-types/lib-schema @enonic-types/lib-scheduler @enonic-types/lib-sse @enonic-types/lib-task @enonic-types/lib-value @enonic-types/lib-vhost @enonic-types/lib-websocket
The @enonic-types/* packages are versioned in lock-step with Enonic XP. Keep them aligned with the xpVersion in gradle.properties.

Additional libraries

The packages above cover only the libraries bundled with Enonic XP. Enonic also maintains standalone libraries that are released independently of XP — for example lib-asset, lib-static, lib-router and lib-mustache. These are not included by default; add them as you need them.

To serve the compiled client-side files, add lib-asset — the assets/ folder this starter ships follows its convention. The related lib-static serves a folder of your choice (static/ by convention) instead.

As an example, here is how to add lib-static. Market library versions are declared in the Gradle version catalog gradle/libs.versions.toml:

gradle/libs.versions.toml
[versions]
static = "3.0.0"

[libraries]
static = { module = "com.enonic.lib:lib-static", version.ref = "static" }

Then declare its runtime dependency in build.gradle so the library is bundled with your app (Gradle turns dashes in catalog aliases into dots, e.g. libs.http.client). Enonic XP’s own libraries need no version at all — they come from the xplibs catalog provided by the com.enonic.xp.settings plugin and follow xpVersion:

dependencies {
  include xplibs.portal
  include libs.static
}

Then install its type definitions as a dev dependency:

npm install --save-dev @enonic-types/lib-static

Unlike the XP-native libraries — which register their own import path for you — a standalone library does not, so require() won’t pick up its types automatically. Map the library’s path to the installed types by augmenting the XpLibraries interface, for example in a src/main/resources/types.d.ts file:

declare global {
  interface XpLibraries {
    '/lib/enonic/static': typeof import('@enonic-types/lib-static');
  }
}

export {};

With that in place, require('/lib/enonic/static') is fully typed. See the Enonic Development Kit TypeScript reference for more on typing custom and third-party libraries.

Tools

Install tools required to set up transpilation and bundling of Typescript code:

npm install --save-dev typescript tsdown glob concurrently
  • typescript will enable support of Typescript language by adding syntax for type declarations and annotations

  • tsdown is a TypeScript bundler powered by Rolldown (the successor to the now-deprecated tsup)

  • glob is used by the build config to discover the source files to bundle

  • concurrently is a tool to run multiple commands concurrently

Configure

package.json

Add the following build script to the package.json file:

{
  "scripts": {
    "build": "tsdown"
  }
}
You will typically NOT publish an Enonic XP project to npm, so it’s a good idea to add the following to the package.json file:
{
  "private": true
}

TypeScript configuration

Add the following config files to the project:

tsconfig.json
{
  // This file should only include files that configures the build system.
  // It should ignore all actual source files.

  // Read more about tsconfig.json at:
  // https://www.typescriptlang.org/tsconfig

  // Specifies an array of filenames or patterns to include in the program.
  // These filenames are resolved relative to the directory containing the
  // tsconfig.json file.
  "include": [
    "**/*.ts"
  ],

  // Specifies an array of filenames or patterns that should be skipped when
  // resolving include.
  // Important: exclude only changes which files are included as a result of
  // the include setting. A file specified by exclude can still become part of
  // your codebase due to an import statement in your code, a types inclusion,
  // a /// <reference directive, or being specified in the files list.
  // It is not a mechanism that prevents a file from being included in the
  // codebase - it simply changes what the include setting finds.
  "exclude": [
    "**/*.d.ts",
    "src/**/*.*", // Avoid Enonic XP source files
  ],

  "compilerOptions": {
    // Leave baseUrl unset: setting it would let bare module specifiers resolve
    // against the project root instead of node_modules, which can shadow real
    // packages with same-named local folders.
    // "baseUrl": ".",

    "lib": [
      "es2023" // string.replaceAll
    ],

    // By default all visible ”@types” packages are included in your compilation.
    // Packages in node_modules/@types of any enclosing folder are considered visible.
    // For example, that means packages within ./node_modules/@types/,
    // ../node_modules/@types/, ../../node_modules/@types/, and so on.
    // If types is specified, only packages listed will be included in the global scope.
    // This feature differs from typeRoots in that it is about specifying only the exact
    // types you want included, whereas typeRoots supports saying you want particular folders.
    "types": [
      "node"
    ],
    // tsdown's types reference `exports`-subpath imports and ship .d.ts using
    // private (#) fields; these let the build config type-check cleanly.
    "skipLibCheck": true,
    "module": "preserve",
    "moduleResolution": "bundler"
  },
}
src/main/resources/tsconfig.json
{
  // This file should only include Enonic XP server-side source files.
  // It should exclude any client-side source files.

  // Read more about tsconfig.json at:
  // https://www.typescriptlang.org/tsconfig

  // Specifies an array of filenames or patterns to include in the program.
  // These filenames are resolved relative to the directory containing the
  // tsconfig.json file.
  "include": [
    "**/*.ts"
  ],

  // Specifies an array of filenames or patterns that should be skipped when
  // resolving include.
  // Important: exclude only changes which files are included as a result of
  // the include setting. A file specified by exclude can still become part of
  // your codebase due to an import statement in your code, a types inclusion,
  // a /// <reference directive, or being specified in the files list.
  // It is not a mechanism that prevents a file from being included in the
  // codebase - it simply changes what the include setting finds.
  "exclude": [
    "**/*.d.ts",
    "assets/**/*.*",
    // "static/**/*.*",
  ],

  "compilerOptions": {
    // A series of entries which re-map imports to lookup locations relative
    // to the baseUrl if set, or to the tsconfig file itself otherwise.
    "paths": {
      // Explicit per-library entries (rather than only the "/lib/xp/*"
      // wildcard) let the IDE's auto-import suggest the runtime-correct
      // "/lib/xp/<name>" specifiers. See also autoImportSpecifierExcludeRegexes
      // in .vscode/settings.json. Add an entry when installing a new
      // @enonic-types/lib-* package.
      "/lib/xp/admin": ["../../../node_modules/@enonic-types/lib-admin/admin"],
      "/lib/xp/app": ["../../../node_modules/@enonic-types/lib-app/app"],
      "/lib/xp/auditlog": ["../../../node_modules/@enonic-types/lib-auditlog/auditlog"],
      "/lib/xp/auth": ["../../../node_modules/@enonic-types/lib-auth/auth"],
      "/lib/xp/cluster": ["../../../node_modules/@enonic-types/lib-cluster/cluster"],
      "/lib/xp/common": ["../../../node_modules/@enonic-types/lib-common/common"],
      "/lib/xp/content": ["../../../node_modules/@enonic-types/lib-content/content"],
      "/lib/xp/context": ["../../../node_modules/@enonic-types/lib-context/context"],
      "/lib/xp/event": ["../../../node_modules/@enonic-types/lib-event/event"],
      "/lib/xp/export": ["../../../node_modules/@enonic-types/lib-export/export"],
      "/lib/xp/grid": ["../../../node_modules/@enonic-types/lib-grid/grid"],
      "/lib/xp/i18n": ["../../../node_modules/@enonic-types/lib-i18n/i18n"],
      "/lib/xp/io": ["../../../node_modules/@enonic-types/lib-io/io"],
      "/lib/xp/mail": ["../../../node_modules/@enonic-types/lib-mail/mail"],
      "/lib/xp/node": ["../../../node_modules/@enonic-types/lib-node/node"],
      "/lib/xp/portal": ["../../../node_modules/@enonic-types/lib-portal/portal"],
      "/lib/xp/project": ["../../../node_modules/@enonic-types/lib-project/project"],
      "/lib/xp/repo": ["../../../node_modules/@enonic-types/lib-repo/repo"],
      "/lib/xp/scheduler": ["../../../node_modules/@enonic-types/lib-scheduler/scheduler"],
      "/lib/xp/schema": ["../../../node_modules/@enonic-types/lib-schema/schema"],
      "/lib/xp/sse": ["../../../node_modules/@enonic-types/lib-sse/sse"],
      "/lib/xp/task": ["../../../node_modules/@enonic-types/lib-task/task"],
      "/lib/xp/value": ["../../../node_modules/@enonic-types/lib-value/value"],
      "/lib/xp/vhost": ["../../../node_modules/@enonic-types/lib-vhost/vhost"],
      "/lib/xp/websocket": ["../../../node_modules/@enonic-types/lib-websocket/websocket"],
      "/lib/xp/*": ["../../../node_modules/@enonic-types/lib-*"],
      "/*": ["./*"],
    },

    "skipLibCheck": true,

    // By default all visible ”@types” packages are included in your compilation.
    // Packages in node_modules/@types of any enclosing folder are considered visible.
    // For example, that means packages within ./node_modules/@types/,
    // ../node_modules/@types/, ../../node_modules/@types/, and so on.
    // If types is specified, only packages listed will be included in the global scope.
    // This feature differs from typeRoots in that it is about specifying only the exact
    // types you want included, whereas typeRoots supports saying you want particular folders.
    "types": [
      // Make the types for the Enonic XP Global objects and functions
      // available in the global scope.
      // https://developer.enonic.com/docs/xp/stable/framework/globals
      // app, exports, log, require, resolve
      // https://developer.enonic.com/docs/xp/stable/framework/java-bridge
      // __.newBean, __.toNativeObject, __.nullOrValue
      "@enonic-types/global"
    ],
  },
}
src/main/resources/assets/tsconfig.json
{
  // This file configures TypeScript for client-side assets.

  // Read more about tsconfig.json at:
  // https://www.typescriptlang.org/tsconfig

  // Specifies an array of filenames or patterns to include in the program.
  // These filenames are resolved relative to the directory containing the
  // tsconfig.json file.
  "include": [
    "./**/*.ts",
    "./**/*.tsx",
  ],

  // Specifies an array of filenames or patterns that should be skipped when
  // resolving include.
  // Important: exclude only changes which files are included as a result of
  // the include setting. A file specified by exclude can still become part of
  // your codebase due to an import statement in your code, a types inclusion,
  // a /// <reference directive, or being specified in the files list.
  // It is not a mechanism that prevents a file from being included in the
  // codebase - it simply changes what the include setting finds.
  "exclude": [
    "./**/*.d.ts",
  ],

  "compilerOptions": {
    "lib": [
      "DOM", // console, document, window, etc...
    ],
  },
}

tsdown configuration

A single config file defines both build targets (server and client/assets). It discovers every source file with glob and emits one output file per input, keeping the directory tree intact — Enonic XP loads each controller/service/task by its resource path. Runtime libraries (/lib/xp/*, Market libraries such as /lib/graphql etc.) are marked external so they are never bundled: an absolute import is bundled only when it is a source file of the app itself, and left to the XP runtime otherwise — mirroring how XP resolves require() at runtime, with no list of libraries to maintain. A target is skipped entirely when it has no source files.

tsdown.config.ts
import {existsSync} from 'node:fs';

import {transform} from '@swc/core';
import {globSync} from 'glob';
import {defineConfig} from 'tsdown';


const SRC = 'src/main/resources';
const SRC_ASSETS = `${SRC}/assets`;
const DST = 'build/resources/main';
const DST_ASSETS = `${DST}/assets`;

const dev = process.env.NODE_ENV === 'development';
const logLevel: 'silent' | 'info' = ['QUIET', 'WARN'].includes(process.env.LOG_LEVEL_FROM_GRADLE || '') ? 'silent' : 'info';

// Enonic XP loads each controller/service/task by its resource path, so every
// source file must become its own output file with the directory tree intact.
// Turn a glob into a tsdown `entry` map ({ "relative/name": "src/path/file.ts" }).
function entries(dir: string, exts: string, ignore: string[] = []): Record<string, string> {
  return Object.fromEntries(
    globSync(`${dir}/**/*.${exts}`, {posix: true, ignore})
      .map(file => [file.slice(dir.length + 1).replace(/\.[^.]+$/, ''), file]),
  );
}

const serverEntry = entries(SRC, '{ts,js}', [`${SRC_ASSETS}/**`]);
const assetEntry = entries(SRC_ASSETS, '{tsx,ts,jsx,js}');

// XP resolves an absolute import at runtime against the app's own resources
// first, then against the modules provided by the runtime: XP's own libraries
// (/lib/xp/*), libraries `include`d in build.gradle, modules from other apps.
// Mirror that rule at build time: bundle an absolute import only when it is a
// source file of this app, and leave every other one to the runtime — no list
// of runtime modules to maintain. A mistyped specifier is caught by
// `check:types` (TS2307), not by the bundler.
const SRC_EXTS = ['.ts', '.tsx', '.js', '.jsx'];
function isAppSource(id: string): boolean {
  return SRC_EXTS.some(ext => existsSync(`${SRC}${id}${ext}`) || existsSync(`${SRC}${id}/index${ext}`));
}
const isRuntimeModule = (id: string, _importer: string | undefined, isResolved: boolean): boolean =>
  !isResolved && id.startsWith('/') && !isAppSource(id);

// Nashorn (XP's server-side JS engine) lacks ES2015 destructuring, but Oxc —
// tsdown's transformer — can't target below es2015. Re-lower the bundled server
// output to es5 with SWC after bundling, so bundled deps are covered too.
const nashornEs5 = {
  name: 'nashorn-es5',
  async renderChunk(code: string) {
    const out = await transform(code, {
      jsc: {
        parser: {syntax: 'ecmascript'},
        target: 'es5',
        loose: true,
        externalHelpers: false,
      },
      isModule: false,
      minify: false,
      sourceMaps: false,
    });
    return {code: out.code, map: null};
  },
};

// Skip a target that has no source files (e.g. a server-only or client-only app).
export default defineConfig([
  ...(Object.keys(serverEntry).length ? [{
    entry: serverEntry,
    outDir: DST,
    format: 'cjs' as const,
    target: 'es2015', // Rolldown/oxc floor; nashornEs5 plugin re-lowers to es5 for Nashorn
    platform: 'neutral' as const,
    clean: false, // outDir also holds Gradle-copied resources + the assets/ subfolder
    dts: false, // d.ts files are useless at runtime
    minify: false, // minifying server files makes debugging harder
    sourcemap: false,
    logLevel,
    plugins: [nashornEs5],
    tsconfig: `${SRC}/tsconfig.json`,
    inputOptions: {
      external: isRuntimeModule,
      resolve: {
        mainFields: ['module', 'main'],
      },
    },
    outputOptions: {
      chunkFileNames: '_chunks/[name]-[hash].js', // avoid chunk-name collisions
    },
  }] : []),
  ...(Object.keys(assetEntry).length ? [{
    entry: assetEntry,
    outDir: DST_ASSETS,
    format: 'esm' as const,
    target: 'es2015',
    platform: 'browser' as const,
    clean: false,
    dts: false,
    minify: !dev,
    sourcemap: dev,
    logLevel,
    tsconfig: `${SRC_ASSETS}/tsconfig.json`,
  }] : []),
]);

Gradle configuration

A vanilla project already ships a complete Enonic Gradle build: settings.gradle applies the com.enonic.xp.settings plugin (which pins the Enonic plugin versions — 4.0.0 at the time of writing), build.gradle applies the com.enonic.xp.app plugin, and the repositories — including the Enonic repo via xp.enonicRepo() — are already configured. You only need to extend the build so Gradle also runs the TypeScript tooling.

Node-gradle plugin

Add the node-gradle plugin to the existing plugins block in build.gradle, so Gradle can run the npm scripts:

build.gradle
plugins {
  id 'com.enonic.xp.app'                            // already present
  id 'com.github.node-gradle.node' version '7.1.0'  // add this
}

Node.js

Configure the node-gradle plugin to download a pinned Node.js version, so builds are reproducible regardless of what is installed locally:

build.gradle
node {
  // Whether to download and install a specific Node.js version or not
  // If false, it will use the globally installed Node.js
  // If true, it will download node using above parameters
  // Note that npm is bundled with Node.js
  download = true

  // Version of node to download and install (only used if download is true)
  // It will be unpacked in the workDir
  version = '24.11.0'
}

npmBuild task

Add the following task to the build.gradle file, which will run the npm build script defined in package.json:

build.gradle
tasks.register('npmBuild', NpmTask) {
  args = [
    'run',
    '--silent',
    'build'
  ]
  dependsOn npmInstall
  environment = [
    'FORCE_COLOR': 'true',
    'LOG_LEVEL_FROM_GRADLE': gradle.startParameter.logLevel.toString(),
    'NODE_ENV': project.findProperty('env') == 'dev' ? 'development' : 'production'
  ]
  inputs.dir 'src/main/resources'
  outputs.dir 'build/resources/main'
  outputs.upToDateWhen { false }
}

jar.dependsOn npmBuild

The env=dev project property selects a development build of the client-side code (readable, unminified output with source maps; production output is minified and ships no source maps, so your TypeScript source is not exposed). It is what the dev task of the com.enonic.xp.app plugin — and therefore enonic dev — passes automatically, so the sandbox development loop gets debuggable code, while build and deploy produce production output. To get a development build from a plain Gradle invocation, run ./gradlew build -Penv=dev.

Clean up

Add the following to the build.gradle file:

build.gradle
tasks.withType(Copy).configureEach {
  includeEmptyDirs = false
}

processResources {
  exclude '**/.gitkeep'
  exclude '**/*.json'
  exclude '**/*.ts'
  exclude '**/*.tsx'
}

This will make sure that assembled application bundle contains only required assets and none of the original source TypeScript files.

Summary

You should now be able to perform basic builds that transpile TypeScript code to JavaScript. In the next chapter we’ll have a look at Type checking.


Contents

Contents