Internal Javascript Libraries

In which situation do I need a library ?

  • When you need to share code between two apps or endpoints

  • When the shared code has at least one NPM dependency

  • When the shared code needs translated strings (gettext)

  • When you want a common endpoint to include Javascript and CSS styles

  • When you need to share code between a TypeScript app and a plain Javascript app

  • When you want to share a Vue component

When NOT to create a library ?

  • When the code uses dynamic import, for example to load polyfills or translations. In this case, use a standard webpack configuration

  • When you need to output a file with a revision hash in its name, for example my-lib-name-0123456aea.js. In this case, use a standard webpack configuration.

Folder structure of an internal library

Create a scripts/lib/ folder in Tuleap Core or in the plugin where code is shared:

# In core
$> mkdir -p tuleap/src/scripts/lib/my-lib-name/ && cd tuleap/src/scripts/lib/my-lib-name/
# In a plugin
$> mkdir -p tuleap/plugins/my-plugin/scripts/lib/my-lib-name/ && cd tuleap/plugins/my-plugin/scripts/lib/my-lib-name/

Here is the folder structure you should follow:

my-plugin/
 |-- build-manifest.json # Edit it to declare your lib for translations
 |-- scripts/
      |-- lib/
           |-- my-lib-name/
                |-- .gitignore          # Exclude dist/ from git
                |-- jest.config.js      # Unit tests bootstrapping
                |-- package-lock.json   # Generated by npm. Never edit manually.
                |-- package.json        # Declares the library name, its dependencies and its build scripts.
                |-- tsconfig.json       # Typescript configuration
                |-- vite.config.ts      # Vite configuration to build the lib
                |-- images/                             # Images to include in the lib's CSS
                     |-- some-image.png
                |-- dist/                               # Generated assets. Must be excluded from git
                     |-- my-lib-name.umd.js             # Javascript UMD bundle, it is referenced in "main" in package.json
                     |-- my-lib-name.es.js              # Javascript ES module bundle, it is referenced in "module" in package.json
                     |-- style.css                      # CSS bundle, it is referenced in "style" in package.json
                |-- po/                                 # Localization strings
                     |-- fr_FR.po                       # Localized strings for French
                |-- src/                                # The lib source-code
                     |-- index.ts                       # Entrypoint for your library
                     |-- subfolder/
                          |-- my-other-source.ts
                |-- themes/                             # The lib styles
                     |-- style.scss                     # Entrypoint for your library styles
                |-- types/                              # TypeScript declarations
                     |--index.d.ts                      # Typescript declarations for the entrypoint, it is referenced in "types" in package.json
                     |-- subfolder/
                          |-- my-other-source.d.ts

Build your internal library

The build system will read build-manifest.json to understand how and where it needs to extract translated strings.

// tuleap/plugins/my-plugin/build-manifest.json
{
    "name": "my-plugin",
    "gettext-ts": {
        "my-lib-name": {
            "src": "src/scripts/lib/my-lib-name/src",
            "po": "src/scripts/lib/my-lib-name/po"
        }
    }
}

Create manually the fr_FR.po file. When you run make generate-po, this file is NOT created but it will be filled with the translations.

// tuleap/plugins/my-plugin/po/fr_FR.po
msgid ""
msgstr ""
"Project-Id-Version: \n"
"POT-Creation-Date: \n"
"PO-Revision-Date: \n"
"Last-Translator: Your Full Name <your email address>\n"
"Language-Team: \n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"

To build up your application, you will have to create a vite.config.ts file. This file should be located in my-lib-name/.

// tuleap/plugins/my-plugin/scripts/lib/my-lib-name/vite.config.ts
import { defineConfig } from "../../../../../tools/utils/scripts/vite-configurator";
import * as path from "path";
export default defineConfig({
    build: {
        lib: {
            entry: path.resolve(__dirname, "src/index.ts"),
            name: "MyLibName",
        },
    },
});

Once you have a Vite config, you will need a package.json in my-lib-name/.

// tuleap/plugins/my-plugin/scripts/lib/my-lib-name/package.json
{
  "author": "Enalean Team",                   // or yourself
  "name": "@tuleap/my-lib-name",
  "homepage": "https://tuleap.org",           // or your lib's homepage
  "license": "GPL-2.0-or-later",              // or your license
  "private": true,
  "version": "0.0.0",
  "main": "dist/my-lib-name.umd.js",          // The Javascript UMD bundle of your lib
  "modules": "dist/my-lib-name.es.js",        // The Javascript ES Module bundle of your lib
  "exports": {
    ".": {
      "import": "./dist/my-lib-name.es.js",   // The Javascript ES Module bundle of your lib
      "require": "./dist/my-lib-name.umd.js"  // The Javascript UMD bundle of your lib
    }
  },
  "types": "types/index.d.ts",                // The Typescript declarations for the endpoint of your lib
  "style": "dist/style.css",                  // The CSS bundle of your lib
  "dependencies": {
    "dompurify": "^2.2.2"
  },
  "devDependencies": {},
  "config": {
    "bin": "../../../../../node_modules/.bin" // This should point to the node_modules/.bin folder in tuleap/ root folder
  },
  "scripts": {
    "build": "$npm_package_config_bin/run-p build:*",
    "build:vite": "$npm_package_config_bin/vite build",
    "build:types": "rm -rf types/ && $npm_package_config_bin/tsc",
    "watch": "$npm_package_config_bin/run-p watch:*",
    "watch:vite": "$npm_package_config_bin/nodemon --watch src/ --ignore \"src/**/*.test.ts\" --ext ts --exec '$npm_package_config_bin/vite build --mode development --minify false'",
    "watch:types": "rm -rf types/ && $npm_package_config_bin/tsc -w --preserveWatchOutput",
    "test": "$npm_package_config_bin/jest"
  }
}

