Compare commits
18
Commits
@@ -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';
|
||||
+240
@@ -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;
|
||||
});
|
||||
});
|
||||
+205
@@ -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';
|
||||
|
||||
+34
-13
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+319
-93
@@ -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##"",
|
||||
|
||||
Reference in New Issue
Block a user