Compare commits

...
Author SHA1 Message Date
Jacob OvergaardandGitHub b27f253be7 Merge branch 'main' into v17/feature/umb-extension-decorator 2026-04-22 13:47:18 +02:00
kjac 6afc272c98 Refactor and clean up the AppPluginsPackageManifestReader, and ensure correct priority over manifests 2026-04-22 12:44:01 +02:00
Jacob Overgaard 01615023eb Manifest: Merge AppPlugins manifest readers into one
Addresses Kenn's review: the folder-scan reader had an implicit coupling
to the JSON-file reader (skipping packages that already had an
umbraco-package.json) AND walked App_Plugins independently, meaning we
iterated the filesystem twice at startup and would grow that cost with
every new auto-discovery mechanism.

Merges AppPluginsExtensionsFolderPackageManifestReader into
AppPluginsPackageManifestReader. The combined reader walks App_Plugins
once and, per package folder, produces a manifest from each of the two
supported sources independently:

  1. umbraco-package.json (parsed verbatim)
  2. extensions/*.js (synthesized as `bundle` entries for
     `@umbExtension` decorator auto-discovery)

Both may coexist for a single package — they are additive. The synthetic
bundle alias `{package}.Extensions.Bundle.{filename}` doesn't collide
with hand-authored manifest entries, so no deduplication is needed; if a
collision ever happened the extension registry handles it the same way
it does for any other alias clash.

Internal structure keeps the two transformations separate (JSON-parse
vs folder-scan) in private helpers, so the single class still has one
clear responsibility per method.

Tests consolidated into AppPluginsPackageManifestReaderTests (the old
*FolderPackageManifestReaderTests file is removed). The old
"packages-with-json-skip-folder" test is replaced with
`Produces_Both_Manifests_When_Package_Has_Json_And_Extensions_Folder`
asserting the new additive behavior.

Manually verified end-to-end on localhost:44339 — both sources load,
including a synthesized bundle from a live extensions/ folder.
2026-04-20 14:39:12 +02:00
Jacob Overgaard e392d8162e Merge remote-tracking branch 'origin/main' into v17/feature/umb-extension-decorator 2026-04-17 10:53:54 +02:00
Jacob OvergaardandClaude Opus 4.6 2f985904fa Extension API: Fix race condition and unstable aliases (Copilot review)
- Store import Promise (not resolved module) in bundle initializer so
  unloadExtension can await an in-flight import instead of missing it
- Use filename-based aliases instead of index-based (e.g.
  MyPackage.Extensions.Bundle.my-dashboard) for stable identity
  across runs regardless of filesystem enumeration order
- Sort extension files by name for deterministic ordering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:33:50 +02:00
Jacob OvergaardandClaude Opus 4.6 5eadacab65 Extension API: Simplify decorator, reader, and bundle initializer
- Collapse identical standard/legacy decorator branches into one
- Replace bundleIndex counter with bundleExtensions.Count
- Merge CreateDirectoryFileInfo into CreateFileInfoMock in tests
- Cache loaded modules in bundle initializer to avoid re-importing
  on unload (was a pre-existing pattern, now fixed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:15:24 +02:00
Jacob OvergaardandClaude Opus 4.6 52648e6133 Extension API: Revert import path in mock — relative path is correct
The Vite dev server has no importmap, so @umbraco-cms/backoffice/*
paths don't resolve for mock App_Plugins files. The relative path
into the source tree is the correct approach here.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:03:07 +02:00
Jacob OvergaardandClaude Opus 4.6 354372e6c8 Extension API: Fix review findings from inline review
- Fix decorator-dashboard.js import to use importmap path instead of
  relative source tree path (broken outside Vite dev server)
- Swap check order in extensions folder reader: check for extensions/
  folder first (cheap Exists check), then for umbraco-package.json
  (avoids directory listing for packages without extensions/)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:01:35 +02:00
Jacob OvergaardandClaude Opus 4.6 de243cfc6d Extension API: Add vanilla JS decorator dashboard mock
Adds a plain JS dashboard using umbExtension() imperatively with
HTMLElement (no Lit, no TypeScript) to the MSW mock handlers. Proves
the decorator works in the simplest possible setup.

Called imperatively because browsers don't support @ syntax in raw JS
yet — with a build step, you'd write @umbExtension({ ... }).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:49:10 +02:00
Jacob OvergaardandClaude Opus 4.6 df68186fb8 Extension API: Remove redundant extension-registry wrapper
The base decorator now uses the global UmbExtensionManifest type
directly, making the re-export wrapper unnecessary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:37:37 +02:00
Jacob OvergaardandClaude Opus 4.6 16226d4cdf Extension API: Address code review findings
- Skip packages with existing umbraco-package.json in extensions/ reader
  to prevent duplicate manifest name conflicts
- Use global UmbExtensionManifest type in base decorator, removing need
  for the identity wrapper in extension-registry (now a re-export)
- Preserve api field in UmbExtensionDecoratorManifest Omit type since
  explicit api class references are a supported pattern
- Namespace Symbol key to 'umbraco:extension:manifest' for safety
- Document non-recursive scan behavior in XML doc
- Add 6 unregister tests and 3 register return-value tests (19 TS total)
- Add test for umbraco-package.json skip behavior (11 C# total)
- Fix JSDoc accuracy and lint warnings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:27:28 +02:00
Jacob OvergaardandClaude Opus 4.6 19747a77b3 Extension API: Simplify and add tests based on code review
Addresses findings from code reuse, quality, and efficiency reviews:

- Add unregisterExtensionModule() helper, eliminating duplicate
  module scanning in the bundle initializer's unload path
- Make registerExtensionModule() return boolean, removing the
  redundant #hasDecoratedExports guard (single-pass instead of double)
- Extract collectDecoratedClasses() to share between register/unregister
- Remove IUmbracoVersion from extensions folder reader — stamping
  Umbraco's version as a third-party package version was semantically wrong
- Fix test paths to use WebPath.Combine matching production code
- Add 12 unit tests for the decorator and registerExtensionModule

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:59:57 +02:00
Jacob OvergaardandClaude Opus 4.6 6975c48174 Extension API: Simplify registerExtensionModule to single inference rule
Replaces the branching single/multi-extension logic with one rule:
HTMLElement subclass → element, anything else → api. Explicit manifest
references are preserved. Removes export name scanning entirely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:52:24 +02:00
Jacob OvergaardandClaude Opus 4.6 c8be7acbd6 Extension API: Infer element vs api from class type in decorator
When registering decorated classes, infer whether to set element or api
based on the class type: HTMLElement subclasses become element, everything
else becomes api. This supports the common pattern where entity actions
use kind:'default' for the UI and only provide an api class.

Also respects explicit element/api references in the decorator manifest
(e.g. { api: MyApiClass }) for pairing classes in multi-extension bundles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:48:07 +02:00
Jacob OvergaardandClaude Opus 4.6 9d5095b5e5 Extension API: Support multiple decorated extensions per module
Updates registerExtensionModule to handle bundled files containing
multiple @umbExtension-decorated classes. Each decorated class is
registered as its own element by default. Classes explicitly exported
as 'api' are registered as api instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:35:29 +02:00
Jacob OvergaardandClaude Opus 4.6 9917e286ca Extension API: Add tests for extensions/ folder manifest reader
10 unit tests covering the AppPluginsExtensionsFolderPackageManifestReader:
- Single and multiple extension discovery
- Multiple packages
- Skipping packages without extensions/ folder
- Filtering non-JS files and subdirectories
- Empty folder and empty App_Plugins handling
- Root-level file filtering
- Correct JS path generation with cache buster

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:16:18 +02:00
Jacob OvergaardandClaude Opus 4.6 e58fb5994e Extension API: Add extensions/ folder auto-discovery for App_Plugins
Adds an IPackageManifestReader that scans App_Plugins/{Package}/extensions/
for JS files and synthesizes bundle manifests automatically. Combined with
the @umbExtension decorator, this enables a no-manifest workflow where
developers just compile their decorated TS files to the extensions/ folder.

- Scans one level deep in each App_Plugins package for extensions/*.js
- Generates bundle entries that the existing bundle initializer loads
- Appends ?v=%CACHE_BUSTER% for cache busting
- Sets version from IUmbracoVersion
- Additive: coexists with existing umbraco-package.json manifests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:52:53 +02:00
Jacob OvergaardandClaude Opus 4.6 2dd768cf6d Extension API: Add @umbExtension class decorator for manifest registration
Introduces a decorator-based approach to extension registration. The
@umbExtension decorator tags classes with manifest metadata, and the
bundle initializer auto-detects decorated modules, eliminating manual
manifest wiring.

- @umbExtension decorator stores manifest metadata on the class via Symbol
- getExtensionManifest() reads metadata from decorated classes
- registerExtensionModule() resolves module exports (default→element, as api→api)
- Bundle initializer detects @umbExtension metadata and registers automatically
- Typed wrapper in extension-registry uses UmbExtensionManifest union
- Supports both TC39 Stage 3 and legacy TS experimental decorators

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:17:30 +02:00
10 changed files with 951 additions and 157 deletions
@@ -1,7 +1,11 @@
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Manifest;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.Manifest;
@@ -10,6 +14,9 @@ namespace Umbraco.Cms.Infrastructure.Manifest;
/// </summary>
internal sealed class AppPluginsPackageManifestReader : PackageManifestReader
{
private const string ExtensionsFolderName = "extensions";
private const string JsExtension = ".js";
/// <summary>
/// Initializes a new instance of the <see cref="Umbraco.Cms.Infrastructure.Manifest.AppPluginsPackageManifestReader"/> class.
/// </summary>
@@ -27,4 +34,47 @@ internal sealed class AppPluginsPackageManifestReader : PackageManifestReader
logger)
{
}
}
protected override Task<PackageManifest?> ParsePackageManifestFromDirectoryAsync(IFileProvider fileProvider, IFileInfo directory, IFileInfo[] directoryContents)
{
IFileInfo? extensionDirectory = directoryContents
.FirstOrDefault(f => f.IsDirectory && f.Name.InvariantEquals(ExtensionsFolderName));
if (extensionDirectory is null)
{
return Task.FromResult<PackageManifest?>(null);
}
var packageName = directory.Name;
var extensionsPath = WebPath.Combine(
Constants.SystemDirectories.AppPlugins,
packageName,
extensionDirectory.Name);
var bundles = fileProvider
.GetDirectoryContents(extensionsPath)
.Where(IsJsFile)
.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase)
.Select(f => CreateBundleExtension(packageName, extensionsPath, f))
.ToArray();
return Task.FromResult(
bundles.Length > 0
? new PackageManifest { Name = packageName, Extensions = bundles }
: null);
}
private static bool IsJsFile(IFileInfo file) =>
!file.IsDirectory && file.Name.EndsWith(JsExtension, StringComparison.OrdinalIgnoreCase);
private static object CreateBundleExtension(string packageName, string extensionsPath, IFileInfo file)
{
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file.Name);
return new
{
type = "bundle",
alias = $"{packageName}.Extensions.Bundle.{fileNameWithoutExtension}",
name = $"{packageName} Extensions Bundle ({fileNameWithoutExtension})",
js = WebPath.Combine(extensionsPath, file.Name) + "?v=%CACHE_BUSTER%",
};
}}
@@ -45,80 +45,83 @@ internal class PackageManifestReader : IPackageManifestReader
/// <exception cref="System.ArgumentNullException">Thrown if the file provider cannot be created.</exception>
public async Task<IEnumerable<PackageManifest>> ReadPackageManifestsAsync()
{
const string extensionFileName = "umbraco-package.json";
IFileProvider? fileProvider = _packageManifestFileProviderFactory.Create();
if (fileProvider is null)
{
throw new ArgumentNullException(nameof(fileProvider));
}
IFileInfo[] files = GetAllPackageManifestFiles(fileProvider, _appPluginsPath).ToArray();
return await ParsePackageManifestFiles(files);
}
var packageManifests = new List<PackageManifest>();
private static IEnumerable<IFileInfo> GetAllPackageManifestFiles(IFileProvider fileProvider, string path)
{
const string extensionFileName = "umbraco-package.json";
foreach (IFileInfo fileInfo in fileProvider.GetDirectoryContents(path))
foreach (IFileInfo fileInfo in fileProvider.GetDirectoryContents(_appPluginsPath))
{
PackageManifest? packageManifest = null;
if (fileInfo.IsDirectory)
{
// find all extension package configuration files one level deep
var virtualPath = WebPath.Combine(path, fileInfo.Name);
IDirectoryContents subDirectoryContents = fileProvider.GetDirectoryContents(virtualPath);
var virtualPath = WebPath.Combine(_appPluginsPath, fileInfo.Name);
IFileInfo[] subDirectoryContents = fileProvider.GetDirectoryContents(virtualPath).ToArray();
IFileInfo? subManifest = subDirectoryContents
.FirstOrDefault(x => !x.IsDirectory && x.Name.InvariantEquals(extensionFileName));
if (subManifest != null)
if (subManifest is not null)
{
yield return subManifest;
// default package manifests take precedence over other manifests per folder
packageManifest = await ParsePackageManifestAsync(subManifest);
}
else
{
// let the concrete reader attempt to parse different manifests (e.g. bundles)
packageManifest = await ParsePackageManifestFromDirectoryAsync(fileProvider, fileInfo, subDirectoryContents);
}
}
else if (fileInfo.Name.InvariantEquals(extensionFileName))
{
yield return fileInfo;
}
}
}
private async Task<IEnumerable<PackageManifest>> ParsePackageManifestFiles(IFileInfo[] files)
{
var packageManifests = new List<PackageManifest>();
foreach (IFileInfo fileInfo in files)
{
string fileContent;
await using (Stream stream = fileInfo.CreateReadStream())
{
using (var reader = new StreamReader(stream, Encoding.UTF8))
{
fileContent = await reader.ReadToEndAsync();
}
packageManifest = await ParsePackageManifestAsync(fileInfo);
}
if (fileContent.IsNullOrWhiteSpace())
if (packageManifest is not null)
{
continue;
}
try
{
PackageManifest? packageManifest = _jsonSerializer.Deserialize<PackageManifest>(fileContent);
if (packageManifest != null)
{
packageManifests.Add(packageManifest);
}
}
catch (JsonException ex)
{
throw new InvalidOperationException(
$"The package manifest file {fileInfo.PhysicalPath} could not be parsed as it does not contain valid JSON. Please see the inner exception for details.", ex);
}
catch (Exception ex)
{
throw new InvalidOperationException(
$"The package manifest file {fileInfo.PhysicalPath} could not be parsed due to an unexpected error. Please see the inner exception for details.", ex);
packageManifests.Add(packageManifest);
}
}
return packageManifests;
}
protected virtual Task<PackageManifest?> ParsePackageManifestFromDirectoryAsync(IFileProvider fileProvider, IFileInfo directory, IFileInfo[] directoryContents)
=> Task.FromResult<PackageManifest?>(null);
private async Task<PackageManifest?> ParsePackageManifestAsync(IFileInfo fileInfo)
{
var fileContent = await ReadFileContent(fileInfo);
if (fileContent.IsNullOrWhiteSpace())
{
return null;
}
try
{
return _jsonSerializer.Deserialize<PackageManifest>(fileContent);
}
catch (JsonException ex)
{
throw new InvalidOperationException(
$"The package manifest file {fileInfo.PhysicalPath} could not be parsed as it does not contain valid JSON. Please see the inner exception for details.", ex);
}
catch (Exception ex)
{
throw new InvalidOperationException(
$"The package manifest file {fileInfo.PhysicalPath} could not be parsed due to an unexpected error. Please see the inner exception for details.", ex);
}
}
private static async Task<string> ReadFileContent(IFileInfo fileInfo)
{
await using Stream stream = fileInfo.CreateReadStream();
using var reader = new StreamReader(stream, Encoding.UTF8);
return await reader.ReadToEndAsync();
}
}
@@ -101,6 +101,17 @@ const privateManifests: UmbPackageManifestResponse = [
},
],
},
{
name: 'Decorator Test',
extensions: [
{
type: 'bundle',
alias: 'My.DecoratorTest.Bundle',
name: 'Decorator Test Bundle',
js: '/App_Plugins/decorator-dashboard.js',
},
],
},
];
const publicManifests: UmbPackageManifestResponse = [
@@ -0,0 +1,30 @@
import { umbExtension } from '../../src/libs/extension-api/decorators/umb-extension.decorator.js';
class MyDecoratorDashboardElement extends HTMLElement {
connectedCallback() {
this.innerHTML = '<h1>Decorator Dashboard</h1><p>Registered via umbExtension() — vanilla JS, no build step.</p>';
}
}
customElements.define('my-decorator-dashboard', MyDecoratorDashboardElement);
// Called imperatively because browsers don't support the @decorator syntax in raw JS yet.
// With a build step (TypeScript/Vite), you'd write: @umbExtension({ ... }) class MyElement { }
umbExtension({
type: 'dashboard',
alias: 'My.Dashboard.Decorator',
name: 'Decorator Dashboard',
weight: 50,
meta: {
label: 'Decorator (Vanilla JS)',
pathname: 'decorator-vanilla',
},
conditions: [
{
alias: 'Umb.Condition.SectionAlias',
match: 'Umb.Section.Content',
},
],
})(MyDecoratorDashboardElement);
export default MyDecoratorDashboardElement;
@@ -0,0 +1,7 @@
export {
umbExtension,
getExtensionManifest,
registerExtensionModule,
unregisterExtensionModule,
} from './umb-extension.decorator.js';
export type { UmbExtensionDecoratorManifest } from './umb-extension.decorator.js';
@@ -0,0 +1,240 @@
import { expect } from '@open-wc/testing';
import {
umbExtension,
getExtensionManifest,
registerExtensionModule,
unregisterExtensionModule,
} from './umb-extension.decorator.js';
import type { ManifestBase } from '../types/index.js';
describe('umbExtension decorator', () => {
it('stores manifest metadata on the decorated class', () => {
const manifest = { type: 'dashboard', alias: 'Test.Dashboard', name: 'Test' };
@umbExtension(manifest)
class TestDashboard {}
const stored = getExtensionManifest(TestDashboard);
expect(stored).to.deep.equal(manifest);
});
it('returns undefined for undecorated classes', () => {
class PlainClass {}
expect(getExtensionManifest(PlainClass)).to.be.undefined;
});
it('returns the decorated class from the decorator', () => {
const manifest = { type: 'dashboard', alias: 'Test.Dashboard', name: 'Test' };
@umbExtension(manifest)
class TestDashboard {}
expect(TestDashboard).to.be.a('function');
expect(getExtensionManifest(TestDashboard)).to.not.be.undefined;
});
it('preserves additional manifest properties', () => {
const manifest = {
type: 'entityAction',
alias: 'Test.Action',
name: 'Test',
forEntityTypes: ['document'],
meta: { label: 'Do it', icon: 'icon-wand' },
conditions: [{ alias: 'Umb.Condition.SectionAlias', match: 'Umb.Section.Content' }],
};
@umbExtension(manifest)
class TestAction {}
const stored = getExtensionManifest(TestAction);
expect(stored).to.deep.equal(manifest);
});
});
describe('registerExtensionModule', () => {
let registry: { register: (manifest: ManifestBase) => void; registered: ManifestBase[] };
beforeEach(() => {
registry = {
registered: [],
register(manifest: ManifestBase) {
this.registered.push(manifest);
},
};
});
it('registers an HTMLElement subclass as element', () => {
@umbExtension({ type: 'dashboard', alias: 'Test.Dashboard', name: 'Test' })
class TestDashboard extends HTMLElement {}
registerExtensionModule({ TestDashboard }, registry);
expect(registry.registered).to.have.length(1);
expect(registry.registered[0]).to.have.property('element', TestDashboard);
expect(registry.registered[0]).to.not.have.property('api');
});
it('registers a non-HTMLElement class as api', () => {
@umbExtension({ type: 'entityAction', alias: 'Test.Action', name: 'Test' })
class TestAction {
execute() {}
}
registerExtensionModule({ TestAction }, registry);
expect(registry.registered).to.have.length(1);
expect(registry.registered[0]).to.have.property('api', TestAction);
expect(registry.registered[0]).to.not.have.property('element');
});
it('preserves explicit api reference from manifest', () => {
class MyApi {
execute() {}
}
// Using 'as any' because ManifestBase doesn't have 'api' — specific types like ManifestElementAndApi do
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@umbExtension({ type: 'entityAction', alias: 'Test.Action', name: 'Test', api: MyApi } as any)
class TestElement extends HTMLElement {}
registerExtensionModule({ TestElement }, registry);
expect(registry.registered).to.have.length(1);
expect(registry.registered[0]).to.have.property('element', TestElement);
expect(registry.registered[0]).to.have.property('api', MyApi);
});
it('registers multiple decorated classes from one module', () => {
@umbExtension({ type: 'dashboard', alias: 'Test.DashA', name: 'Dashboard A' })
class DashboardA extends HTMLElement {}
@umbExtension({ type: 'dashboard', alias: 'Test.DashB', name: 'Dashboard B' })
class DashboardB extends HTMLElement {}
registerExtensionModule({ DashboardA, DashboardB }, registry);
expect(registry.registered).to.have.length(2);
expect(registry.registered[0]).to.have.property('alias', 'Test.DashA');
expect(registry.registered[0]).to.have.property('element', DashboardA);
expect(registry.registered[1]).to.have.property('alias', 'Test.DashB');
expect(registry.registered[1]).to.have.property('element', DashboardB);
});
it('registers mixed element and api classes from one module', () => {
@umbExtension({ type: 'dashboard', alias: 'Test.Dashboard', name: 'Dashboard' })
class MyDashboard extends HTMLElement {}
@umbExtension({ type: 'entityAction', alias: 'Test.Action', name: 'Action' })
class MyAction {
execute() {}
}
registerExtensionModule({ MyDashboard, MyAction }, registry);
expect(registry.registered).to.have.length(2);
const dashboard = registry.registered.find((m: any) => m.alias === 'Test.Dashboard')!;
const action = registry.registered.find((m: any) => m.alias === 'Test.Action')!;
expect(dashboard).to.have.property('element', MyDashboard);
expect(action).to.have.property('api', MyAction);
});
it('skips undecorated exports', () => {
@umbExtension({ type: 'dashboard', alias: 'Test.Dashboard', name: 'Test' })
class TestDashboard extends HTMLElement {}
class NotDecorated extends HTMLElement {}
registerExtensionModule({ TestDashboard, NotDecorated }, registry);
expect(registry.registered).to.have.length(1);
expect(registry.registered[0]).to.have.property('alias', 'Test.Dashboard');
});
it('returns true when decorated classes are found', () => {
@umbExtension({ type: 'dashboard', alias: 'Test.Dashboard', name: 'Test' })
class TestDashboard extends HTMLElement {}
const result = registerExtensionModule({ TestDashboard }, registry);
expect(result).to.be.true;
});
it('returns false when no decorated classes are found', () => {
class PlainClass {}
const result = registerExtensionModule({ PlainClass }, registry);
expect(result).to.be.false;
});
it('returns false for empty module exports', () => {
const result = registerExtensionModule({}, registry);
expect(result).to.be.false;
});
});
describe('unregisterExtensionModule', () => {
let unregistered: string[];
let unregistry: { unregister: (alias: string) => void };
beforeEach(() => {
unregistered = [];
unregistry = {
unregister(alias: string) {
unregistered.push(alias);
},
};
});
it('unregisters decorated classes by alias', () => {
@umbExtension({ type: 'dashboard', alias: 'Test.Dashboard', name: 'Test' })
class TestDashboard extends HTMLElement {}
unregisterExtensionModule({ TestDashboard }, unregistry);
expect(unregistered).to.have.length(1);
expect(unregistered[0]).to.equal('Test.Dashboard');
});
it('unregisters multiple decorated classes', () => {
@umbExtension({ type: 'dashboard', alias: 'Test.DashA', name: 'A' })
class DashA extends HTMLElement {}
@umbExtension({ type: 'dashboard', alias: 'Test.DashB', name: 'B' })
class DashB extends HTMLElement {}
unregisterExtensionModule({ DashA, DashB }, unregistry);
expect(unregistered).to.have.length(2);
expect(unregistered).to.include('Test.DashA');
expect(unregistered).to.include('Test.DashB');
});
it('skips undecorated exports', () => {
@umbExtension({ type: 'dashboard', alias: 'Test.Dashboard', name: 'Test' })
class Decorated extends HTMLElement {}
class NotDecorated extends HTMLElement {}
unregisterExtensionModule({ Decorated, NotDecorated }, unregistry);
expect(unregistered).to.have.length(1);
expect(unregistered[0]).to.equal('Test.Dashboard');
});
it('returns true when decorated classes are found', () => {
@umbExtension({ type: 'dashboard', alias: 'Test.Dashboard', name: 'Test' })
class TestDashboard extends HTMLElement {}
const result = unregisterExtensionModule({ TestDashboard }, unregistry);
expect(result).to.be.true;
});
it('returns false when no decorated classes are found', () => {
class PlainClass {}
const result = unregisterExtensionModule({ PlainClass }, unregistry);
expect(result).to.be.false;
});
it('returns false for empty module exports', () => {
const result = unregisterExtensionModule({}, unregistry);
expect(result).to.be.false;
});
});
@@ -0,0 +1,205 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { ManifestBase } from '../types/index.js';
/**
* Static property name used to store manifest metadata on decorated classes.
*/
const UMB_EXTENSION_MANIFEST = Symbol.for('umbraco:extension:manifest');
/**
* The manifest metadata accepted by the `@umbExtension` decorator.
*
* Omits loader properties (element, js, elementName) and phantom types (ELEMENT_TYPE, API_TYPE)
* since these are resolved automatically. The `api` field is preserved — it can be set to a
* class reference to pair an API class with the decorated element class.
*
* The generic parameter allows using specific manifest types (e.g. `ManifestDashboard`,
* `ManifestEntityAction`) for full type-safety.
*/
export type UmbExtensionDecoratorManifest<T extends UmbExtensionManifest = UmbExtensionManifest> = Omit<
T,
'element' | 'elementName' | 'js' | 'ELEMENT_TYPE' | 'API_TYPE'
>;
/**
* A class decorator that stores extension manifest metadata on the class.
*
* The decorator itself has no runtime side-effects — it only tags the class
* with manifest data. Registration happens separately via the bundle initializer
* (automatic) or by calling {@link registerExtensionModule} manually.
*
* When registered, the decorated class is assigned to the manifest based on its type:
* - `HTMLElement` subclass → `element`
* - Anything else → `api`
*
* To pair an element with a separate API class, pass `api` in the manifest:
* `@umbExtension({ ..., api: MyApiClass })`
*
* Supports both modern "standard" decorators (Stage 3 TC39 proposal) and
* legacy TypeScript experimental decorators for backward compatibility.
* @param {UmbExtensionDecoratorManifest} manifest - The extension manifest metadata.
* @returns {UmbExtensionClassDecorator} A class decorator function.
* @example
* ```ts
* // Dashboard — HTMLElement subclass, auto-assigned as element
* @umbExtension({
* type: 'dashboard',
* alias: 'My.Dashboard',
* name: 'My Dashboard',
* meta: { label: 'My Dashboard', pathname: 'my-dashboard' },
* })
* @customElement('my-dashboard')
* export default class MyDashboardElement extends UmbLitElement {
* render() { return html`<h1>Hello</h1>`; }
* }
* ```
* @example
* ```ts
* // Entity action with kind — non-HTMLElement, auto-assigned as api
* @umbExtension({
* type: 'entityAction',
* kind: 'default',
* alias: 'My.Action',
* name: 'My Action',
* forEntityTypes: ['document'],
* meta: { label: 'Do it', icon: 'icon-wand' },
* })
* export class MyActionApi implements UmbApi {
* async execute() { ... }
* }
* ```
*/
export function umbExtension<T extends UmbExtensionManifest = UmbExtensionManifest>(
manifest: UmbExtensionDecoratorManifest<T>,
) {
// Both standard (TC39 Stage 3) and legacy (TS experimental) decorators receive the
// class as the first argument. The runtime behavior is identical — only the type
// signatures differ, which is handled by UmbExtensionClassDecorator.
return ((targetOrValue: any) => {
targetOrValue[UMB_EXTENSION_MANIFEST] = manifest;
return targetOrValue;
}) as UmbExtensionClassDecorator;
}
/**
* Retrieves the extension manifest metadata stored on a class by the `@umbExtension` decorator.
* @param {object} target - The class to read metadata from.
* @returns {UmbExtensionDecoratorManifest | undefined} The manifest metadata, or undefined if not decorated.
*/
export function getExtensionManifest(target: any): UmbExtensionDecoratorManifest | undefined {
return target?.[UMB_EXTENSION_MANIFEST];
}
/**
* Registers extensions from a module's exports with the provided extension registry.
*
* Scans the module for classes decorated with `@umbExtension` and registers each one.
* The decorated class is automatically assigned to the manifest based on its type:
* - `HTMLElement` subclass → `element`
* - Anything else → `api`
*
* Explicit `element` or `api` references in the decorator manifest are preserved.
* This allows pairing classes: `@umbExtension({ ..., api: MyApiClass })`
*
* The manifest metadata is read from the decorated class via {@link getExtensionManifest}.
* Only classes with `@umbExtension` metadata are registered.
* @param {object} moduleExports - The module's exports (e.g. from `import * as mod from './my-ext.js'`).
* @param {object} registry - The UmbExtensionRegistry instance.
* @param {(manifest: ManifestBase) => void} registry.register - The register method on the extension registry.
* @returns {boolean} True if any decorated classes were found and registered.
* @example
* ```ts
* // In a backofficeEntryPoint onInit:
* import * as myDashboard from './my-dashboard.js';
*
* export const onInit: UmbEntryPointOnInit = (_host, extensionRegistry) => {
* registerExtensionModule(myDashboard, extensionRegistry);
* };
* ```
*/
export function registerExtensionModule(
moduleExports: Record<string, any>,
registry: { register(manifest: ManifestBase): void },
): boolean {
const decoratedClasses = collectDecoratedClasses(moduleExports);
if (decoratedClasses.size === 0) {
return false;
}
for (const [targetClass, manifest] of decoratedClasses) {
const fullManifest: any = { ...manifest };
if (!fullManifest.element && isHTMLElement(targetClass)) {
fullManifest.element = targetClass;
}
if (!fullManifest.api && !isHTMLElement(targetClass)) {
fullManifest.api = targetClass;
}
registry.register(fullManifest);
}
return true;
}
/**
* Unregisters extensions from a module's exports that were previously registered
* via {@link registerExtensionModule}.
* @param {object} moduleExports - The module's exports.
* @param {object} registry - The UmbExtensionRegistry instance.
* @param {(alias: string) => void} registry.unregister - The unregister method on the extension registry.
* @returns {boolean} True if any decorated classes were found and unregistered.
*/
export function unregisterExtensionModule(
moduleExports: Record<string, any>,
registry: { unregister(alias: string): void },
): boolean {
const decoratedClasses = collectDecoratedClasses(moduleExports);
if (decoratedClasses.size === 0) {
return false;
}
for (const [, manifest] of decoratedClasses) {
registry.unregister(manifest.alias);
}
return true;
}
/**
* Collects all decorated classes and their manifest metadata from module exports.
* @param {object} moduleExports - The module's exports.
* @returns {Map<object, UmbExtensionDecoratorManifest>} Map of class → manifest metadata.
*/
function collectDecoratedClasses(moduleExports: Record<string, any>): Map<any, UmbExtensionDecoratorManifest> {
const decoratedClasses = new Map<any, UmbExtensionDecoratorManifest>();
for (const value of Object.values(moduleExports)) {
const manifest = getExtensionManifest(value);
if (manifest) {
decoratedClasses.set(value, manifest);
}
}
return decoratedClasses;
}
/**
* Checks whether a class constructor extends HTMLElement.
* @param {object} target - The class constructor to check.
* @returns {boolean} True if the class extends HTMLElement.
*/
function isHTMLElement(target: any): boolean {
return typeof HTMLElement !== 'undefined' && target.prototype instanceof HTMLElement;
}
/**
* Type for the `@umbExtension` class decorator supporting both standard and legacy forms.
*/
type UmbExtensionClassDecorator = {
// Standard decorator (Stage 3 TC39)
<T extends abstract new (...args: any[]) => any>(value: T, context: ClassDecoratorContext<T>): T | void;
// Legacy TypeScript experimental decorator
<T extends abstract new (...args: any[]) => any>(target: T): T | void;
};
@@ -1,5 +1,6 @@
export type * from './condition/index.js';
export * from './controller/index.js';
export * from './decorators/index.js';
export * from './functions/index.js';
export * from './initializers/index.js';
export * from './registry/extension.registry.js';
@@ -1,22 +1,38 @@
import type { ManifestBase, ManifestBundle } from '../types/index.js';
import type { UmbExtensionRegistry } from '../registry/extension.registry.js';
import { loadManifestPlainJs } from '../functions/load-manifest-plain-js.function.js';
import { registerExtensionModule, unregisterExtensionModule } from '../decorators/index.js';
import { UmbExtensionInitializerBase } from './extension-initializer-base.js';
import type { UmbElement } from '@umbraco-cms/backoffice/element-api';
/**
* Extension initializer for the `bundle` extension type
* Extension initializer for the `bundle` extension type.
*
* Handles two module formats:
* 1. **Classic bundles:** Exports manifest arrays/objects directly.
* 2. **Decorator bundles:** Exports classes decorated with `@umbExtension`.
* These are detected automatically and registered via {@link registerExtensionModule}.
*/
export class UmbBundleExtensionInitializer extends UmbExtensionInitializerBase<'bundle', ManifestBundle> {
// Stores the import promise so unloadExtension can await it even if instantiate is still in flight.
#loadingModules = new Map<string, Promise<Record<string, unknown> | undefined>>();
constructor(host: UmbElement, extensionRegistry: UmbExtensionRegistry<ManifestBundle>) {
super(host, extensionRegistry, 'bundle');
}
async instantiateExtension(manifest: ManifestBundle): Promise<void> {
if (manifest.js) {
const js = await loadManifestPlainJs(manifest.js);
const jsPromise = loadManifestPlainJs(manifest.js);
this.#loadingModules.set(manifest.alias, jsPromise);
const js = await jsPromise;
if (js) {
if (registerExtensionModule(js, this.extensionRegistry)) {
return;
}
Object.keys(js).forEach((key) => {
const value = js[key];
@@ -31,20 +47,25 @@ export class UmbBundleExtensionInitializer extends UmbExtensionInitializerBase<'
}
async unloadExtension(manifest: ManifestBundle): Promise<void> {
if (manifest.js) {
const js = await loadManifestPlainJs(manifest.js);
const jsPromise = this.#loadingModules.get(manifest.alias);
this.#loadingModules.delete(manifest.alias);
if (js) {
Object.keys(js).forEach((key) => {
const value = js[key];
const js = await jsPromise;
if (Array.isArray(value)) {
this.extensionRegistry.unregisterMany(value.map((v) => v.alias));
} else if (typeof value === 'object') {
this.extensionRegistry.unregister((value as ManifestBase).alias);
}
});
if (js) {
if (unregisterExtensionModule(js, this.extensionRegistry)) {
return;
}
Object.keys(js).forEach((key) => {
const value = js[key];
if (Array.isArray(value)) {
this.extensionRegistry.unregisterMany(value.map((v) => v.alias));
} else if (typeof value === 'object') {
this.extensionRegistry.unregister((value as ManifestBase).alias);
}
});
}
}
}
@@ -7,6 +7,7 @@ using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Infrastructure.Manifest;
using Umbraco.Cms.Infrastructure.Serialization;
@@ -32,19 +33,39 @@ public class PackageManifestReaderTests
fileProviderFactoryMock.Setup(m => m.Create()).Returns(_fileProviderMock.Object);
_loggerMock = new Mock<ILogger<AppPluginsPackageManifestReader>>();
_reader = new AppPluginsPackageManifestReader(fileProviderFactoryMock.Object, new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()), _loggerMock.Object);
_reader = new AppPluginsPackageManifestReader(
fileProviderFactoryMock.Object,
new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()),
_loggerMock.Object);
}
// ---------------------------------------------------------------------
// umbraco-package.json source
// ---------------------------------------------------------------------
[Test]
public async Task Can_Read_PackageManifest_In_Root_Directories()
{
var directoryOne = CreatePackageFolderWithManifest("my-extension", DefaultPackageManifestContent("Package One"));
var directoryTwo = CreatePackageFolderWithManifest("my-other-extension", DefaultPackageManifestContent("Package Two"));
SetRootContents(directoryOne, directoryTwo);
var result = (await _reader.ReadPackageManifestsAsync()).ToList();
Assert.AreEqual(2, result.Count);
Assert.AreEqual("Package One", result[0].Name);
Assert.AreEqual("Package Two", result[1].Name);
}
[Test]
public async Task Can_Read_PackageManifests_At_Root()
public async Task Can_Read_Importmap_From_Manifest()
{
_rootDirectoryContentsMock
.Setup(f => f.GetEnumerator())
.Returns(new List<IFileInfo> { CreatePackageManifestFile() }.GetEnumerator());
var packageFolder = CreatePackageFolderWithManifest("my-extension", DefaultPackageManifestContent());
SetRootContents(packageFolder);
var result = await _reader.ReadPackageManifestsAsync();
Assert.AreEqual(1, result.Count());
var result = (await _reader.ReadPackageManifestsAsync()).ToList();
Assert.AreEqual(1, result.Count);
var first = result.First();
Assert.AreEqual("My Package", first.Name);
Assert.AreEqual("1.2.3", first.Version);
@@ -84,89 +105,64 @@ public class PackageManifestReaderTests
}
]
}";
_rootDirectoryContentsMock
.Setup(f => f.GetEnumerator())
.Returns(new List<IFileInfo> { CreatePackageManifestFile(content) }.GetEnumerator());
var packageFolder = CreatePackageFolderWithManifest("my-extension", content);
SetRootContents(packageFolder);
var result = await _reader.ReadPackageManifestsAsync();
Assert.AreEqual(1, result.Count());
var result = (await _reader.ReadPackageManifestsAsync()).ToList();
Assert.AreEqual(1, result.Count);
var first = result.First();
// Ensure that the extensions are deserialized as JsonElement
Assert.IsTrue(first.Extensions.All(e => e is JsonObject));
// Test the deserialization of the first extension to make sure we don't break the JSON parsing
JsonObject firstExtension = (JsonObject)first.Extensions.First();
Assert.AreEqual("tree", firstExtension["type"].GetValue<string>());
var meta = firstExtension["meta"];
Assert.AreEqual("My Tree", meta["label"].GetValue<string>());
var someArray = meta["someArray"];
Assert.AreEqual(1, someArray[0].GetValue<int>());
}
[Test]
public async Task Can_Read_PackageManifest_In_Root_Directories()
{
var directoryOne = CreateDirectoryMock("/my-extension", CreatePackageManifestFile(DefaultPackageManifestContent("Package One")));
var directoryTwo = CreateDirectoryMock("/my-other-extension", CreatePackageManifestFile(DefaultPackageManifestContent("Package Two")));
_rootDirectoryContentsMock
.Setup(f => f.GetEnumerator())
.Returns(new List<IFileInfo> { directoryOne, directoryTwo }.GetEnumerator());
var result = await _reader.ReadPackageManifestsAsync();
Assert.AreEqual(2, result.Count());
Assert.AreEqual("Package One", result.First().Name);
Assert.AreEqual("Package Two", result.Last().Name);
Assert.AreEqual("tree", firstExtension["type"].GetValue<string>());
var meta = firstExtension["meta"];
Assert.AreEqual("My Tree", meta["label"].GetValue<string>());
var someArray = meta["someArray"];
Assert.AreEqual(1, someArray[0].GetValue<int>());
}
[Test]
public async Task Can_Skip_Empty_Directories()
{
var packageFolder = CreateDirectoryMock("/my-package-folder", CreatePackageManifestFile(DefaultPackageManifestContent("My Package")));
var emptyFolder = CreateDirectoryMock("/my-empty-folder");
var packageFolder = CreatePackageFolderWithManifest("my-package-folder", DefaultPackageManifestContent());
var emptyFolder = CreateEmptyPackageFolder("my-empty-folder");
SetRootContents(emptyFolder, packageFolder);
_rootDirectoryContentsMock
.Setup(f => f.GetEnumerator())
.Returns(new List<IFileInfo> { emptyFolder, packageFolder }.GetEnumerator());
var result = (await _reader.ReadPackageManifestsAsync()).ToList();
var result = await _reader.ReadPackageManifestsAsync();
Assert.AreEqual(1, result.Count());
Assert.AreEqual(1, result.Count);
Assert.AreEqual("My Package", result.First().Name);
}
[Test]
public async Task Can_Skip_Other_Files()
public async Task Can_Skip_Non_Package_Json_Files_In_Package_Folder()
{
var packageFolder = CreateDirectoryMock(
"/my-package-folder",
CreateOtherFile("my.js"),
CreatePackageManifestFile(DefaultPackageManifestContent("My Package")));
var otherFolder = CreateDirectoryMock(
"/my-empty-folder",
CreateOtherFile("some.js"),
CreateOtherFile("some.css"));
var packageFolder = CreatePackageFolderWithManifestAndExtras(
"my-package-folder",
DefaultPackageManifestContent(),
CreateFile("my.js"));
var otherFolder = CreateEmptyPackageFolder("my-other-folder", CreateFile("some.js"), CreateFile("some.css"));
SetRootContents(otherFolder, packageFolder);
_rootDirectoryContentsMock
.Setup(f => f.GetEnumerator())
.Returns(new List<IFileInfo> { otherFolder, packageFolder }.GetEnumerator());
var result = (await _reader.ReadPackageManifestsAsync()).ToList();
var result = await _reader.ReadPackageManifestsAsync();
Assert.AreEqual(1, result.Count());
Assert.AreEqual(1, result.Count);
Assert.AreEqual("My Package", result.First().Name);
}
[Test]
public async Task Can_Handle_All_Empty_Directories()
{
var folders = Enumerable.Range(1, 10).Select(i => CreateDirectoryMock($"/my-empty-folder-{i}")).ToList();
var folders = Enumerable.Range(1, 10)
.Select(i => CreateEmptyPackageFolder($"my-empty-folder-{i}"))
.ToArray();
SetRootContents(folders);
_rootDirectoryContentsMock
.Setup(f => f.GetEnumerator())
.Returns(folders.GetEnumerator());
var result = (await _reader.ReadPackageManifestsAsync()).ToList();
var result = await _reader.ReadPackageManifestsAsync();
Assert.AreEqual(0, result.Count());
Assert.AreEqual(0, result.Count);
}
[Test]
@@ -182,9 +178,8 @@ public class PackageManifestReaderTests
}
]
}";
_rootDirectoryContentsMock
.Setup(f => f.GetEnumerator())
.Returns(new List<IFileInfo> { CreatePackageManifestFile(content) }.GetEnumerator());
var packageFolder = CreatePackageFolderWithManifest("my-extension", content);
SetRootContents(packageFolder);
var exception = Assert.ThrowsAsync<InvalidOperationException>(() => _reader.ReadPackageManifestsAsync());
Assert.NotNull(exception);
@@ -199,9 +194,8 @@ public class PackageManifestReaderTests
""version"": ""1.2.3"",
""allowTelemetry"": true
}";
_rootDirectoryContentsMock
.Setup(f => f.GetEnumerator())
.Returns(new List<IFileInfo> { CreatePackageManifestFile(content) }.GetEnumerator());
var packageFolder = CreatePackageFolderWithManifest("my-extension", content);
SetRootContents(packageFolder);
var exception = Assert.ThrowsAsync<InvalidOperationException>(() => _reader.ReadPackageManifestsAsync());
Assert.NotNull(exception);
@@ -222,9 +216,8 @@ public class PackageManifestReaderTests
}
}
}";
_rootDirectoryContentsMock
.Setup(f => f.GetEnumerator())
.Returns(new List<IFileInfo> { CreatePackageManifestFile(content) }.GetEnumerator());
var packageFolder = CreatePackageFolderWithManifest("my-extension", content);
SetRootContents(packageFolder);
var exception = Assert.ThrowsAsync<InvalidOperationException>(() => _reader.ReadPackageManifestsAsync());
Assert.NotNull(exception);
@@ -235,55 +228,288 @@ public class PackageManifestReaderTests
[TestCase(@"{""name"": ""invalid-json"", ""version"": ")]
public void Cannot_Read_Invalid_PackageManifest(string content)
{
_rootDirectoryContentsMock
.Setup(f => f.GetEnumerator())
.Returns(new List<IFileInfo> { CreatePackageManifestFile(content) }.GetEnumerator());
var packageFolder = CreatePackageFolderWithManifest("my-extension", content);
SetRootContents(packageFolder);
var exception = Assert.ThrowsAsync<InvalidOperationException>(() => _reader.ReadPackageManifestsAsync());
Assert.NotNull(exception);
Assert.IsInstanceOf<JsonException>(exception.InnerException);
}
private IFileInfo CreateDirectoryMock(string path, params IFileInfo[] children)
// ---------------------------------------------------------------------
// extensions/ folder source
// ---------------------------------------------------------------------
[Test]
public async Task Can_Discover_Extensions_In_Extensions_Folder()
{
var directoryContentsMock = new Mock<IDirectoryContents>();
directoryContentsMock
.Setup(f => f.GetEnumerator())
.Returns(children.ToList().GetEnumerator());
var packageFolder = CreatePackageFolderWithExtensions("MyPackage", "my-dashboard.js");
SetRootContents(packageFolder);
_fileProviderMock
.Setup(m => m.GetDirectoryContents($"{Constants.SystemDirectories.AppPlugins}{path}"))
.Returns(directoryContentsMock.Object);
var result = (await _reader.ReadPackageManifestsAsync()).ToList();
Assert.AreEqual(1, result.Count);
var fileInfo = new Mock<IFileInfo>();
fileInfo.SetupGet(f => f.IsDirectory).Returns(true);
fileInfo.SetupGet(f => f.Name).Returns(path.Split(Constants.CharArrays.ForwardSlash).Last());
var manifest = result.First();
Assert.AreEqual("MyPackage", manifest.Name);
Assert.IsNull(manifest.Version);
Assert.AreEqual(1, manifest.Extensions.Length);
return fileInfo.Object;
var extension = manifest.Extensions.First();
Assert.AreEqual("bundle", GetProperty(extension, "type"));
Assert.AreEqual("MyPackage.Extensions.Bundle.my-dashboard", GetProperty(extension, "alias"));
Assert.That(GetProperty(extension, "js")!.ToString(), Does.Contain("/App_Plugins/MyPackage/extensions/my-dashboard.js"));
Assert.That(GetProperty(extension, "js")!.ToString(), Does.Contain("?v=%CACHE_BUSTER%"));
}
private IFileInfo CreatePackageManifestFile(string? content = null)
[Test]
public async Task Can_Discover_Multiple_Extensions_In_One_Package()
{
content ??= DefaultPackageManifestContent();
var packageFolder = CreatePackageFolderWithExtensions("MyPackage", "dashboard.js", "action.js", "editor.js");
SetRootContents(packageFolder);
var result = (await _reader.ReadPackageManifestsAsync()).ToList();
Assert.AreEqual(1, result.Count);
Assert.AreEqual(3, result.First().Extensions.Length);
var aliases = result.First().Extensions
.Select(e => GetProperty(e, "alias")!.ToString())
.ToList();
Assert.That(aliases, Does.Contain("MyPackage.Extensions.Bundle.action"));
Assert.That(aliases, Does.Contain("MyPackage.Extensions.Bundle.dashboard"));
Assert.That(aliases, Does.Contain("MyPackage.Extensions.Bundle.editor"));
}
[Test]
public async Task Can_Discover_Extensions_From_Multiple_Packages()
{
var packageOne = CreatePackageFolderWithExtensions("PackageOne", "dashboard.js");
var packageTwo = CreatePackageFolderWithExtensions("PackageTwo", "editor.js");
SetRootContents(packageOne, packageTwo);
var result = (await _reader.ReadPackageManifestsAsync()).ToList();
Assert.AreEqual(2, result.Count);
Assert.AreEqual("PackageOne", result[0].Name);
Assert.AreEqual("PackageTwo", result[1].Name);
}
[Test]
public async Task Can_Skip_Packages_Without_Extensions_Folder()
{
var packageWithExtensions = CreatePackageFolderWithExtensions("WithExtensions", "dashboard.js");
var packageWithoutExtensions = CreateEmptyPackageFolder("WithoutExtensions");
SetRootContents(packageWithoutExtensions, packageWithExtensions);
var result = (await _reader.ReadPackageManifestsAsync()).ToList();
Assert.AreEqual(1, result.Count);
Assert.AreEqual("WithExtensions", result.First().Name);
}
[Test]
public async Task Can_Skip_Non_Js_Files_In_Extensions_Folder()
{
var packageFolder = CreatePackageFolderWithExtensions(
"MyPackage",
"dashboard.js",
"readme.md",
"styles.css",
"types.d.ts");
SetRootContents(packageFolder);
var result = (await _reader.ReadPackageManifestsAsync()).ToList();
Assert.AreEqual(1, result.Count);
Assert.AreEqual(1, result.First().Extensions.Length);
Assert.That(GetProperty(result.First().Extensions.First(), "js")!.ToString(), Does.Contain("dashboard.js"));
}
[Test]
public async Task Can_Skip_Subdirectories_In_Extensions_Folder()
{
var packageFolder = CreatePackageFolderWithExtensionFiles(
"MyPackage",
CreateDirectory("subfolder"),
CreateFile("dashboard.js"));
SetRootContents(packageFolder);
var result = (await _reader.ReadPackageManifestsAsync()).ToList();
Assert.AreEqual(1, result.Count);
Assert.AreEqual(1, result.First().Extensions.Length);
}
[Test]
public async Task Can_Skip_Empty_Extensions_Folder()
{
var packageFolder = CreatePackageFolderWithExtensions("MyPackage");
SetRootContents(packageFolder);
var result = (await _reader.ReadPackageManifestsAsync()).ToList();
Assert.AreEqual(0, result.Count);
}
[Test]
public async Task Can_Handle_No_Package_Folders()
{
SetRootContents();
var result = (await _reader.ReadPackageManifestsAsync()).ToList();
Assert.AreEqual(0, result.Count);
}
[Test]
public async Task Can_Skip_Root_Level_Files()
{
var rootFile = CreateFile("some-file.js");
var packageFolder = CreatePackageFolderWithExtensions("MyPackage", "dashboard.js");
SetRootContents(rootFile, packageFolder);
var result = (await _reader.ReadPackageManifestsAsync()).ToList();
Assert.AreEqual(1, result.Count);
Assert.AreEqual("MyPackage", result.First().Name);
}
[Test]
public async Task Generates_Correct_Js_Paths()
{
var packageFolder = CreatePackageFolderWithExtensions("My.Package", "my-extension.js");
SetRootContents(packageFolder);
var result = (await _reader.ReadPackageManifestsAsync()).ToList();
Assert.AreEqual(
"/App_Plugins/My.Package/extensions/my-extension.js?v=%CACHE_BUSTER%",
GetProperty(result.First().Extensions.First(), "js")!.ToString());
}
// ---------------------------------------------------------------------
// Both sources together
// ---------------------------------------------------------------------
[Test]
public async Task Produces_Only_PackageManifest_When_Package_Has_Both_PackageManifest_And_Extensions_Folder()
{
// Package has BOTH an umbraco-package.json AND an extensions/ folder.
// The reader is NOT additive — it prioritizes the package manifest over the bundle manifest.
var packageFolder = CreatePackageFolderWithManifestAndExtensions(
"Hybrid",
DefaultPackageManifestContent("Hybrid Package"),
"dashboard.js");
SetRootContents(packageFolder);
var result = (await _reader.ReadPackageManifestsAsync()).ToList();
Assert.AreEqual(1, result.Count);
var manifest = result.Single(m => m.Name == "Hybrid Package");
Assert.AreEqual("1.2.3", manifest.Version);
Assert.AreEqual(2, manifest.Extensions.Length);
}
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
private void SetRootContents(params IFileInfo[] items) =>
_rootDirectoryContentsMock
.Setup(f => f.GetEnumerator())
.Returns(((IEnumerable<IFileInfo>)items).GetEnumerator());
private IFileInfo CreatePackageFolderWithManifest(string packageName, string manifestContent)
{
SetupPackageContents(packageName, CreateManifestFile(manifestContent));
return CreateDirectory(packageName);
}
private IFileInfo CreatePackageFolderWithManifestAndExtras(
string packageName,
string manifestContent,
params IFileInfo[] extras)
{
IFileInfo[] contents = [.. extras, CreateManifestFile(manifestContent)];
SetupPackageContents(packageName, contents);
return CreateDirectory(packageName);
}
private IFileInfo CreatePackageFolderWithExtensions(string packageName, params string[] jsFileNames)
{
IFileInfo[] extensionFiles = jsFileNames.Select(n => CreateFile(n)).ToArray();
return CreatePackageFolderWithExtensionFiles(packageName, extensionFiles);
}
private IFileInfo CreatePackageFolderWithExtensionFiles(string packageName, params IFileInfo[] extensionFiles)
{
SetupPackageContents(packageName, CreateDirectory("extensions"));
SetupExtensionsContents(packageName, extensionFiles);
return CreateDirectory(packageName);
}
private IFileInfo CreatePackageFolderWithManifestAndExtensions(
string packageName,
string manifestContent,
params string[] jsFileNames)
{
SetupPackageContents(packageName, CreateManifestFile(manifestContent), CreateDirectory("extensions"));
SetupExtensionsContents(packageName, jsFileNames.Select(n => CreateFile(n)).ToArray());
return CreateDirectory(packageName);
}
private IFileInfo CreateEmptyPackageFolder(string packageName, params IFileInfo[] contents)
{
SetupPackageContents(packageName, contents);
return CreateDirectory(packageName);
}
private void SetupPackageContents(string packageName, params IFileInfo[] contents)
{
var mock = new Mock<IDirectoryContents>();
mock.Setup(f => f.GetEnumerator()).Returns(((IEnumerable<IFileInfo>)contents).GetEnumerator());
var packagePath = WebPath.Combine(Constants.SystemDirectories.AppPlugins, packageName);
_fileProviderMock.Setup(m => m.GetDirectoryContents(packagePath)).Returns(mock.Object);
}
private void SetupExtensionsContents(string packageName, params IFileInfo[] files)
{
var mock = new Mock<IDirectoryContents>();
mock.SetupGet(d => d.Exists).Returns(true);
mock.Setup(f => f.GetEnumerator()).Returns(((IEnumerable<IFileInfo>)files).GetEnumerator());
var extensionsPath = WebPath.Combine(Constants.SystemDirectories.AppPlugins, packageName, "extensions");
_fileProviderMock.Setup(m => m.GetDirectoryContents(extensionsPath)).Returns(mock.Object);
}
private static IFileInfo CreateManifestFile(string content)
{
var fileInfo = new Mock<IFileInfo>();
fileInfo.SetupGet(f => f.IsDirectory).Returns(false);
fileInfo.SetupGet(f => f.Name).Returns("umbraco-package.json");
fileInfo.Setup(f => f.CreateReadStream()).Returns(new MemoryStream(Encoding.UTF8.GetBytes(content)));
fileInfo.Setup(f => f.CreateReadStream()).Returns(() => new MemoryStream(Encoding.UTF8.GetBytes(content)));
return fileInfo.Object;
}
private IFileInfo CreateOtherFile(string name)
private static IFileInfo CreateFile(string name)
{
var fileInfo = new Mock<IFileInfo>();
fileInfo.SetupGet(f => f.IsDirectory).Returns(false);
fileInfo.SetupGet(f => f.Name).Returns(name);
fileInfo.Setup(f => f.CreateReadStream()).Returns(new MemoryStream(Encoding.UTF8.GetBytes("this is some file content")));
return fileInfo.Object;
}
private static IFileInfo CreateDirectory(string name)
{
var fileInfo = new Mock<IFileInfo>();
fileInfo.SetupGet(f => f.IsDirectory).Returns(true);
fileInfo.SetupGet(f => f.Name).Returns(name);
return fileInfo.Object;
}
private static object? GetProperty(object obj, string name) =>
obj.GetType().GetProperty(name)?.GetValue(obj);
private static string DefaultPackageManifestContent(string name = "My Package")
=> @"{
""name"": ""##NAME##"",