Note

All the Vite/Jest/npm-run-all (run-p)/nodemon dependencies are available at the tuleap root folder, hence the config.bin.

Use the npm scripts to build the library or to launch the unit tests.

npm run build # For a production build, outputs minified code.
npm run watch # Build the lib in watch mode.
npm test      # Run the Jest unit tests only once.

Warning

In order to test the library in real conditions (with your browser), you need to also include it in an application AND also rebuild that application.

Once you have a package.json file, you will also need a tsconfig.json file to configure Typescript.

// tuleap/plugins/my-plugin/scripts/lib/my-lib-name/tsconfig.json
{
    "extends": "../../../../../tools/utils/scripts/tsconfig-for-libraries.json",
    "compilerOptions": {
        "lib": [],  // Add values like "DOM" if your lib interacts with the DOM
        "outDir": "types/"
    },
    "include": ["src/**/*"],
    "exclude": ["src/**/*.test.ts"]
}

You also need a Jest config, but this one has nothing special.

// tuleap/plugins/my-plugin/scripts/lib/my-lib-name/jest.config.js
const base_config = require("../../../../../tests/jest/jest.base.config.js");

module.exports = {
    ...base_config,
    displayName: "my-lib-name",
};

Add a .gitignore file to remove the dist/ and types folders from source control. They contains only generated files and should not be committed.

// tuleap/plugins/my-plugin/scripts/lib/my-lib-name/.gitignore
dist/
types/

If you have gettext translations with node-gettext, you will need a pofile-shim.d.ts so that TypeScript understands what is returned by import "file.po".

// tuleap/plugins/my-plugin/scripts/lib/my-lib-name/src/pofile-shim.d.ts
declare module "*.po" {
    // See https://github.com/smhg/gettext-parser for the file format reference
    interface Translation {
        readonly msgid: string;
        readonly msgstr: string;
    }

    interface TranslatedStrings {
        readonly [key: string]: Translation;
    }

    export interface Contexts {
        readonly [key: string]: TranslatedStrings;
    }

    export interface GettextParserPoFile {
        readonly translations: Contexts;
    }

    const content: GettextParserPoFile;
    export default content;
}

In your stylesheet, you can reference images. They will be inlined (converted to a base64 string) and included in dist/style.css.

// tuleap/plugins/my-plugin/scripts/lib/themes/style.scss
.some-css-class {
    // The image will be converted to a base64 string
    background: url('../images/some-image.png');
}

Finally, your index.ts file (the lib entrypoint) should export types that callers will need. Exporting them will ensure that the generated index.d.ts declaration file references those types. Also note that you need to import the style file you referenced in your package.json so it can be processed by Vite.

// tuleap/plugins/my-plugin/scripts/lib/my-lib-name/src/index.ts
import "../themes/style.scss"; // Import the styles to bundle them in dist/style.css
import type { MyType, MyOtherType } from "./types";

export type { MyType, MyOtherType }; // Re-export the types, so that TypeScript callers can import them
export function myFunction(param: MyType): MyOtherType {
    //...
}

Use your library from another application

To use your library from another application, you must first declare it as a dependency in the app’s package.json file.

// tuleap/plugins/other-plugin/package.json
{
  "name": "@tuleap/other-plugin",
  // ...
  "dependencies": {
    "@tuleap/my-lib-name": "file:../my-plugin/scripts/lib/my-lib-name" // Add your lib as a dependency. Reference it with file: protocol to create a symlink
  },
  "scripts": {
    "build": "...",
    "postshrinkwrap": "php ../../tools/utils/scripts/clean-lockfile-from-local-tuleap-dep.php \"$(pwd)\"" // Don't forget to add this script, otherwise package-lock.json will copy all your lib's dependencies
  }
}

Use the library like any other “npm module” in Javascript / Typescript files:

// tuleap/plugins/other-plugin/scripts/other-app/src/other-file.ts
import type { MyOtherType } from "@tuleap/my-lib-name";
import { myFunction } from "@tuleap/my-lib-name";

const result: MyOtherType = myFunction(param);

Import the CSS styles like any other “npm module” in SCSS files:

// tuleap/plugins/other-plugin/themes/BurningParrot/src/other-file.scss
@import '~@tuleap/my-lib-name';

Making changes to your library

Warning

While working on your library, changes will NOT be automatically visible from the application. Both the library and the application MUST be rebuilt in order to see your changes.

$> (cd tuleap/plugins/my-plugin/scripts/lib/my-lib-name/ && npm run watch)
# In another terminal usually
$> (cd tuleap/plugins/other-plugin/ && npm run watch)