Building

Contents

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

Create project

We start this tutorial with the most basic setup possible, a vanilla XP project:

enonic create myproject -r starter-vanilla

NPM modules

Types

Install types needed to configure the build system:

npm install --save-dev @types/node

Install types needed to work with Enonic Core API:

npm install --save-dev @enonic-types/core @enonic-types/lib-content @enonic-types/lib-portal @enonic-types/global @enonic-types/lib-context @enonic-types/lib-auth @enonic-types/lib-event @enonic-types/lib-common @enonic-types/lib-repo @enonic-types/lib-websocket @enonic-types/lib-node @enonic-types/lib-i18n @enonic-types/lib-task @enonic-types/lib-io @enonic-types/lib-project @enonic-types/lib-mail @enonic-types/lib-export @enonic-types/lib-scheduler @enonic-types/lib-admin @enonic-types/lib-cluster @enonic-types/lib-value

Tools

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

npm install --save-dev typescript tsup @swc/core concurrently
  • typescript will enable support of Typescript language by adding syntax for type declarations and annotations

  • tsup is a TypeScript bundler powered by esbuild

  • @swc/core is a TypeScript / JavaScript compiler

  • concurrently is a tool to run multiple commands concurrently

Configure

File processing

We need a cross-platform way to check if there are any files to build, so here are a couple scripts that do just that:

tsup/anyAssetFiles.js
const fs = require('fs');

const folderPath = 'src/main/resources/assets';
const regExpPattern = '(^.?|\.[^d]|[^.]d|[^.][^d])\.tsx?$';

function checkFilesRecursively(folderPath) {
  const files = fs.readdirSync(folderPath);
  for (const file of files) {
    const filePath = `${folderPath}/${file}`;
    if (fs.lstatSync(filePath).isDirectory()) {
      checkFilesRecursively(filePath);
    } else if (file.match(regExpPattern)) {
      return true; // Exit with code 0 if a matching file is found
    }
  }
  return false; // No matching files found in this directory
}

if (checkFilesRecursively(folderPath)) {
  process.exit(0);
} else {
  process.exit(1);
}
tsup/anyServerFiles.js
const fs = require('fs');

const folderPath = 'src/main/resources';
const pattern = '(^.?|\.[^d]|[^.]d|[^.][^d])\.ts$';
const ignoredPaths = ['src/main/resources/assets'];

function checkFilesRecursively(folderPath) {
  const files = fs.readdirSync(folderPath);
  for (const file of files) {
    const filePath = `${folderPath}/${file}`;
    if (ignoredPaths.some(d => filePath.includes(d))) {
      continue; // Skip ignored folders
    }
    if (fs.lstatSync(filePath).isDirectory()) {
      checkFilesRecursively(filePath);
    } else if (file.match(pattern)) {
      return true; // Exit with code 0 if a matching file is found
    }
  }
  return false; // No matching files found in this directory
}

if (checkFilesRecursively(folderPath)) {
  process.exit(0);
} else {
  process.exit(1);
}

package.json

Add the following scripts to the package.json file (the first script concurrently executes the other two):

{
  "scripts": {
    "build": "concurrently -c auto -g --timings npm:build:*",
    "build:assets": "node tsup/anyAssetFiles.js && npx tsup -d build/resources/main/assets || exit 0",
    "build:server": "node tsup/anyServerFiles.js && npx tsup -d build/resources/main || exit 0",
  }
}
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": {
    // Do NOT add this as it will resolve tsup to the local tsup folder, rather
    // than the tsup node_module.
    // "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"
    ],
  },
}
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": {
      "/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...
    ],
  },
}

TSup configuration

Add the following config files to the project:

tsup.config.ts
import type { Options } from './tsup';


import { defineConfig } from 'tsup';
import { DIR_DST, DIR_DST_ASSETS } from './tsup/constants';


export default defineConfig(async (options: Options) => {
  if (options.d === DIR_DST) {
    return import('./tsup/server').then(m => m.default());
  }
  if (options.d === DIR_DST_ASSETS) {
    return import('./tsup/client').then(m => m.default());
  }
  throw new Error(`Unconfigured directory:${options.d}!`)
});
tsup/index.d.ts
import type { Options as TsupOptions } from 'tsup';

