diff --git a/src/packages/core/extension-registry/models/index.ts b/src/packages/core/extension-registry/models/index.ts index adc53de04c..bcec4417ea 100644 --- a/src/packages/core/extension-registry/models/index.ts +++ b/src/packages/core/extension-registry/models/index.ts @@ -46,6 +46,7 @@ import type { ManifestTinyMcePlugin } from './tinymce-plugin.model.js'; import type { ManifestTree } from './tree.model.js'; import type { ManifestTreeItem } from './tree-item.model.js'; import type { ManifestUfmComponent } from './ufm-component.model.js'; +import type { ManifestUfmFilter } from './ufm-filter.model.js'; import type { ManifestUserProfileApp } from './user-profile-app.model.js'; import type { ManifestWorkspace, ManifestWorkspaceRoutableKind } from './workspace.model.js'; import type { ManifestWorkspaceAction, ManifestWorkspaceActionDefaultKind } from './workspace-action.model.js'; @@ -117,6 +118,7 @@ export type * from './tinymce-plugin.model.js'; export type * from './tree-item.model.js'; export type * from './tree.model.js'; export type * from './ufm-component.model.js'; +export type * from './ufm-filter.model.js'; export type * from './user-granular-permission.model.js'; export type * from './user-profile-app.model.js'; export type * from './workspace-action-menu-item.model.js'; @@ -210,6 +212,7 @@ export type ManifestTypes = | ManifestTreeItem | ManifestTreeStore | ManifestUfmComponent + | ManifestUfmFilter | ManifestUserProfileApp | ManifestWorkspaceActionMenuItem | ManifestWorkspaceActions diff --git a/src/packages/core/extension-registry/models/ufm-filter.model.ts b/src/packages/core/extension-registry/models/ufm-filter.model.ts new file mode 100644 index 0000000000..8144601021 --- /dev/null +++ b/src/packages/core/extension-registry/models/ufm-filter.model.ts @@ -0,0 +1,14 @@ +import type { ManifestApi, UmbApi } from '@umbraco-cms/backoffice/extension-api'; + +export interface UmbUfmFilterApi extends UmbApi { + filter(...args: Array): string | undefined | null; +} + +export interface MetaUfmFilter { + alias: string; +} + +export interface ManifestUfmFilter extends ManifestApi { + type: 'ufmFilter'; + meta: MetaUfmFilter; +} diff --git a/src/packages/ufm/components/content-name/content-name.component.ts b/src/packages/ufm/components/content-name/content-name.component.ts new file mode 100644 index 0000000000..dfcd5c6e2b --- /dev/null +++ b/src/packages/ufm/components/content-name/content-name.component.ts @@ -0,0 +1,15 @@ +import type { UfmToken } from '../../plugins/marked-ufm.plugin.js'; +import { UmbUfmComponentBase } from '../ufm-component-base.js'; + +import './content-name.element.js'; + +export class UmbUfmContentNameComponent extends UmbUfmComponentBase { + render(token: UfmToken) { + if (!token.text) return; + + const attributes = super.getAttributes(token.text); + return ``; + } +} + +export { UmbUfmContentNameComponent as api }; diff --git a/src/packages/ufm/components/content-name/content-name.element.ts b/src/packages/ufm/components/content-name/content-name.element.ts new file mode 100644 index 0000000000..4335c2bb28 --- /dev/null +++ b/src/packages/ufm/components/content-name/content-name.element.ts @@ -0,0 +1,74 @@ +import { UmbUfmElementBase } from '../ufm-element-base.js'; +import { UMB_UFM_RENDER_CONTEXT } from '../ufm-render/ufm-render.context.js'; +import { customElement, property } from '@umbraco-cms/backoffice/external/lit'; +import { UmbDocumentItemRepository } from '@umbraco-cms/backoffice/document'; +import { UmbMediaItemRepository } from '@umbraco-cms/backoffice/media'; +import { UmbMemberItemRepository } from '@umbraco-cms/backoffice/member'; + +const elementName = 'ufm-content-name'; + +@customElement(elementName) +export class UmbUfmContentNameElement extends UmbUfmElementBase { + @property() + alias?: string; + + #documentRepository?: UmbDocumentItemRepository; + #mediaRepository?: UmbMediaItemRepository; + #memberRepository?: UmbMemberItemRepository; + + constructor() { + super(); + + this.consumeContext(UMB_UFM_RENDER_CONTEXT, (context) => { + this.observe( + context.value, + async (value) => { + const temp = + this.alias && typeof value === 'object' + ? ((value as Record)[this.alias] as string) + : (value as string); + + const entityType = Array.isArray(temp) && temp.length > 0 ? temp[0].type : null; + const uniques = Array.isArray(temp) ? temp.map((x) => x.unique) : temp ? [temp] : []; + + if (uniques?.length) { + const repository = this.#getRepository(entityType); + if (repository) { + const { data } = await repository.requestItems(uniques); + this.value = data ? data.map((item) => item.name).join(', ') : ''; + return; + } + } + + this.value = ''; + }, + 'observeValue', + ); + }); + } + + #getRepository(entityType?: string | null) { + switch (entityType) { + case 'media': + if (!this.#mediaRepository) this.#mediaRepository = new UmbMediaItemRepository(this); + return this.#mediaRepository; + + case 'member': + if (!this.#memberRepository) this.#memberRepository = new UmbMemberItemRepository(this); + return this.#memberRepository; + + case 'document': + default: + if (!this.#documentRepository) this.#documentRepository = new UmbDocumentItemRepository(this); + return this.#documentRepository; + } + } +} + +export { UmbUfmContentNameElement as element }; + +declare global { + interface HTMLElementTagNameMap { + [elementName]: UmbUfmContentNameElement; + } +} diff --git a/src/packages/ufm/components/index.ts b/src/packages/ufm/components/index.ts new file mode 100644 index 0000000000..7f1f30e709 --- /dev/null +++ b/src/packages/ufm/components/index.ts @@ -0,0 +1,3 @@ +export * from './ufm-render/index.js'; +export * from './ufm-component-base.js'; +export * from './ufm-element-base.js'; diff --git a/src/packages/ufm/components/label-value/label-value.component.ts b/src/packages/ufm/components/label-value/label-value.component.ts new file mode 100644 index 0000000000..bbadd36dc5 --- /dev/null +++ b/src/packages/ufm/components/label-value/label-value.component.ts @@ -0,0 +1,15 @@ +import type { UfmToken } from '../../plugins/marked-ufm.plugin.js'; +import { UmbUfmComponentBase } from '../ufm-component-base.js'; + +import './label-value.element.js'; + +export class UmbUfmLabelValueComponent extends UmbUfmComponentBase { + render(token: UfmToken) { + if (!token.text) return; + + const attributes = super.getAttributes(token.text); + return ``; + } +} + +export { UmbUfmLabelValueComponent as api }; diff --git a/src/packages/ufm/ufm-components/label-value.element.ts b/src/packages/ufm/components/label-value/label-value.element.ts similarity index 51% rename from src/packages/ufm/ufm-components/label-value.element.ts rename to src/packages/ufm/components/label-value/label-value.element.ts index cb28e01989..64081048ac 100644 --- a/src/packages/ufm/ufm-components/label-value.element.ts +++ b/src/packages/ufm/components/label-value/label-value.element.ts @@ -1,17 +1,14 @@ -import { UMB_UFM_RENDER_CONTEXT } from '../components/ufm-render/index.js'; -import { customElement, nothing, property, state } from '@umbraco-cms/backoffice/external/lit'; -import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UMB_UFM_RENDER_CONTEXT } from '../ufm-render/ufm-render.context.js'; +import { UmbUfmElementBase } from '../ufm-element-base.js'; +import { customElement, property } from '@umbraco-cms/backoffice/external/lit'; const elementName = 'ufm-label-value'; @customElement(elementName) -export class UmbUfmLabelValueElement extends UmbLitElement { +export class UmbUfmLabelValueElement extends UmbUfmElementBase { @property() alias?: string; - @state() - private _value?: unknown; - constructor() { super(); @@ -20,19 +17,15 @@ export class UmbUfmLabelValueElement extends UmbLitElement { context.value, (value) => { if (this.alias !== undefined && value !== undefined && typeof value === 'object') { - this._value = (value as Record)[this.alias]; + this.value = (value as Record)[this.alias]; } else { - this._value = value; + this.value = value; } }, 'observeValue', ); }); } - - override render() { - return this._value !== undefined ? this._value : nothing; - } } export { UmbUfmLabelValueElement as element }; diff --git a/src/packages/ufm/components/localize/localize.component.ts b/src/packages/ufm/components/localize/localize.component.ts new file mode 100644 index 0000000000..8789e05455 --- /dev/null +++ b/src/packages/ufm/components/localize/localize.component.ts @@ -0,0 +1,15 @@ +import type { UfmToken } from '../../plugins/marked-ufm.plugin.js'; +import { UmbUfmComponentBase } from '../ufm-component-base.js'; + +import './localize.element.js'; + +export class UmbUfmLocalizeComponent extends UmbUfmComponentBase { + render(token: UfmToken) { + if (!token.text) return; + + const attributes = super.getAttributes(token.text); + return ``; + } +} + +export { UmbUfmLocalizeComponent as api }; diff --git a/src/packages/ufm/components/localize/localize.element.ts b/src/packages/ufm/components/localize/localize.element.ts new file mode 100644 index 0000000000..9f440de11a --- /dev/null +++ b/src/packages/ufm/components/localize/localize.element.ts @@ -0,0 +1,26 @@ +import { UmbUfmElementBase } from '../ufm-element-base.js'; +import { customElement, property } from '@umbraco-cms/backoffice/external/lit'; + +const elementName = 'ufm-localize'; + +@customElement(elementName) +export class UmbUfmLocalizeElement extends UmbUfmElementBase { + @property() + public set alias(value: string | undefined) { + if (!value) return; + this.#alias = value; + this.value = this.localize.term(value); + } + public get alias(): string | undefined { + return this.#alias; + } + #alias?: string; +} + +export { UmbUfmLocalizeElement as element }; + +declare global { + interface HTMLElementTagNameMap { + [elementName]: UmbUfmLocalizeElement; + } +} diff --git a/src/packages/ufm/components/manifests.ts b/src/packages/ufm/components/manifests.ts new file mode 100644 index 0000000000..a1120024a0 --- /dev/null +++ b/src/packages/ufm/components/manifests.ts @@ -0,0 +1,25 @@ +import type { ManifestUfmComponent } from '@umbraco-cms/backoffice/extension-registry'; + +export const manifests: Array = [ + { + type: 'ufmComponent', + alias: 'Umb.Markdown.LabelValue', + name: 'Label Value UFM Component', + api: () => import('./label-value/label-value.component.js'), + meta: { marker: '=' }, + }, + { + type: 'ufmComponent', + alias: 'Umb.Markdown.Localize', + name: 'Localize UFM Component', + api: () => import('./localize/localize.component.js'), + meta: { marker: '#' }, + }, + { + type: 'ufmComponent', + alias: 'Umb.Markdown.ContentName', + name: 'Content Name UFM Component', + api: () => import('./content-name/content-name.component.js'), + meta: { marker: '~' }, + }, +]; diff --git a/src/packages/ufm/components/ufm-component-base.ts b/src/packages/ufm/components/ufm-component-base.ts new file mode 100644 index 0000000000..6b1d35217d --- /dev/null +++ b/src/packages/ufm/components/ufm-component-base.ts @@ -0,0 +1,25 @@ +import type { UfmToken } from '../plugins/marked-ufm.plugin.js'; +import type { UmbUfmComponentApi } from '@umbraco-cms/backoffice/extension-registry'; + +export abstract class UmbUfmComponentBase implements UmbUfmComponentApi { + protected getAttributes(text: string): string | null { + if (!text) return null; + + const pipeIndex = text.indexOf('|'); + + if (pipeIndex === -1) { + return `alias="${text.trim()}"`; + } + + const alias = text.substring(0, pipeIndex).trim(); + const filters = text.substring(pipeIndex + 1).trim(); + + return Object.entries({ alias, filters }) + .map(([key, value]) => (value ? `${key}="${value.trim()}"` : null)) + .join(' '); + } + + abstract render(token: UfmToken): string | undefined; + + destroy() {} +} diff --git a/src/packages/ufm/components/ufm-element-base.ts b/src/packages/ufm/components/ufm-element-base.ts new file mode 100644 index 0000000000..524d281f81 --- /dev/null +++ b/src/packages/ufm/components/ufm-element-base.ts @@ -0,0 +1,54 @@ +import { UMB_UFM_CONTEXT } from '../contexts/ufm.context.js'; +import { nothing, property, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; + +// eslint-disable-next-line local-rules/enforce-element-suffix-on-element-class-name +export abstract class UmbUfmElementBase extends UmbLitElement { + #filterFuncArgs?: Array<{ alias: string; args: Array }>; + + @property() + public set filters(value: string | undefined) { + this.#filters = value; + + this.#filterFuncArgs = value + ?.split('|') + .filter((item) => item) + .map((item) => { + const [alias, ...args] = item.split(':').map((x) => x.trim()); + return { alias, args }; + }); + } + public get filters(): string | undefined { + return this.#filters; + } + #filters?: string; + + @state() + value?: unknown; + + #ufmContext?: typeof UMB_UFM_CONTEXT.TYPE; + + constructor() { + super(); + + this.consumeContext(UMB_UFM_CONTEXT, (ufmContext) => { + this.#ufmContext = ufmContext; + }); + } + + override render() { + if (!this.#ufmContext) return nothing; + + let values = Array.isArray(this.value) ? this.value : [this.value]; + if (this.#filterFuncArgs) { + for (const item of this.#filterFuncArgs) { + const filter = this.#ufmContext.getFilterByAlias(item.alias); + if (filter) { + values = values.map((value) => filter(value, ...item.args)); + } + } + } + + return values.join(', '); + } +} diff --git a/src/packages/ufm/components/ufm-render/ufm-render.context.ts b/src/packages/ufm/components/ufm-render/ufm-render.context.ts index 1ba6e4fe14..77de0e84a0 100644 --- a/src/packages/ufm/components/ufm-render/ufm-render.context.ts +++ b/src/packages/ufm/components/ufm-render/ufm-render.context.ts @@ -1,7 +1,7 @@ import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; export class UmbUfmRenderContext extends UmbContextBase { #value = new UmbObjectState(undefined); diff --git a/src/packages/ufm/components/ufm-render/ufm-render.element.ts b/src/packages/ufm/components/ufm-render/ufm-render.element.ts index 56173caf27..6bf24160e5 100644 --- a/src/packages/ufm/components/ufm-render/ufm-render.element.ts +++ b/src/packages/ufm/components/ufm-render/ufm-render.element.ts @@ -1,43 +1,8 @@ -import type { UfmPlugin } from '../../plugins/marked-ufm.plugin.js'; -import { ufm } from '../../plugins/marked-ufm.plugin.js'; +import { UMB_UFM_CONTEXT } from '../../contexts/ufm.context.js'; import { UmbUfmRenderContext } from './ufm-render.context.js'; import { css, customElement, nothing, property, unsafeHTML, until } from '@umbraco-cms/backoffice/external/lit'; -import { DOMPurify } from '@umbraco-cms/backoffice/external/dompurify'; -import { Marked } from '@umbraco-cms/backoffice/external/marked'; -import type { UmbExtensionApiInitializer } from '@umbraco-cms/backoffice/extension-api'; -import { UmbExtensionsApiInitializer } from '@umbraco-cms/backoffice/extension-api'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; -import type { ManifestUfmComponent } from '@umbraco-cms/backoffice/extension-registry'; - -const UmbDomPurify = DOMPurify(window); -const UmbDomPurifyConfig: DOMPurify.Config = { - USE_PROFILES: { html: true }, - CUSTOM_ELEMENT_HANDLING: { - tagNameCheck: /^(?:ufm|umb|uui)-.*$/, - attributeNameCheck: /.+/, - allowCustomizedBuiltInElements: false, - }, -}; - -UmbDomPurify.addHook('afterSanitizeAttributes', function (node) { - // set all elements owning target to target=_blank - if ('target' in node) { - node.setAttribute('target', '_blank'); - } -}); - -export const UmbMarked = new Marked({ - async: true, - gfm: true, - breaks: true, - hooks: { - postprocess: (markup) => { - return UmbDomPurify.sanitize(markup, UmbDomPurifyConfig) as string; - }, - }, -}); const elementName = 'umb-ufm-render'; @@ -59,26 +24,13 @@ export class UmbUfmRenderElement extends UmbLitElement { return this.#context.getValue(); } + #ufmContext?: typeof UMB_UFM_CONTEXT.TYPE; + constructor() { super(); - new UmbExtensionsApiInitializer(this, umbExtensionsRegistry, 'ufmComponent', [], undefined, (controllers) => { - UmbMarked.use( - ufm( - controllers - .map((controller) => { - const ctrl = controller as unknown as UmbExtensionApiInitializer; - if (!ctrl.manifest || !ctrl.api) return; - return { - alias: ctrl.manifest.alias, - marker: ctrl.manifest.meta.marker, - render: ctrl.api.render, - }; - }) - .filter((x) => x) as Array, - ), - ); - this.requestUpdate('markdown'); + this.consumeContext(UMB_UFM_CONTEXT, (ufmContext) => { + this.#ufmContext = ufmContext; }); } @@ -87,8 +39,8 @@ export class UmbUfmRenderElement extends UmbLitElement { } async #renderMarkdown() { - if (!this.markdown) return null; - const markup = !this.inline ? await UmbMarked.parse(this.markdown) : await UmbMarked.parseInline(this.markdown); + if (!this.#ufmContext || !this.markdown) return null; + const markup = await this.#ufmContext.parse(this.markdown, this.inline); return markup ? unsafeHTML(markup) : nothing; } diff --git a/src/packages/ufm/contexts/index.ts b/src/packages/ufm/contexts/index.ts new file mode 100644 index 0000000000..dcc7961ddc --- /dev/null +++ b/src/packages/ufm/contexts/index.ts @@ -0,0 +1 @@ +export * from './ufm.context.js'; diff --git a/src/packages/ufm/contexts/manifest.ts b/src/packages/ufm/contexts/manifest.ts new file mode 100644 index 0000000000..98c747273f --- /dev/null +++ b/src/packages/ufm/contexts/manifest.ts @@ -0,0 +1,8 @@ +import type { ManifestGlobalContext } from '@umbraco-cms/backoffice/extension-registry'; + +export const manifest: ManifestGlobalContext = { + type: 'globalContext', + alias: 'Umb.GlobalContext.Ufm', + name: 'UFM Context', + api: () => import('./ufm.context.js'), +}; diff --git a/src/packages/ufm/contexts/ufm.context.ts b/src/packages/ufm/contexts/ufm.context.ts new file mode 100644 index 0000000000..4a8512fc41 --- /dev/null +++ b/src/packages/ufm/contexts/ufm.context.ts @@ -0,0 +1,96 @@ +import { ufm } from '../plugins/marked-ufm.plugin.js'; +import type { UfmPlugin } from '../plugins/marked-ufm.plugin.js'; +import { DOMPurify } from '@umbraco-cms/backoffice/external/dompurify'; +import { Marked } from '@umbraco-cms/backoffice/external/marked'; +import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api'; +import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; +import { UmbExtensionsApiInitializer } from '@umbraco-cms/backoffice/extension-api'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import type { ManifestUfmFilter, ManifestUfmComponent } from '@umbraco-cms/backoffice/extension-registry'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbExtensionApiInitializer } from '@umbraco-cms/backoffice/extension-api'; + +const UmbDomPurify = DOMPurify(window); +const UmbDomPurifyConfig: DOMPurify.Config = { + USE_PROFILES: { html: true }, + CUSTOM_ELEMENT_HANDLING: { + tagNameCheck: /^(?:ufm|umb|uui)-.*$/, + attributeNameCheck: /.+/, + allowCustomizedBuiltInElements: false, + }, +}; + +UmbDomPurify.addHook('afterSanitizeAttributes', function (node) { + // set all elements owning target to target=_blank + if ('target' in node) { + node.setAttribute('target', '_blank'); + } +}); + +export const UmbMarked = new Marked({ + async: true, + gfm: true, + breaks: true, + hooks: { + postprocess: (markup) => { + return UmbDomPurify.sanitize(markup, UmbDomPurifyConfig) as string; + }, + }, +}); + +type UmbUfmFilterType = { + alias: string; + filter: ((...args: Array) => string | undefined | null) | undefined; +}; + +export class UmbUfmContext extends UmbContextBase { + #filters = new UmbArrayState([], (x) => x.alias); + public readonly filters = this.#filters.asObservable(); + + constructor(host: UmbControllerHost) { + super(host, UMB_UFM_CONTEXT); + + new UmbExtensionsApiInitializer(this, umbExtensionsRegistry, 'ufmComponent', [], undefined, (controllers) => { + UmbMarked.use( + ufm( + controllers + .map((controller) => { + const ctrl = controller as unknown as UmbExtensionApiInitializer; + if (!ctrl.manifest || !ctrl.api) return; + return { + alias: ctrl.manifest.alias, + marker: ctrl.manifest.meta.marker, + render: ctrl.api.render, + }; + }) + .filter((x) => x) as Array, + ), + ); + }); + + new UmbExtensionsApiInitializer(this, umbExtensionsRegistry, 'ufmFilter', [], undefined, (controllers) => { + const filters = controllers + .map((controller) => { + const ctrl = controller as unknown as UmbExtensionApiInitializer; + if (!ctrl.manifest || !ctrl.api) return null; + return { alias: ctrl.manifest.meta.alias, filter: ctrl.api.filter }; + }) + .filter((x) => x) as Array; + + this.#filters.setValue(filters); + }); + } + + public getFilterByAlias(alias: string) { + return this.#filters.getValue().find((x) => x.alias === alias)?.filter; + } + + public async parse(markdown: string, inline: boolean) { + return !inline ? await UmbMarked.parse(markdown) : await UmbMarked.parseInline(markdown); + } +} + +export const UMB_UFM_CONTEXT = new UmbContextToken('UmbUfmContext'); + +export { UmbUfmContext as api }; diff --git a/src/packages/ufm/filters/fallback.filter.ts b/src/packages/ufm/filters/fallback.filter.ts new file mode 100644 index 0000000000..ceb8001754 --- /dev/null +++ b/src/packages/ufm/filters/fallback.filter.ts @@ -0,0 +1,9 @@ +import { UmbUfmFilterBase } from '../types.js'; + +class UmbUfmFallbackFilterApi extends UmbUfmFilterBase { + filter(str: string, fallback: string) { + return typeof str !== 'string' || str ? str : fallback; + } +} + +export { UmbUfmFallbackFilterApi as api }; diff --git a/src/packages/ufm/filters/lowercase.filter.ts b/src/packages/ufm/filters/lowercase.filter.ts new file mode 100644 index 0000000000..bf175a2a65 --- /dev/null +++ b/src/packages/ufm/filters/lowercase.filter.ts @@ -0,0 +1,9 @@ +import { UmbUfmFilterBase } from '../types.js'; + +class UmbUfmLowercaseFilterApi extends UmbUfmFilterBase { + filter(str?: string) { + return str?.toLocaleLowerCase(); + } +} + +export { UmbUfmLowercaseFilterApi as api }; diff --git a/src/packages/ufm/filters/manifests.ts b/src/packages/ufm/filters/manifests.ts new file mode 100644 index 0000000000..c80c5ffd35 --- /dev/null +++ b/src/packages/ufm/filters/manifests.ts @@ -0,0 +1,53 @@ +import type { ManifestUfmFilter } from '@umbraco-cms/backoffice/extension-registry'; + +export const manifests: Array = [ + { + type: 'ufmFilter', + alias: 'Umb.Filter.Fallback', + name: 'Fallback UFM Filter', + api: () => import('./fallback.filter.js'), + meta: { alias: 'fallback' }, + }, + { + type: 'ufmFilter', + alias: 'Umb.Filter.Lowercase', + name: 'Lowercase UFM Filter', + api: () => import('./lowercase.filter.js'), + meta: { alias: 'lowercase' }, + }, + { + type: 'ufmFilter', + alias: 'Umb.Filter.StripHtml', + name: 'Strip HTML UFM Filter', + api: () => import('./strip-html.filter.js'), + meta: { alias: 'strip-html' }, + }, + { + type: 'ufmFilter', + alias: 'Umb.Filter.TitleCase', + name: 'Title Case UFM Filter', + api: () => import('./title-case.filter.js'), + meta: { alias: 'title-case' }, + }, + { + type: 'ufmFilter', + alias: 'Umb.Filter.Truncate', + name: 'Truncate UFM Filter', + api: () => import('./truncate.filter.js'), + meta: { alias: 'truncate' }, + }, + { + type: 'ufmFilter', + alias: 'Umb.Filter.Uppercase', + name: 'Uppercase UFM Filter', + api: () => import('./uppercase.filter.js'), + meta: { alias: 'uppercase' }, + }, + { + type: 'ufmFilter', + alias: 'Umb.Filter.WordLimit', + name: 'Word Limit UFM Filter', + api: () => import('./word-limit.filter.js'), + meta: { alias: 'word-limit' }, + }, +]; diff --git a/src/packages/ufm/filters/strip-html.filter.ts b/src/packages/ufm/filters/strip-html.filter.ts new file mode 100644 index 0000000000..6d19708e59 --- /dev/null +++ b/src/packages/ufm/filters/strip-html.filter.ts @@ -0,0 +1,15 @@ +import { UmbUfmFilterBase } from '../types.js'; + +class UmbUfmStripHtmlFilterApi extends UmbUfmFilterBase { + filter(value: string | { markup: string } | undefined | null) { + if (!value) return ''; + + const markup = typeof value === 'object' && Object.hasOwn(value, 'markup') ? value.markup : (value as string); + const parser = new DOMParser(); + const doc = parser.parseFromString(markup, 'text/html'); + + return doc.body.textContent ?? ''; + } +} + +export { UmbUfmStripHtmlFilterApi as api }; diff --git a/src/packages/ufm/filters/title-case.filter.ts b/src/packages/ufm/filters/title-case.filter.ts new file mode 100644 index 0000000000..ab546b7ee3 --- /dev/null +++ b/src/packages/ufm/filters/title-case.filter.ts @@ -0,0 +1,9 @@ +import { UmbUfmFilterBase } from '../types.js'; + +class UmbUfmTitleCaseFilterApi extends UmbUfmFilterBase { + filter(str?: string) { + return str?.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.substring(1).toLowerCase()); + } +} + +export { UmbUfmTitleCaseFilterApi as api }; diff --git a/src/packages/ufm/filters/truncate.filter.ts b/src/packages/ufm/filters/truncate.filter.ts new file mode 100644 index 0000000000..34360ae41d --- /dev/null +++ b/src/packages/ufm/filters/truncate.filter.ts @@ -0,0 +1,14 @@ +import { UmbUfmFilterBase } from '../types.js'; + +class UmbUfmTruncateFilterApi extends UmbUfmFilterBase { + filter(str: string, length: number, tail?: string) { + if (typeof str !== 'string' || !str.length) return str; + if (tail === 'false') tail = ''; + if (tail === 'true') tail = '…'; + tail = !tail && tail !== '' ? '…' : tail; + + return str.slice(0, length).trim() + tail; + } +} + +export { UmbUfmTruncateFilterApi as api }; diff --git a/src/packages/ufm/filters/uppercase.filter.ts b/src/packages/ufm/filters/uppercase.filter.ts new file mode 100644 index 0000000000..76af187aa9 --- /dev/null +++ b/src/packages/ufm/filters/uppercase.filter.ts @@ -0,0 +1,9 @@ +import { UmbUfmFilterBase } from '../types.js'; + +class UmbUfmUppercaseFilterApi extends UmbUfmFilterBase { + filter(str?: string) { + return str?.toLocaleUpperCase(); + } +} + +export { UmbUfmUppercaseFilterApi as api }; diff --git a/src/packages/ufm/filters/word-limit.filter.ts b/src/packages/ufm/filters/word-limit.filter.ts new file mode 100644 index 0000000000..8924386638 --- /dev/null +++ b/src/packages/ufm/filters/word-limit.filter.ts @@ -0,0 +1,10 @@ +import { UmbUfmFilterBase } from '../types.js'; + +class UmbUfmWordLimitFilterApi extends UmbUfmFilterBase { + filter(str: string, limit: number) { + const words = str?.split(/\s+/) ?? []; + return limit && words.length > limit ? words.slice(0, limit).join(' ') : str; + } +} + +export { UmbUfmWordLimitFilterApi as api }; diff --git a/src/packages/ufm/index.ts b/src/packages/ufm/index.ts index 6ef5cd49bd..56ba93470c 100644 --- a/src/packages/ufm/index.ts +++ b/src/packages/ufm/index.ts @@ -1,3 +1,4 @@ -export * from './components/ufm-render/index.js'; -export * from './plugins/marked-ufm.plugin.js'; -export * from './ufm-components/ufm-component-base.js'; +export * from './types.js'; +export * from './components/index.js'; +export * from './contexts/index.js'; +export * from './plugins/index.js'; diff --git a/src/packages/ufm/manifests.ts b/src/packages/ufm/manifests.ts index ec604e0842..930ae37b3a 100644 --- a/src/packages/ufm/manifests.ts +++ b/src/packages/ufm/manifests.ts @@ -1,18 +1,6 @@ -import type { ManifestUfmComponent } from '@umbraco-cms/backoffice/extension-registry'; +import { manifest as ufmContext } from './contexts/manifest.js'; +import { manifests as ufmComponents } from './components/manifests.js'; +import { manifests as ufmFilters } from './filters/manifests.js'; +import type { ManifestTypes } from '@umbraco-cms/backoffice/extension-registry'; -export const manifests: Array = [ - { - type: 'ufmComponent', - alias: 'Umb.Markdown.LabelValue', - name: 'Label Value UFM Component', - api: () => import('./ufm-components/label-value.component.js'), - meta: { marker: '=' }, - }, - { - type: 'ufmComponent', - alias: 'Umb.Markdown.Localize', - name: 'Localize UFM Component', - api: () => import('./ufm-components/localize.component.js'), - meta: { marker: '#' }, - }, -]; +export const manifests: Array = [ufmContext, ...ufmComponents, ...ufmFilters]; diff --git a/src/packages/ufm/plugins/index.ts b/src/packages/ufm/plugins/index.ts new file mode 100644 index 0000000000..09836813d3 --- /dev/null +++ b/src/packages/ufm/plugins/index.ts @@ -0,0 +1,2 @@ +export { ufm } from './marked-ufm.plugin.js'; +export type { UfmPlugin, UfmToken } from './marked-ufm.plugin.js'; diff --git a/src/packages/ufm/plugins/marked-ufm.plugin.ts b/src/packages/ufm/plugins/marked-ufm.plugin.ts index 65b57912a0..edebdefcca 100644 --- a/src/packages/ufm/plugins/marked-ufm.plugin.ts +++ b/src/packages/ufm/plugins/marked-ufm.plugin.ts @@ -12,7 +12,8 @@ export interface UfmToken extends Tokens.Generic { /** * - * @param plugins + * @param {Array} plugins - An array of UFM plugins. + * @returns {MarkedExtension} A Marked extension object. */ export function ufm(plugins: Array = []): MarkedExtension { return { diff --git a/src/packages/ufm/plugins/marked-ufm.test.ts b/src/packages/ufm/plugins/marked-ufm.test.ts index ef01944675..71a2177ddb 100644 --- a/src/packages/ufm/plugins/marked-ufm.test.ts +++ b/src/packages/ufm/plugins/marked-ufm.test.ts @@ -1,8 +1,9 @@ import { expect } from '@open-wc/testing'; import { ufm } from './marked-ufm.plugin.js'; -import { UmbMarked } from '../index.js'; -import { UmbUfmLabelValueComponent } from '../ufm-components/label-value.component.js'; -import { UmbUfmLocalizeComponent } from '../ufm-components/localize.component.js'; +import { UmbMarked } from '../contexts/ufm.context.js'; +import { UmbUfmContentNameComponent } from '../components/content-name/content-name.component.js'; +import { UmbUfmLabelValueComponent } from '../components/label-value/label-value.component.js'; +import { UmbUfmLocalizeComponent } from '../components/localize/localize.component.js'; describe('UmbMarkedUfm', () => { describe('UFM parsing', () => { @@ -11,12 +12,18 @@ describe('UmbMarkedUfm', () => { { ufm: '{= prop1}', expected: '' }, { ufm: '{= prop1 }', expected: '' }, { ufm: '{{=prop1}}', expected: '{}' }, - { ufm: '{#general_add}', expected: '' }, + { + ufm: '{= prop1 | strip-html | truncate:30}', + expected: '', + }, + { ufm: '{#general_add}', expected: '' }, + { ufm: '{~contentPicker}', expected: '' }, ]; // Manually configuring the UFM components for testing. UmbMarked.use( ufm([ + { alias: 'Umb.Markdown.ContentName', marker: '~', render: new UmbUfmContentNameComponent().render }, { alias: 'Umb.Markdown.LabelValue', marker: '=', render: new UmbUfmLabelValueComponent().render }, { alias: 'Umb.Markdown.Localize', marker: '#', render: new UmbUfmLocalizeComponent().render }, ]), diff --git a/src/packages/ufm/types.ts b/src/packages/ufm/types.ts new file mode 100644 index 0000000000..94c6966cc1 --- /dev/null +++ b/src/packages/ufm/types.ts @@ -0,0 +1,6 @@ +import type { UmbUfmFilterApi } from '@umbraco-cms/backoffice/extension-registry'; + +export abstract class UmbUfmFilterBase implements UmbUfmFilterApi { + abstract filter(...args: Array): string | undefined | null; + destroy() {} +} diff --git a/src/packages/ufm/ufm-components/document-name.component.ts b/src/packages/ufm/ufm-components/document-name.component.ts deleted file mode 100644 index 8b7e5ec47e..0000000000 --- a/src/packages/ufm/ufm-components/document-name.component.ts +++ /dev/null @@ -1,12 +0,0 @@ -// import { UmbUfmComponentBase } from './ufm-component-base.js'; -// import type { Tokens } from '@umbraco-cms/backoffice/external/marked'; - -// import './document-name.element.js'; - -// export class UmbUfmDocumentNameComponent extends UmbUfmComponentBase { -// render(token: Tokens.Generic) { -// return ``; -// } -// } - -// export { UmbUfmDocumentNameComponent as api }; diff --git a/src/packages/ufm/ufm-components/document-name.element.ts b/src/packages/ufm/ufm-components/document-name.element.ts deleted file mode 100644 index d4f59d15cc..0000000000 --- a/src/packages/ufm/ufm-components/document-name.element.ts +++ /dev/null @@ -1,54 +0,0 @@ -// import { UMB_UFM_RENDER_CONTEXT } from '@umbraco-cms/backoffice/components'; -// import { UmbDocumentItemRepository } from '@umbraco-cms/backoffice/document'; -// import { customElement, property, state } from '@umbraco-cms/backoffice/external/lit'; -// import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; - -// const elementName = 'ufm-document-name'; - -// @customElement(elementName) -// export class UmbUfmDocumentNameElement extends UmbLitElement { -// @property() -// alias?: string; - -// @state() -// private _value?: unknown; - -// #documentRepository = new UmbDocumentItemRepository(this); - -// constructor() { -// super(); - -// this.consumeContext(UMB_UFM_RENDER_CONTEXT, (context) => { -// this.observe( -// context.value, -// async (value) => { -// if (!value) return; - -// const unique = -// this.alias && typeof value === 'object' -// ? ((value as Record)[this.alias] as string) -// : (value as string); - -// if (!unique) return; - -// const { data } = await this.#documentRepository.requestItems([unique]); - -// this._value = data?.[0]?.name; -// }, -// 'observeValue', -// ); -// }); -// } - -// override render() { -// return this._value ?? this.alias; -// } -// } - -// export { UmbUfmDocumentNameElement as element }; - -// declare global { -// interface HTMLElementTagNameMap { -// [elementName]: UmbUfmDocumentNameElement; -// } -// } diff --git a/src/packages/ufm/ufm-components/label-value.component.ts b/src/packages/ufm/ufm-components/label-value.component.ts deleted file mode 100644 index 7fa6b5d815..0000000000 --- a/src/packages/ufm/ufm-components/label-value.component.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { UfmToken } from '../plugins/marked-ufm.plugin.js'; -import { UmbUfmComponentBase } from './ufm-component-base.js'; - -import './label-value.element.js'; - -export class UmbUfmLabelValueComponent extends UmbUfmComponentBase { - render(token: UfmToken) { - if (!token.text) return; - return ``; - } -} - -export { UmbUfmLabelValueComponent as api }; diff --git a/src/packages/ufm/ufm-components/localize.component.ts b/src/packages/ufm/ufm-components/localize.component.ts deleted file mode 100644 index 79890f75f5..0000000000 --- a/src/packages/ufm/ufm-components/localize.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { UfmToken } from '../plugins/marked-ufm.plugin.js'; -import { UmbUfmComponentBase } from './ufm-component-base.js'; - -export class UmbUfmLocalizeComponent extends UmbUfmComponentBase { - render(token: UfmToken) { - if (!token.text) return; - return ``; - } -} - -export { UmbUfmLocalizeComponent as api }; diff --git a/src/packages/ufm/ufm-components/ufm-component-base.ts b/src/packages/ufm/ufm-components/ufm-component-base.ts deleted file mode 100644 index 71f552363e..0000000000 --- a/src/packages/ufm/ufm-components/ufm-component-base.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { UfmToken } from '../plugins/marked-ufm.plugin.js'; -import type { UmbUfmComponentApi } from '@umbraco-cms/backoffice/extension-registry'; - -export abstract class UmbUfmComponentBase implements UmbUfmComponentApi { - abstract render(token: UfmToken): string | undefined; - destroy() {} -}