src/docs/guides/assets-manager/README.md
Based on Concurrently the Orchard Core assets management tool is used for building, watching, hosting assets. It allows to use bundlers as Parcel Vite and Webpack. This is a non-opiniated tool which allows to be extended for any asset compiler/bundler that someone may require in the future. Everything is written as ES6 modules (.mjs files).
Concurrently, is a concurrent shell runner which allows to trigger any possible shell command.
Concurrently uses an Assets.json file that defines actions to execute.
Parcel is the easiest way to build assets so far as it doesn't require any configuration. It is a zero file configuration bundler which means we use the same configuration for all assets. It is the recommended builder for those who want to easily start with a bundler. Though, Vite is more suited for Vue apps.
packageManager value in the root package.json (currently v4.9.x).
REM On Windows may require to run command shell with administrator privileges.
corepack enable
yarn
!!! danger
Some third-party distributors may not include Corepack by default, in particular if you install Node.js from your system package manager. If that happens, running npm install -g corepack before corepack enable should do the trick.
What to do if you change an SCSS, JS, or TS/TSX file in any of Orchard Core's projects, and want to update the output files (that go into the wwwroot folders)?
yarn to update the dependencies.yarn build from the command line in the root of the repository. This will build all changed assets.Alternatively, if you make a lot of changes during development that you want to test quickly, you don't need to run the full build every time. Instead, use yarn watch to automatically build assets when you save a file. For this, run yarn watch -n asset-name, where asset-name is the name property you can find for the given file in the Assets.json file of the given project's root folder. E.g., for the Audit Trail module's audittrailui.scss file it's audittrail, so the command is yarn watch -n audittrail. You can also watch multiple assets at once by separating their names with commas, e.g., yarn watch -n audittrail, audit-trail-diff-viewer.
yarn buildyarn build -n asset-name (also supports --name / --names)yarn build -t tagname (also supports --tag / --tags)yarn watch -n asset-name.yarn host -n asset-name.-n filter: yarn {build, watch or host} -n asset-name1, asset-name2yarn clean. Will also clean parcel-cache folder.package.json.Runs the Parcel bundler.
Note: Sometimes, Parcel is too aggressive in its caching. If you manually delete any output folders, you may need to delete the .parcel-cache folder as well for Parcel to write to it again. Running the yarn clean command will clean it up for you. Also, you should set the "dest" folder of your Parcel assets to a different folder per asset defined in your Assets.json file. It is required because we are cleaning the folder before watching or building the files.
[
{
"action": "parcel",
"name": "module-microsoft-datasource-wrapper",
"source": "Assets/Scripts/datasource-wrapper.js",
"dest": "wwwroot/datasource-wrapper",
"tags":["vue3"]
}
]
The source property must be the entry point passed to the parcel bundler.
The dest property must be a folder. This is because parcel will usually output multiple files.
The tags property can be a string or an array of strings.
You can also pass an options object that override parcel options.
It is possible to bundle apps together when using Parcel by using the "bundleEntrypoint" param in the Assets.json file. This allows to bundle different apps together in your app even though they are not standing in the same directory. These files will be compiled in the folder set in your build.config.mjs file standing at the root of the solution. When using bundleEntrypoint parameter there is no need to set the dest param.
Parcel bundle output folder:
export const parcelBundleOutput = "src/OrchardCore.Modules/OrchardCore.Resources/wwwroot/Scripts/bundle"
Parcel bundleEntrypoint parameter:
[
{
"action": "parcel",
"name": "module-microsoft-datasource-wrapper",
"source": "Assets/Scripts/datasource-wrapper.js",
"bundleEntrypoint": "bundle-name",
"tags":["vue3"]
}
]
For javascript files Parcel will create a .min.js file for you along with a .map file. The .min.js is created for use in production as it doesn't reference the .map file. Using the ResourceManagementOptionsConfiguration you will want to set it this way:
_manifest
.DefineScript("admin")
.SetDependencies("bootstrap", "admin-main", "theme-manager", "jQuery", "Sortable")
.SetUrl("~/TheAdmin/js/theadmin/TheAdmin.min.js", "~/TheAdmin/js/theadmin/TheAdmin.js")
.SetVersion("1.0.0");
Vite bundler action will support any configuration. From bundling a vue app to compiling a simple library. It is working by configuration file. The asset management tool simply loads a vite.config.ts file based on the source folder that we instruct it to use from the Assets.json file.
Example of Assets.json config file:
[
{
"action": "vite",
"name": "my-vue-app",
"source": "Assets/vite-project/",
"tags": ["admin", "dashboard", "js"]
}
]
The source property must be the root folder of your Vite app where your vite.config.ts or .js file stands.
Create a new module or theme in Orchard Core. This module or theme needs to have a /Assets folder. In this /Assets folder we will create a boilerplate Vue app using this command:
cd src/OrchardCore.Modules/path-to-your-module/Assets
yarn create vite
Here is an example of a Vue app using Typescript:
➤ YN0000: · Yarn 4.9.4
➤ YN0000: · Yarn 4.9.4
➤ YN0000: ┌ Resolution step
➤ YN0085: │ + create-vite@npm:6.2.0
➤ YN0000: └ Completed
➤ YN0000: ┌ Fetch step
➤ YN0013: │ A package was added to the project (+ 141.45 KiB).
➤ YN0000: └ Completed
➤ YN0000: ┌ Link step
➤ YN0000: └ Completed
➤ YN0000: · Done in 0s 258ms
√ Project name: ... vite-project
√ Select a framework: » Vue
√ Select a variant: » TypeScript
Scaffolding project in C:\repo\OrchardCore\src\OrchardCore.Modules\OrchardCore.Media\Assets\vite-project...
Done. Now run:
cd vite-project
yarn
yarn dev
Now you could execute the commands that are suggested. It will start the Vite dev server with HMR feature. Though what we want is to execute the server by using the asset manager tool. We will need an Assets.json file for that matter.
Create an Assets.json file at the root of your new module. For example: "src/OrchardCore.Modules/PathToYourModule/Assets.json". This file should contain these settings:
[
{
"action": "vite",
"name": "my-vue-app",
"source": "Assets/vite-project/",
"tags": ["admin", "dashboard", "js"]
}
]
This Assets.json file will instruct the asset manager tool to execute the Vite bundler and to look at the source folder for a vite.config.ts or .js file. But we need to define where we want these assets to be compiled. For that matter we will need to modify the vite.config.ts file.
Here is an example of a configuration file that the asset bundler will be able to work with in the context of a Vue app. Notice that we are using path.resolve() so that this configuration file always returns the appropriate relative path to the asset bundler. Also, it is required that you set an outDir so that the assets be compiled to that directory.
For more details, these configurations are well documented on Rollup.js and Vite.js websites.
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path';
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
build: {
outDir: path.resolve(__dirname, '../../wwwroot/Scripts/Media2/'),
},
})
Execute Vite dev server from the asset manager tool:
# Move to Orchard Core src folder
cd C:/repo/OrchardCore/src
# Install dependencies
yarn
# start vite dev server
yarn host -n my-vue-app
Alternatively, execute Vite watcher:
yarn watch -n my-vue-app
Or simply build that Vite app:
yarn build -n my-vue-app
Webpack bundler action will support any configuration. From bundling a vue app to compiling a simple library. It is working by configuration file. The asset management tool simply loads a given webpack.config.js file that we instruct to use from the Assets.json file.
Example of Assets.json config file:
[
{
"action": "webpack",
"name": "graphiql",
"config": "/Assets/webpack.config.js",
"tags": ["admin", "dashboard", "js"]
}
]
The config property must be the path to where your webpack.config.js file stands.
Allows to run any command through Concurrently.
[
{
"action": "run",
"name": "itwin-viewer-app",
"source": "Assets/itwin-viewer-app",
"scripts": {
"build": "yarn build",
"watch": "yarn start"
}
}
]
Here, the source property must be a folder and is used as the working directory where the command is ran.
The scripts keys must match the command used to start the pipeline. If you start the pipeline with yarn build this would run all build scripts of the run actions.
Allows to copy files.
[
{
"action": "copy",
"dryRun": true,
"name": "copy-bootstrap-js",
"source": "node_modules/bootstrap/dist/js/*.js*",
"dest": "wwwroot/Scripts"
},
{
"action": "copy",
"name": "copy-bootstrap-css",
"source": "node_modules/bootstrap/dist/css/*.css*",
"dest": "wwwroot/Styles"
}
]
Consolidate multiple copy operations into a single entry:
[
{
"action": "copy",
"name": "bootstrap-4.6.1",
"source": [
"node_modules/bootstrap-4.6.1/dist/css/bootstrap.css",
"node_modules/bootstrap-4.6.1/dist/css/bootstrap.min.css",
"node_modules/bootstrap-4.6.1/dist/js/bootstrap.js",
"node_modules/bootstrap-4.6.1/dist/js/bootstrap.min.js"
],
"dest": "wwwroot/Vendor/bootstrap-4.6.1/",
"tags": ["resources", "css", "js"]
}
]
Benefits:
Notes:
source field can be a string (single pattern) or an array of strings (multiple patterns)dest should always be a folder (we do not support renaming files)dry-run task to preview where files will be copiedWhen using ** in a source pattern, the base folder is auto-detected:
{
"source": "node_modules/bootstrap-4.6.1/dist/**"
}
node_modules/bootstrap-4.6.1/dist/dist/css/bootstrap.css → {dest}/css/bootstrap.cssYou can mix different pattern types in the same array:
{
"source": [
"node_modules/lib/dist/**", // Recursive glob
"node_modules/lib/extras/*.js", // Single-level glob
"node_modules/lib/readme.md" // Specific file
]
}
If dest is not specified, the default destination is determined from tags and file extension of the first source:
{
"action": "copy",
"name": "my-scripts",
"source": [
"node_modules/lib/file1.js", // First source determines dest
"node_modules/lib/file2.css"
],
"tags": ["resources", "js"]
// dest defaults to: "{basePath}/wwwroot/Scripts/"
}
Allows to minify files.
[
{
"action": "min",
"dryRun": true,
"name": "copy-bootstrap-js",
"source": "node_modules/bootstrap/dist/js/*.js*",
"dest": "wwwroot/Scripts"
},
{
"action": "min",
"name": "copy-bootstrap-css",
"source": "node_modules/bootstrap/dist/css/*.css*",
"dest": "wwwroot/Styles"
}
]
The source field can be a file, or a glob.
The destination should always be a folder as we do not support renaming files.
You can use the dry-run task to log to the console where the files will be copied to.
Allows to transpile scss files.
[
{
"action": "sass",
"name": "transpile-bootstrap-scss",
"source": "node_modules/bootstrap/dist/css/main.scss",
"dest": "wwwroot/Styles"
}
]
The source field can be a file, or a glob.
The destination should always be a folder as we do not support renaming files.
You can use the dry-run task to log to the console where the files will be copied to.
Allows to concatenate files together.
[
{
"action": "concat",
"name": "media",
"source": [
"node_modules/blueimp-file-upload/js/jquery.iframe-transport.js",
"node_modules/blueimp-file-upload/js/jquery.fileupload.js",
"Assets/js/app/Shared/uploadComponent.js"
],
"dest": "wwwroot/Scripts"
}
]
The source field must be an array of files.
The destination should always be a folder as we do not support renaming files.
The concat action physically concatenates files - it does not use a bundler or module resolver. This has important implications for how it handles node_modules dependencies:
When a source path starts with node_modules/, the concat action resolves it from the workspace root node_modules/ directory (where Yarn hoists all workspace dependencies):
// In assetGroups.mjs
if (src.startsWith("node_modules")) {
// Always resolves to: <workspace-root>/node_modules/
return path.resolve(path.join(process.cwd(), src)).replace(/\\/g, "/");
}
⚠️ Critical Limitation: The concat action resolves dependencies from the workspace root node_modules/ directory. This means all modules/themes using the same dependency with concat must coordinate their version requirements.
Why? Yarn workspaces use "selective hoisting":
node_modules/node_modules/Since concat always resolves from the root node_modules/, it will:
Example Problem:
OrchardCore.Media/Assets/package.json: "bootstrap": "5.3.8"
OrchardCore.Resources/Assets/package.json: "bootstrap": "5.3.8"
SomeTheme/Assets/package.json: "bootstrap": "4.6.1" ← Different!
Result:
[email protected] → hoisted to node_modules/bootstrap/ (most common)[email protected] → installed in SomeTheme/Assets/node_modules/bootstrap/concat action will always use 5.3.8 from root, even for SomeThemeRecommended: Use NPM Package Aliasing
The recommended approach to handle multiple versions is NPM package aliasing. This allows you to install different versions of the same package under unique names in the root node_modules/:
// Assets/package.json (in your module)
{
"dependencies": {
"bootstrap": "5.3.8", // Current version
"bootstrap-4.6.1": "npm:[email protected]" // Legacy version (aliased)
}
}
After running yarn install, both versions are hoisted to the root:
node_modules/
bootstrap/ → 5.3.8
bootstrap-4.6.1/ → 4.6.1 (aliased)
Now you can reference either version in your Assets.json:
[
{
"action": "concat",
"name": "modern-bootstrap",
"source": [
"node_modules/bootstrap/dist/js/bootstrap.js", // Uses 5.3.8
"Assets/js/modern-features.js"
],
"dest": "wwwroot/Scripts"
},
{
"action": "concat",
"name": "legacy-bootstrap",
"source": [
"node_modules/bootstrap-4.6.1/dist/js/bootstrap.js", // Uses 4.6.1
"Assets/js/legacy-features.js"
],
"dest": "wwwroot/Scripts"
}
]
Benefits:
node_modules/ and work with concatpackage.jsonSee the Managing Multiple Package Versions section and NPM Aliasing Guide for complete documentation.
1. Coordinate Versions Across Modules When Possible If all modules can use the same version, keep it simple:
// All Assets/package.json files should have:
{
"dependencies": {
"bootstrap": "5.3.8", // ← Same exact version everywhere
"jquery": "3.7.1" // ← Same exact version everywhere
}
}
2. Use Root Resolutions for Consistency
Add a resolutions field in the root package.json to enforce version consistency when using a single version:
// package.json (root)
{
"resolutions": {
"bootstrap": "5.3.8",
"jquery": "3.7.1"
}
}
3. Use NPM Aliasing for Different Versions When different modules genuinely need different versions, use NPM package aliasing as shown above. This is the recommended approach for maintaining multiple versions while keeping them manageable through package managers.
4. Alternative: Use a Bundler
If you need complex dependency resolution or module bundling, consider using a bundler action (parcel, webpack, or vite) instead of concat. Bundlers properly resolve node_modules and handle version differences automatically.
5. Last Resort: Local Assets Folder
Only if NPM aliasing doesn't meet your needs, copy the required library files directly into your module's Assets/ folder:
[
{
"action": "concat",
"name": "media",
"source": [
"Assets/vendor/blueimp-file-upload/jquery.iframe-transport.js", // ← Local copy
"Assets/vendor/blueimp-file-upload/jquery.fileupload.js",
"Assets/js/app/Shared/uploadComponent.js"
],
"dest": "wwwroot/Scripts"
}
]
This approach trades convenience for complete version control, but eliminates the shared dependency issue. However, NPM aliasing is preferred as it keeps dependencies manageable and trackable.
You can create a build.config.mjs file next to the root package.json.
This file allows you to customize options used by the build tools.
For example, if you wanted to override the parcel browserlist:
// The type of command running and the current group's json object.
export function parcel(type, group) {
return {
defaultTargetOptions: {
engines: {
browsers: "> 1%, last 4 versions, not dead",
},
},
};
}
Here is an example for Vite:
import vue from "@vitejs/plugin-vue";
export function viteConfig(action) {
return {
plugins: [vue()],
build: {
minify: false,
rollupOptions: {
output: {
manualChunks: (id) => {
if (id.includes("node_modules")) {
if (id.includes("@vue") || id.includes("/vue/")) {
return "vue";
}
return "vendor"; // all other package goes here
}
},
},
},
},
};
}
You can also specify the glob pattern used to harvest the Assets.json files in your solution in the build.config.mjs file
export const assetsLookupGlob = "src/{OrchardCore.Modules,OrchardCore.Themes}/*/Assets.json";
The ECMAScript module (ESM) format is the standardized way of loading JavaScript packages. CommonJS is a legacy implementation of modules that is not standardized. ESM is asynchronously loaded, while CommonJS is synchronous. We should favorize building as ESM modules.
Vite (rollup.js) will build by default as ECMAScripts and it is by design. Parcel will automatically build as CommonJS or ECMAScript based on package.json configuration; CommonJS being the default when the "type" parameter is not specified.
To be able to compile as ECMAScript there are requirements.
1 - the package.json file needs to have:
{
"type": "module",
}
This should be enough for any single script files that we want to execute asynchronously.
Though, for Vue 3 apps to use ECMAScript; it needs to use an alias to its ESM bundler version to prevent needing it everywhere in the different components of the app.
import { createApp } from 'vue' //needs an alias to 'vue/dist/vue.esm-bundler.js'
Example for an app that will use Vite/TS would be to add this configuration to a vite.config.ts file.
resolve: {
alias: {
'vue': 'vue/dist/vue.esm-bundler.js',
},
},
Also, now when adding a <script> tag to the HTML, you will need to use:
<script type="module" src="somepath"></script>
<script> tags are non-ESM by default; you have to add a type="module" attribute to opt into ESM mode.
Meaning that the ESM script will be interpreted as CommonJS by the browser if the script tag doesn't have it.
Additionally, Vite by default only allows ES6 builds. It will log a message to the console if you attempt to use its .js builds without the type="module" attribute in the script tag; otherwise, it will throw exceptions in the code. Therefore, it is mandatory to include type="module" with Vite builds.
Parcel allows ES6 builds by setting the "type": "module" parameter in the package.json file. If this parameter is not set, Parcel will compile as CommonJS.
ESM compiled scripts will load fine in a script tag without the type="module" attribute. However, you should try to avoid this.
For more details, see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#applying_the_module_to_your_html
We use NPM package aliasing to manage multiple versions of the same package without manual copying. See the NPM Aliasing Guide for complete documentation.
Quick Example:
// Assets/package.json
{
"dependencies": {
"vue": "3.5.13", // Latest version
"vue-2.6.14": "npm:[email protected]" // Legacy version (aliased)
}
}
This allows you to:
node_modules in your Assets.jsonFor detailed instructions, migration guides, and best practices, see NPM_ALIASING_GUIDE.md.
The following section describes the internal architecture of the asset management system. This is useful for advanced users who want to understand how the system works or need to manage dependencies.
The OrchardCore asset management system uses a three-tier package structure designed to separate concerns and minimize version conflicts:
package.json - Workspace OrchestratorLocation: Root of the repository
Purpose:
resolutions to prevent conflictsWhy it's minimal:
.scripts/assets-manager/package.json - Build Toolchain PackageLocation: .scripts/assets-manager/
Purpose:
bin: ./build.mjs)Why it contains build tools:
node_modulesAssets/package.json FilesLocation: src/OrchardCore.{Modules,Themes}/*/Assets/
Purpose:
Why they're separate:
┌─────────────────────────────────────────────────────────┐
│ Root package.json │
│ - Workspace orchestration │
│ - General dev dependencies (ESLint, TypeScript) │
│ - References assets-manager as workspace dependency │
│ - Version constraints (resolutions) │
└────────────┬────────────────────────────────────────────┘
│ (workspace dependency)
▼
┌─────────────────────────────────────────────────────────┐
│ .scripts/assets-manager/package.json │
│ - Build tools (Parcel, Vite, Webpack, Sass) │
│ - Asset compilation dependencies │
│ - Entry point: build.mjs │
└────────────┬────────────────────────────────────────────┘
│ (processes)
▼
┌─────────────────────────────────────────────────────────┐
│ Module/Theme Assets/package.json │
│ - Runtime dependencies (jQuery, Vue, etc.) │
│ - Libraries that get bundled │
│ - Module-specific versions |
└─────────────────────────────────────────────────────────┘
resolutions field in root enforces consistencyWhen you run yarn build:
package.json script calls assets-manager buildassets-manager to .scripts/assets-manager/build.mjsAssets.json and bundles the runtime dependencieswwwroot folderTo add or update a build tool (e.g., upgrading Parcel, adding a new PostCSS plugin):
.scripts/assets-manager/package.json under dependenciesyarn install from the repository rootpackage.json resolutionsExample:
// .scripts/assets-manager/package.json
{
"dependencies": {
"parcel": "2.14.0" // Update version
}
}
// package.json (root) - optional, for enforcing versions
{
"resolutions": {
"parcel": "2.14.0" // Enforce everywhere
}
}
To add a library that your module needs at runtime (e.g., a Vue component library):
cd src/OrchardCore.Modules/YourModule/Assetsyarn add your-libraryAssets/package.json in that module onlyExample:
cd src/OrchardCore.Modules/OrchardCore.Media/Assets
yarn add [email protected]
The root package.json can include a resolutions field to enforce specific versions:
{
"resolutions": {
"postcss": "8.5.3",
"sass": "^1.85.1",
"glob": "^11.0.1"
}
}
This ensures that even if different modules request different versions, Yarn will use the version specified in resolutions. This prevents version conflicts across the workspace.