export declare interface Options extends TsupOptions {
  d?: string
}
tsup/constants.ts
export const AND_BELOW = '**';

export const DIR_DST = 'build/resources/main';
export const DIR_DST_ASSETS = `${DIR_DST}/assets`;

export const DIR_SRC = 'src/main/resources';
export const DIR_SRC_ASSETS = `${DIR_SRC}/assets`;
tsup/client.ts
import type { Options } from '.';


import { globSync } from 'glob';
import { AND_BELOW, DIR_SRC_ASSETS } from './constants';


export default function buildAssetConfig(): Options {
  const GLOB_EXTENSIONS_ASSETS = '{tsx,ts,jsx,js}';
  const FILES_ASSETS = globSync(
    `${DIR_SRC_ASSETS}/${AND_BELOW}/*.${GLOB_EXTENSIONS_ASSETS}`
  ).map(s => s.replaceAll('\\', '/'));
  return {
    bundle: true,
    dts: false, // d.ts files are use useless at runtime
    entry: FILES_ASSETS,
    esbuildPlugins: [],

    // By default tsup bundles all imported modules, but dependencies
    // and peerDependencies in your packages.json are always excluded
    // external: [ // Must be loaded into global scope instead
    // ],

    format: ['esm'],
    minify: process.env.NODE_ENV === 'development' ? false : true,
    // noExternal: [],
    platform: 'browser',

    silent: ['QUIET', 'WARN']
      .includes(process.env.LOG_LEVEL_FROM_GRADLE||''),

    splitting: true,
    sourcemap: process.env.NODE_ENV === 'development' ? false : true,
    tsconfig:`${DIR_SRC_ASSETS}/tsconfig.json`,
  };
}
tsup/server.ts
import type { Options } from '.';


import { globSync } from 'glob';
import {
  AND_BELOW,
  DIR_SRC,
  DIR_SRC_ASSETS,
} from './constants';


export default function buildServerConfig(): Options {
  const GLOB_EXTENSIONS_SERVER = '{ts,js}';
  const FILES_SERVER = globSync(
    `${DIR_SRC}/${AND_BELOW}/*.${GLOB_EXTENSIONS_SERVER}`,
    {
      absolute: false,
      ignore: globSync(
        `${DIR_SRC_ASSETS}/${AND_BELOW}/*.${GLOB_EXTENSIONS_SERVER}`
      )
    }
  ).map(s => s.replaceAll('\\', '/'));

  return {
    bundle: true,
    dts: false, // d.ts files are use useless at runtime
    entry: FILES_SERVER,
    env: {
      BROWSER_SYNC_PORT: '3100',
    },
    esbuildOptions(options, context) {
      // If you have libs with chunks, use this to avoid collisions
      options.chunkNames = '_chunks/[name]-[hash]';

      options.mainFields = ['module', 'main'];
    },

    external: [
      '/lib/cache',
      '/lib/enonic/static',
      /^\/lib\/guillotine/,
      '/lib/graphql',
      '/lib/graphql-connection',
      '/lib/http-client',
      '/lib/license',
      '/lib/mustache',
      '/lib/router',
      '/lib/util',
      '/lib/vanilla',
      '/lib/text-encoding',
      '/lib/thymeleaf',
      /^\/lib\/xp\//,
    ],
    format: 'cjs',
    minify: false, // Minifying server files makes debugging harder
    // noExternal: [],
    platform: 'neutral',

    silent: ['QUIET', 'WARN']
      .includes(process.env.LOG_LEVEL_FROM_GRADLE || ''),

    shims: false,
    splitting: true,
    sourcemap: false,
    target: 'es5'
  };
}

Gradle configuration

Node-gradle plugin

In order for Gradle to be able to run npm scripts, you need to use the node-gradle plugin.

Add the following to the build.gradle file:

build.gradle
plugins {
  id 'com.github.node-gradle.node' version '7.0.2'
}

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 = '20.12.2'
}

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.hasProperty('dev') || project.hasProperty('development') ? 'development' : 'production'
  ]
  inputs.dir 'src/main/resources'
  outputs.dir 'build/resources/main'
}

jar.dependsOn npmBuild

Clean up jar file

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 jar file 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